<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>绿色记忆 &#187; Work</title>
	<atom:link href="https://blog.gmem.cc/category/work/feed" rel="self" type="application/rss+xml" />
	<link>https://blog.gmem.cc</link>
	<description></description>
	<lastBuildDate>Fri, 03 Apr 2026 04:13:36 +0000</lastBuildDate>
	<language>en-US</language>
		<sy:updatePeriod>hourly</sy:updatePeriod>
		<sy:updateFrequency>1</sy:updateFrequency>
	<generator>http://wordpress.org/?v=3.9.14</generator>
	<item>
		<title>人工智能知识速查（理论）</title>
		<link>https://blog.gmem.cc/ai-knowledge-quick-ref</link>
		<comments>https://blog.gmem.cc/ai-knowledge-quick-ref#comments</comments>
		<pubDate>Fri, 03 Apr 2026 03:15:04 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[AI]]></category>
		<category><![CDATA[Algorithm]]></category>
		<category><![CDATA[Math]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=40317</guid>
		<description><![CDATA[<p>基础数学 代数基础 运算律与代数式 代数式（Algebraic Expression）由常数、变量与运算构成；给定变量取值后即可求值。化简（Simplification）的目标是把表达式写成更可读、更便于推导/比较的等价形式：合并同类项（Like Terms）、提取公因子（Common Factor）、展开（Expansion）与因式分解（Factorization）是最常见的操作。 运算律（Algebraic Laws）本质上是在某个数系/代数结构（例如实数域）中成立的恒等式（Identity）；它们允许在不改变值的前提下重排/重写表达式。需要区分：加法/乘法满足交换律与结合律，但减法/除法一般不满足。 性质 公式 备注 交换律（Commutativity） 不适用于 、 结合律（Associativity） 允许不改变括号结构地分组 分配律（Distributivity） 展开与提因式的核心 恒等元（Identity Element） 0 <a class="read-more" href="https://blog.gmem.cc/ai-knowledge-quick-ref">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/ai-knowledge-quick-ref">人工智能知识速查（理论）</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">基础数学</span></div>
<div class="blog_h2"><span class="graybg">代数基础</span></div>
<div class="blog_h3"><span class="graybg">运算律与代数式</span></div>
<p>代数式（Algebraic Expression）由常数、变量与运算构成；给定变量取值后即可求值。化简（Simplification）的目标是把表达式写成更可读、更便于推导/比较的等价形式：合并同类项（Like Terms）、提取公因子（Common Factor）、展开（Expansion）与因式分解（Factorization）是最常见的操作。</p>
<p>运算律（Algebraic Laws）本质上是在某个数系/代数结构（例如实数域）中成立的恒等式（Identity）；它们允许在不改变值的前提下重排/重写表达式。需要区分：加法/乘法满足交换律与结合律，但减法/除法一般不满足。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">性质</td>
<td style="text-align: center;">公式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>交换律（Commutativity）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a+b=b+a\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(ab=ba\)</span></td>
<td>不适用于 <span displaypfx="inline-" class="mathjax-container">\(a-b\)</span>、<span displaypfx="inline-" class="mathjax-container">\(a/b\)</span></td>
</tr>
<tr>
<td>结合律（Associativity）</td>
<td><span displaypfx="inline-" class="mathjax-container">\((a+b)+c=a+(b+c)\)</span><br /><span displaypfx="inline-" class="mathjax-container">\((ab)c=a(bc)\)</span></td>
<td>允许不改变括号结构地分组</td>
</tr>
<tr>
<td>分配律（Distributivity）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a(b+c)=ab+ac\)</span></td>
<td>展开与提因式的核心</td>
</tr>
<tr>
<td>恒等元（Identity Element）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a+0=a\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(a\cdot 1=a\)</span></td>
<td>0 与 1 在代数推导中常被隐式使用</td>
</tr>
<tr>
<td>逆元（Inverse）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a+(-a)=0\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(a\cdot a^{-1}=1\)</span></td>
<td>第二式要求 <span displaypfx="inline-" class="mathjax-container">\(a\ne 0\)</span></td>
</tr>
<tr>
<td>零乘积原则（Zero Product）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(ab=0\Rightarrow a=0\ \text{或}\ b=0\)</span></td>
<td>把方程从“乘积=0”转为“因子=0”</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">因式分解（Factorization）</span></div>
<p>因式分解（Factorization）把一个表达式改写成若干因子（Factor）的乘积。它的直接价值是：把“值为 0 / 符号变化 / 约束条件”转成对各因子的分析。与之对偶的是展开（Expansion）：把乘积写成和式。</p>
<p>对多项式（Polynomial）<span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span>，根（Root/Zero）与线性因子之间存在精确对应：若 <span displaypfx="inline-" class="mathjax-container">\(p(r)=0\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\((x-r)\)</span> 是 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span> 的因子（因子定理（Factor Theorem））。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">套路/恒等式</td>
<td style="text-align: center;">形式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>提公因子（Common Factor）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(ax+ay=a(x+y)\)</span></td>
<td>先把最大公因子提出来</td>
</tr>
<tr>
<td>平方差（Difference of Squares）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^2-b^2=(a-b)(a+b)\)</span></td>
<td>常用于构造可约因子</td>
</tr>
<tr>
<td>完全平方（Perfect Square）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^2\pm 2ab+b^2=(a\pm b)^2\)</span></td>
<td>与配方（Completing the Square）等价</td>
</tr>
<tr>
<td>立方和/差（Sum/Difference of Cubes）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^3-b^3=(a-b)(a^2+ab+b^2)\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(a^3+b^3=(a+b)(a^2-ab+b^2)\)</span></td>
<td>二次因子在实数域可能不可再分</td>
</tr>
<tr>
<td>二次式按根分解</td>
<td><span displaypfx="inline-" class="mathjax-container">\(ax^2+bx+c=a(x-r_1)(x-r_2)\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(r_1,r_2\)</span> 可为复数</td>
</tr>
<tr>
<td>分组分解（Grouping）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(ax+ay+bx+by=(a+b)(x+y)\)</span></td>
<td>目标是制造共同因子</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">多项式（Polynomial）</span></div>
<p>一元多项式（Univariate Polynomial）是形如 <span displaypfx="inline-" class="mathjax-container">\(p(x)=\sum_{k=0}^{n} a_k x^k\)</span> 的函数，其中 <span displaypfx="inline-" class="mathjax-container">\(a_k\)</span> 是系数（Coefficient），<span displaypfx="inline-" class="mathjax-container">\(n\)</span> 是次数（Degree）。多项式在实数域上处处可导、可积；在局部逼近（例如 Taylor）与特征构造中非常常用。</p>
<p>多元多项式（Multivariate Polynomial）可写成对多重指数（Multi-index）求和：若 <span displaypfx="inline-" class="mathjax-container">\(x\in\mathbb{R}^d\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[p(x)=\sum_{\alpha\in\mathbb{N}^d} c_\alpha\,x^\alpha,\quad x^\alpha=\prod_{i=1}^{d} x_i^{\alpha_i}\]</span>
<p>补充：多元二次多项式（Quadratic Polynomial）的纯二次部分在线性代数里通常称为二次型（Quadratic Form），可写成矩阵形式 <span displaypfx="inline-" class="mathjax-container">\(q(\mathbf{x})=\mathbf{x}^\top A\mathbf{x}\)</span>；其中 <span displaypfx="inline-" class="mathjax-container">\(x_ix_j\)</span>（例如 <span displaypfx="inline-" class="mathjax-container">\(xy\)</span>）是交叉项（Cross Term）。</p>
<p>在机器学习里，<span style="background-color: #c0c0c0;">多项式特征（Polynomial Features）</span>把输入映射到包含高阶项的特征空间，等价于显式构造某些核函数（Kernel）的有限维版本。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">概念</td>
<td style="text-align: center;">表述</td>
<td style="text-align: center;">用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>首项/首项系数（Leading Term/Coefficient）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a_n x^n\)</span> / <span displaypfx="inline-" class="mathjax-container">\(a_n\)</span></td>
<td>决定远端增长阶与符号</td>
</tr>
<tr>
<td>余数定理（Remainder Theorem）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(p(x)=q(x)(x-a)+p(a)\)</span></td>
<td>快速计算 <span displaypfx="inline-" class="mathjax-container">\(p(a)\)</span></td>
</tr>
<tr>
<td>因子定理（Factor Theorem）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(p(a)=0\Leftrightarrow (x-a)\mid p(x)\)</span></td>
<td>把“根”与“线性因子”连接起来</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">一元二次方程（Quadratic Equation）</span></div>
<p>一元二次方程（Quadratic Equation）标准形式为 <span displaypfx="inline-" class="mathjax-container">\(ax^2+bx+c=0\)</span>（<span displaypfx="inline-" class="mathjax-container">\(a\ne 0\)</span>）。核心量是判别式（Discriminant）<span displaypfx="inline-" class="mathjax-container">\(\Delta=b^2-4ac\)</span>：它决定解的个数与类型。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">结论</td>
<td style="text-align: center;">公式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>求根公式（Quadratic Formula）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(x=\frac{-b\pm\sqrt{\Delta}}{2a}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\Delta&lt;0\)</span> 时根为共轭复数</td>
</tr>
<tr>
<td>配方（Completing the Square）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(ax^2+bx+c=a\left(x+\frac{b}{2a}\right)^2-\frac{\Delta}{4a}\)</span></td>
<td>同时给出顶点与最值</td>
</tr>
<tr>
<td>顶点（Vertex）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(x_v=-\frac{b}{2a},\quad f(x_v)=-\frac{\Delta}{4a}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a&gt;0\)</span> 时为全局最小；<span displaypfx="inline-" class="mathjax-container">\(a&lt;0\)</span> 时为全局最大</td>
</tr>
<tr>
<td>韦达定理（Vieta）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(r_1+r_2=-\frac{b}{a},\quad r_1r_2=\frac{c}{a}\)</span></td>
<td>无需显式求根即可得到对称量</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">分式与有理式（Rational Expressions）</span></div>
<p>分式/有理式（Rational Expression）是两个多项式之比：<span displaypfx="inline-" class="mathjax-container">\(\frac{p(x)}{q(x)}\)</span>，并要求 <span displaypfx="inline-" class="mathjax-container">\(q(x)\ne 0\)</span>。任何化简都必须保留定义域（Domain）约束：约分（Cancellation）只是在允许的点上重写表达式，不会“把不可取值点变得可取”。</p>
<p>典型例子： <span displaypfx="inline-" class="mathjax-container">\(\frac{x^2-1}{x-1}=\frac{(x-1)(x+1)}{x-1}=x+1\)</span>，但仍需强调 <span displaypfx="inline-" class="mathjax-container">\(x\ne 1\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(x=1\)</span> 是可去间断点（Removable Discontinuity）：原式无定义，而约分后的表达式在该点有值。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">操作</td>
<td style="text-align: center;">规则</td>
<td style="text-align: center;">要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>加减</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{a}{b}\pm\frac{c}{d}=\frac{ad\pm bc}{bd}\)</span></td>
<td>先通分（Common Denominator）</td>
</tr>
<tr>
<td>乘除</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{a}{b}\cdot\frac{c}{d}=\frac{ac}{bd}\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\frac{a}{b}\div\frac{c}{d}=\frac{a}{b}\cdot\frac{d}{c}\)</span></td>
<td>除法要求 <span displaypfx="inline-" class="mathjax-container">\(c\ne 0\)</span></td>
</tr>
<tr>
<td>约分</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{(x-r)u(x)}{(x-r)v(x)}=\frac{u(x)}{v(x)}\)</span></td>
<td>仍需保留 <span displaypfx="inline-" class="mathjax-container">\(x\ne r\)</span></td>
</tr>
<tr>
<td>符号分析</td>
<td>把数轴按零点/极点分段</td>
<td>有理不等式常用“区间符号表”</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">绝对值与分段（Absolute Value &amp; Piecewise）</span></div>
<p>绝对值（Absolute Value）<span displaypfx="inline-" class="mathjax-container">\(|x|\)</span> 表示到 0 的距离（Distance to Zero）。它把“正负”信息丢掉，只保留大小；因此绝对值相关方程/不等式通常要通过分段（Piecewise）把符号情况拆开讨论。</p>
<span displaypfx="" class="mathjax-container">\[|x|=\begin{cases}x,&amp; x\ge 0\\ -x,&amp; x&lt;0\end{cases}\]</span>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">形式</td>
<td style="text-align: center;">等价条件</td>
<td style="text-align: center;">前提</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(|x|=a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x=a\ \text{或}\ x=-a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a\ge 0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(|x|&lt;a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(-a&lt;x&lt;a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a&gt;0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(|x|\le a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(-a\le x\le a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a\ge 0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(|x|\ge a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x\le -a\ \text{或}\ x\ge a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a\ge 0\)</span></td>
</tr>
</tbody>
</table>
<p>三角不等式（Triangle Inequality）<span displaypfx="inline-" class="mathjax-container">\(|x+y|\le |x|+|y|\)</span> 是绝对值最重要的性质之一；它把“求和后的误差”上界化为“各自误差的和”，在误差分析与泛化界推导中高频出现。</p>
<p>深度学习里常见的 ReLU、hinge loss 等都是分段函数：<span style="background-color: #c0c0c0;">分段点处通常不可导，但仍可用次梯度（Subgradient）做优化</span>。</p>
<div class="blog_h3"><span class="graybg">常用代数技巧：配方 / 换元 / 估计</span></div>
<div class="blog_h4"><span class="graybg">配方（Completing the Square）</span></div>
<p>配方（Completing the Square）把二次式改写成“平方 + 常数”，从而直接读出最值、解的结构与可行区间：</p>
<span displaypfx="" class="mathjax-container">\[ax^2+bx+c=a\left(x+\frac{b}{2a}\right)^2-\frac{b^2-4ac}{4a}\]</span>
<p>例如 <span displaypfx="inline-" class="mathjax-container">\(x^2+6x+5=(x+3)^2-4\)</span>，因此方程 <span displaypfx="inline-" class="mathjax-container">\(x^2+6x+5=0\)</span> 等价于 <span displaypfx="inline-" class="mathjax-container">\((x+3)^2=4\)</span>，解为 <span displaypfx="inline-" class="mathjax-container">\(x=-1,-5\)</span>。</p>
<div class="blog_h4"><span class="graybg">换元（Substitution）</span></div>
<p>换元（Substitution）通过引入新变量，把原问题变成更低复杂度的标准形式，尤其适用于“重复结构”。例如：</p>
<span displaypfx="" class="mathjax-container">\[x^4-5x^2+4=0,\ \text{令 }u=x^2\Rightarrow u^2-5u+4=0\]</span>
<p>解得 <span displaypfx="inline-" class="mathjax-container">\(u=1,4\)</span>，再回代得到 <span displaypfx="inline-" class="mathjax-container">\(x=\pm 1,\pm 2\)</span>。</p>
<div class="blog_h4"><span class="graybg">估计（Bounding / Estimation）</span></div>
<p>估计（Bounding/Estimation）常把表达式改写为“非负项 + 常数”，或利用单调性把复杂项夹逼到可控区间。最常见的来源是“平方非负”（<span displaypfx="inline-" class="mathjax-container">\((\cdot)^2\ge 0\)</span>）：</p>
<span displaypfx="" class="mathjax-container">\[(x-1)^2\ge 0\Rightarrow x^2+1\ge 2x,\quad (|x|-1)^2\ge 0\Rightarrow x^2+1\ge 2|x|\]</span>
<p>这类估计在证明最值、构造上界/下界、以及把损失函数改写成“凸的主项 + 可控余项”时很有效。</p>
<div class="blog_h3"><span class="graybg">数系（Number Systems）</span></div>
<p>数系（Number Systems）描述“允许使用哪些数，以及这些数上哪些运算是封闭的”。常见链条是</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{N}\subset \mathbb{Z}\subset \mathbb{Q}\subset \mathbb{R}\subset \mathbb{C}\]</span>
<p>其中实数（Real Numbers）是有序（Ordered）且完备（Complete）的；复数（Complex Numbers）扩展了方程可解性（例如 <span displaypfx="inline-" class="mathjax-container">\(x^2+1=0\)</span> 在实数无解，但在复数有解）。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">数系</td>
<td style="text-align: center;">记号</td>
<td style="text-align: center;">典型元素</td>
<td style="text-align: center;">结构要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>自然数（Natural Numbers）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{N}\)</span></td>
<td>0,1,2,…（是否含 0 取决于约定）</td>
<td>对加法/乘法封闭；一般不可做减法/除法</td>
</tr>
<tr>
<td>整数（Integers）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{Z}\)</span></td>
<td>…,-2,-1,0,1,2,…</td>
<td>对加减乘封闭；除法不封闭</td>
</tr>
<tr>
<td>有理数（Rational Numbers）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{Q}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(p/q\)</span>（<span displaypfx="inline-" class="mathjax-container">\(p,q\in\mathbb{Z},q\ne 0\)</span>）</td>
<td>域（Field）：非零元素存在乘法逆元</td>
</tr>
<tr>
<td>实数（Real Numbers）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span></td>
<td>包含无理数（Irrational），如 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{2},\pi\)</span></td>
<td>有序完备域；极限/连续的基础</td>
</tr>
<tr>
<td>复数（Complex Numbers）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{C}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a+bi\)</span></td>
<td>代数闭包（Algebraic Closure）；不可定义全序</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">函数基础</span></div>
<div class="blog_h3"><span class="graybg">定义域与值域</span></div>
<p>函数（Function）是映射：把输入集合中的每个元素映到一个输出。定义域（Domain）是允许的输入集合；值域（Range/Image）是实际能取到的输出集合（通常是陪域（Codomain）的子集）。常用记法：</p>
<span displaypfx="" class="mathjax-container">\[f:D\to Y,\quad x\mapsto f(x)\]</span>
<p>从表达式读定义域的常见约束：</p>
<ul>
<li>分母不为 0： <span displaypfx="inline-" class="mathjax-container">\(\frac{p(x)}{q(x)}\)</span> 要求 <span displaypfx="inline-" class="mathjax-container">\(q(x)\ne 0\)</span>。</li>
<li>偶次根非负： <span displaypfx="inline-" class="mathjax-container">\(\sqrt{g(x)}\)</span> 要求 <span displaypfx="inline-" class="mathjax-container">\(g(x)\ge 0\)</span>（实数域）。</li>
<li>对数正数： <span displaypfx="inline-" class="mathjax-container">\(\ln g(x)\)</span> 要求 <span displaypfx="inline-" class="mathjax-container">\(g(x)&gt;0\)</span>。</li>
</ul>
<p>值域分析常用“解方程 + 约束”思路：令 <span displaypfx="inline-" class="mathjax-container">\(y=f(x)\)</span>，把 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 表示成 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 并推导可行条件；若 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 在某区间单调，则可用反函数直接得到值域。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">函数</td>
<td style="text-align: center;">定义域</td>
<td style="text-align: center;">值域</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\sqrt{x}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x\ge 0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(y\ge 0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span></td>
</tr>
<tr>
<td>sigmoid（<span displaypfx="inline-" class="mathjax-container">\(\sigma(z)=\frac{1}{1+e^{-z}}\)</span>）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\tanh z\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\((-1,1)\)</span></td>
</tr>
<tr>
<td>ReLU（<span displaypfx="inline-" class="mathjax-container">\(\max(0,z)\)</span>）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\([0,+\infty)\)</span></td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">复合函数</span></div>
<p>复合函数（Function Composition）把一个函数的输出作为另一个函数的输入：若 <span displaypfx="inline-" class="mathjax-container">\(g:D\to E\)</span>、<span displaypfx="inline-" class="mathjax-container">\(f:E\to Y\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[(f\circ g)(x)=f(g(x))\]</span>
<p>定义域必须同时满足两层约束： <span displaypfx="inline-" class="mathjax-container">\(x\in\mathrm{dom}(g)\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(g(x)\in\mathrm{dom}(f)\)</span>。例如 <span displaypfx="inline-" class="mathjax-container">\(f(x)=\sqrt{x}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(g(x)=x^2-1\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\((f\circ g)(x)=\sqrt{x^2-1}\)</span> 的定义域是 <span displaypfx="inline-" class="mathjax-container">\(|x|\ge 1\)</span>。</p>
<p>复合满足结合律（Associativity）：<span displaypfx="inline-" class="mathjax-container">\((f\circ g)\circ h=f\circ(g\circ h)\)</span>，但一般不满足交换律： <span displaypfx="inline-" class="mathjax-container">\(f\circ g\ne g\circ f\)</span>。</p>
<div class="blog_h3"><span class="graybg">反函数</span></div>
<p>反函数（Inverse Function）把映射“倒过来”。若 <span displaypfx="inline-" class="mathjax-container">\(f:D\to Y\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 上是双射（Bijection），则存在 <span displaypfx="inline-" class="mathjax-container">\(f^{-1}:\mathrm{range}(f)\to D\)</span> 满足</p>
<span displaypfx="" class="mathjax-container">\[f^{-1}(f(x))=x,\quad f(f^{-1}(y))=y\]</span>
<p>注意 <span displaypfx="inline-" class="mathjax-container">\(f^{-1}\)</span> 表示反函数，不是倒数 <span displaypfx="inline-" class="mathjax-container">\(1/f\)</span>。求反函数的常用步骤是：设 <span displaypfx="inline-" class="mathjax-container">\(y=f(x)\)</span>，交换 <span displaypfx="inline-" class="mathjax-container">\(x,y\)</span> 并解出 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>。</p>
<p>例：线性函数 <span displaypfx="inline-" class="mathjax-container">\(y=ax+b\)</span>（<span displaypfx="inline-" class="mathjax-container">\(a\ne 0\)</span>）的反函数是 <span displaypfx="inline-" class="mathjax-container">\(f^{-1}(y)=\frac{y-b}{a}\)</span>。sigmoid 的反函数是 logit：若 <span displaypfx="inline-" class="mathjax-container">\(p=\sigma(z)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(z=\log\frac{p}{1-p}\)</span>（要求 <span displaypfx="inline-" class="mathjax-container">\(p\in(0,1)\)</span>）。</p>
<p>若函数不单调或不可一一对应（如 <span displaypfx="inline-" class="mathjax-container">\(f(x)=x^2\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span> 上），则必须限制定义域（例如限制为 <span displaypfx="inline-" class="mathjax-container">\(x\ge 0\)</span>）才能得到真正的反函数。</p>
<div class="blog_h3"><span class="graybg">奇偶性与单调性</span></div>
<p>奇偶性（Parity）描述对称性：偶函数（Even Function）满足 <span displaypfx="inline-" class="mathjax-container">\(f(-x)=f(x)\)</span>（关于 y 轴对称），奇函数（Odd Function）满足 <span displaypfx="inline-" class="mathjax-container">\(f(-x)=-f(x)\)</span>（关于原点对称）。</p>
<p>单调性（Monotonicity）描述“随输入增加，输出是否不减/不增”。在区间 <span displaypfx="inline-" class="mathjax-container">\(I\)</span> 上：</p>
<ul>
<li>单调递增（Monotone Increasing）：<span displaypfx="inline-" class="mathjax-container">\(x_1&lt;x_2\Rightarrow f(x_1)\le f(x_2)\)</span>。</li>
<li>严格递增（Strictly Increasing）：<span displaypfx="inline-" class="mathjax-container">\(x_1&lt;x_2\Rightarrow f(x_1)&lt;f(x_2)\)</span>。</li>
<li>单调递减/严格递减同理。</li>
</ul>
<p>单调函数在区间上必为单射（Injective），因此在该区间上可定义反函数。这也是为什么很多“不可逆”的函数（如 <span displaypfx="inline-" class="mathjax-container">\(x^2\)</span>）在限制到某个单调区间后就变得可逆。</p>
<div class="blog_h3"><span class="graybg">凸性与凹性</span></div>
<p>凸性（Convexity）是优化与泛化分析的核心几何性质。函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 在区间/凸集上是凸函数（Convex Function），当且仅当对任意 <span displaypfx="inline-" class="mathjax-container">\(x_1,x_2\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\lambda\in[0,1]\)</span> 都有</p>
<span displaypfx="" class="mathjax-container">\[f(\lambda x_1+(1-\lambda)x_2)\le \lambda f(x_1)+(1-\lambda)f(x_2)\]</span>
<p>凹函数（Concave Function）则把不等号方向反过来。几何上：凸函数“弦在图像上方”，凹函数“弦在图像下方”。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 二阶可导，则一维判别很简单： <span displaypfx="inline-" class="mathjax-container">\(f''(x)\ge 0\)</span> 则凸， <span displaypfx="inline-" class="mathjax-container">\(f''(x)\le 0\)</span> 则凹；多变量情形把 <span displaypfx="inline-" class="mathjax-container">\(f''\)</span> 替换为 Hessian，要求其半正定/半负定。</p>
<p>典型例子： <span displaypfx="inline-" class="mathjax-container">\(x^2\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(e^x\)</span> 是凸函数；<span displaypfx="inline-" class="mathjax-container">\(\log x\)</span>（<span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span>）是凹函数。很多经典损失（如 MSE、logistic loss）对模型输出是凸的，但对深度网络参数整体通常非凸。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/convex.jpg"><img class="alignnone size-full wp-image-40553" src="https://blog.gmem.cc/wp-content/uploads/2026/03/convex.jpg" alt="convex" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">方程与超平面</span></div>
<div class="blog_h3"><span class="graybg">线性方程（Ax + By + C = 0）</span></div>
<p>二维平面中，线性方程（Linear Equation）<span displaypfx="inline-" class="mathjax-container">\(Ax+By+C=0\)</span>（<span displaypfx="inline-" class="mathjax-container">\((A,B)\ne(0,0)\)</span>）表示一条直线（Line）。向量 <span displaypfx="inline-" class="mathjax-container">\((A,B)\)</span> 是法向量（Normal Vector）：它与直线方向垂直；常数项 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 控制沿法向量方向的平移。</p>
<p>该形式与超平面形式 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\cdot\mathbf{x}+b=0\)</span> 完全一致：只需取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x,y)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}=(A,B)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(b=C\)</span>。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">等价形式</td>
<td style="text-align: center;">表达式</td>
<td style="text-align: center;">条件/说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>斜截式（Slope-Intercept）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(y=mx+b\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(B\ne 0\)</span> 时 <span displaypfx="inline-" class="mathjax-container">\(m=-A/B,\ b=-C/B\)</span></td>
</tr>
<tr>
<td>截距（Intercepts）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(x\text{-截距}=-C/A\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(y\text{-截距}=-C/B\)</span></td>
<td>分别要求 <span displaypfx="inline-" class="mathjax-container">\(A\ne 0\)</span>、<span displaypfx="inline-" class="mathjax-container">\(B\ne 0\)</span></td>
</tr>
<tr>
<td>点法式（Point-Normal）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\cdot(\mathbf{x}-\mathbf{x}_0)=0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_0\)</span> 是直线上一点</td>
</tr>
<tr>
<td>点到直线距离</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathrm{dist}=\frac{|Ax_0+By_0+C|}{\sqrt{A^2+B^2}}\)</span></td>
<td>来自把点沿法向量投影到直线</td>
</tr>
</tbody>
</table>
<p>两条直线相交/平行可由法向量判断：若 <span displaypfx="inline-" class="mathjax-container">\((A_1,B_1)\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\((A_2,B_2)\)</span> 共线，则两直线平行（或重合）；否则相交，交点可由 2×2 线性方程组求解。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/linear-equation-geometry.jpg"><img class="alignnone size-full wp-image-40541" src="https://blog.gmem.cc/wp-content/uploads/2026/03/linear-equation-geometry.jpg" alt="linear-equation-geometry" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">超平面（w·x + b = 0）</span></div>
<p>超平面（Hyperplane）是高维空间中的“线性边界”。在 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^d\)</span> 中，方程</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{w}\cdot \mathbf{x}+b=0\]</span>
<p>定义一个 <span displaypfx="inline-" class="mathjax-container">\((d-1)\)</span> 维的仿射子空间（Affine Subspace）。其中 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 是法向量（Normal Vector），决定边界的朝向；<span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是偏置（Bias），决定边界沿法向量方向的平移。</p>
<p>工程与论文里常写成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top \mathbf{x}+b\)</span>：这里的转置（Transpose）符号 <span displaypfx="inline-" class="mathjax-container">\(^\top\)</span> 只是为了把列向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 变成行向量，从而与列向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 做矩阵乘法；数值上它等价于点积：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{w}^\top \mathbf{x}=\sum_{i=1}^{d} w_i x_i\]</span>
<p>例：令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}=(2,3)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(4,5)^\top\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top \mathbf{x}=2\cdot4+3\cdot5=23\)</span>。</p>
<p>在机器学习里，线性分类器（Linear Classifier）可写成 <span displaypfx="inline-" class="mathjax-container">\(\hat y=\mathrm{sign}(\mathbf{w}^\top\mathbf{x}+b)\)</span>；逻辑回归（Logistic Regression）把它送入 sigmoid： <span displaypfx="inline-" class="mathjax-container">\(p(y=1|\mathbf{x})=\sigma(\mathbf{w}^\top\mathbf{x}+b)\)</span>。</p>
<div class="blog_h3"><span class="graybg">法向量与半空间</span></div>
<p>超平面把空间划分成两个半空间（Half-space）：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{w}\cdot\mathbf{x}+b\ge 0,\quad \mathbf{w}\cdot\mathbf{x}+b\le 0\]</span>
<p>法向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 指向“值更大”的一侧：沿 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 方向移动会增大 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\cdot\mathbf{x}+b\)</span>。这也是为什么在线性分类里，得分的正负号自然对应类别划分。</p>
<div class="blog_h3"><span class="graybg">点到超平面的距离</span></div>
<p>点 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_0\)</span> 到超平面 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\cdot\mathbf{x}+b=0\)</span> 的欧氏距离（Euclidean Distance）为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}(\mathbf{x}_0,\ \mathbf{w}\cdot\mathbf{x}+b=0)=\frac{|\mathbf{w}\cdot\mathbf{x}_0+b|}{\|\mathbf{w}\|_2}\]</span>
<p>推导直觉：把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_0\)</span> 沿法向量方向投影到超平面上；分子是“沿法向量方向的带符号位移”，除以 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|\)</span> 把它变成真实距离。</p>
<div class="blog_h3"><span class="graybg">约束函数、梯度与法向量</span></div>
<p>约束优化中的边界通常由一个定义在整个空间上的标量函数（Scalar Function）<span displaypfx="inline-" class="mathjax-container">\(g(x)\)</span> 给出，并通过等值方程 <span displaypfx="inline-" class="mathjax-container">\(g(x)=0\)</span> 表示边界本身。函数 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 是求导对象；边界则是满足该方程的点集。梯度（Gradient）算子 <span displaypfx="inline-" class="mathjax-container">\(\nabla\)</span> 作用在约束函数 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 上，由此把边界的法向几何信息编码为一个向量场。</p>
<p>线性超平面的情形最直接。边界写成</p>
<span displaypfx="" class="mathjax-container">\[g(\mathbf{x})=\mathbf{w}^\top\mathbf{x}+b=0\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 是定义在整个 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^d\)</span> 上的线性函数，而边界只是它的零等值面（Zero Level Set）。由于线性函数的一阶导数处处相同，立刻得到</p>
<span displaypfx="" class="mathjax-container">\[\nabla g(\mathbf{x})=\mathbf{w}\]</span>
<p>因此，线性超平面的法向量是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span>，本质上等价于“定义该超平面的约束函数 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 的梯度等于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span>”。</p>
<p>这一结论对一般光滑边界同样成立。设 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 是边界 <span displaypfx="inline-" class="mathjax-container">\(g(x)=0\)</span> 上一点，且 <span displaypfx="inline-" class="mathjax-container">\(\nabla g(x^*)\ne 0\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 是该点处的切向量（Tangent Vector），则沿着 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 做无穷小移动仍停留在同一条边界上，因而 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 的一阶变化为 0：</p>
<span displaypfx="" class="mathjax-container">\[\frac{d}{d\epsilon}g(x^*+\epsilon t)\Big|_{\epsilon=0}=0\]</span>
<p>应用链式法则（Chain Rule）得到</p>
<span displaypfx="" class="mathjax-container">\[\nabla g(x^*)^\top t=0\]</span>
<p>这表示边界上的任意切向量都与 <span displaypfx="inline-" class="mathjax-container">\(\nabla g(x^*)\)</span> 正交。因此 <span displaypfx="inline-" class="mathjax-container">\(\nabla g(x^*)\)</span> 沿法向方向指向边界外侧或内侧，而不沿边界本身滑动；梯度正是边界法向量的解析表达。</p>
<p>KKT 条件中的 <span displaypfx="inline-" class="mathjax-container">\(\nabla g(x^*)\)</span> 正是以这种方式出现的。约束优化不是对“边界这个集合”求导，而是借助约束函数 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 的梯度，把边界的法向几何结构写入一阶最优性条件。驻点条件</p>
<span displaypfx="" class="mathjax-container">\[\nabla f(x^*)+\lambda^*\nabla g(x^*)=0\]</span>
<p>表达的是：在活跃边界上，目标函数剩余的下降趋势完全落在约束边界的法向空间里，并与乘子加权后的法向量达到平衡。</p>
<div class="blog_h2"><span class="graybg">基础几何（Basic Geometry）</span></div>
<p>解析几何（Analytic Geometry）、向量（Vector）、三角函数（Trigonometric Functions）以及很多 AI 中的空间直觉，都建立在更基础的几何概念上。这里先把最常用的几块地基补齐：距离、角度、弧度、比例与面积。</p>
<div class="blog_h3"><span class="graybg">点、线、角与角度单位</span></div>
<p>平面几何最基本的对象是点（Point）、线段（Segment）、直线（Line）与角（Angle）。角度描述两条射线的张开程度；常见有两种单位：</p>
<span displaypfx="" class="mathjax-container">\[360^\circ=2\pi\ \text{rad},\quad 180^\circ=\pi\ \text{rad},\quad 90^\circ=\frac{\pi}{2}\ \text{rad}\]</span>
<p>度数（Degree）更适合日常表达，弧度（Radian）更适合数学推导，因为它和圆弧长度、三角函数、导数公式天然兼容。后面遇到旋转矩阵（Rotation Matrix）、复数极坐标（Polar Form）、傅里叶分析（Fourier Analysis）时，默认几乎都使用弧度。</p>
<div class="blog_h3"><span class="graybg">勾股定理与欧氏距离</span></div>
<p>勾股定理（Pythagorean Theorem）是平面距离公式的根源。对直角三角形，若两条直角边长为 <span displaypfx="inline-" class="mathjax-container">\(a,b\)</span>，斜边长为 <span displaypfx="inline-" class="mathjax-container">\(c\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[a^2+b^2=c^2\]</span>
<p>把它应用到坐标平面，就得到两点之间的欧氏距离（Euclidean Distance）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}(P_1,P_2)=\sqrt{(x_1-x_2)^2+(y_1-y_2)^2}\]</span>
<p>在高维空间里，这个公式直接推广为 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}-\mathbf{y}\|_2\)</span>。因此从二维几何到机器学习里的向量距离，本质上是一条连续的概念链。KNN、K-means、embedding 检索、对比学习（Contrastive Learning）都在反复使用这套“距离越小越相似”的几何直觉。</p>
<div class="blog_h3"><span class="graybg">弧度、弧长与扇形</span></div>
<p>弧度的定义不是“人为约定一个新单位”，而是直接由圆弧长度导出的：若半径为 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 的圆上有一段弧长 <span displaypfx="inline-" class="mathjax-container">\(s\)</span>，对应圆心角为 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>（弧度），则</p>
<span displaypfx="" class="mathjax-container">\[\theta=\frac{s}{r},\quad s=r\theta\]</span>
<p>对应扇形面积（Sector Area）是：</p>
<span displaypfx="" class="mathjax-container">\[A=\frac{1}{2}r^2\theta\]</span>
<p>这正是弧度“自然”的原因：一旦用弧度记角，弧长与面积公式会变得非常干净。AI 里很多周期性表示都默认使用弧度输入，例如 <span displaypfx="inline-" class="mathjax-container">\(\sin\theta\)</span> / <span displaypfx="inline-" class="mathjax-container">\(\cos\theta\)</span> 的位置编码（Positional Encoding）、旋转位置编码（RoPE）以及频域特征（Fourier Features）。</p>
<div class="blog_h3"><span class="graybg">相似、缩放与比例</span></div>
<p>相似（Similarity）指图形形状相同、大小可以不同；等价地说，对应角相等、对应边成比例。若把一个图形按比例 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 缩放，则长度变为原来的 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 倍，面积变为原来的 <span displaypfx="inline-" class="mathjax-container">\(k^2\)</span> 倍。</p>
<p>这个看似初等的事实，在 AI 图像处理中极其常见：图片 resize、本征尺度（Scale）、特征金字塔（Feature Pyramid）、多尺度检测（Multi-scale Detection）都在处理“同一对象在不同尺度下如何保持可识别性”的问题。若缩放时不保持纵横比（Aspect Ratio），就会引入几何畸变，进而影响分类、检测与分割结果。</p>
<div class="blog_h3"><span class="graybg">面积、重叠与 IoU</span></div>
<p>基础几何里，面积（Area）衡量二维区域所占的大小。矩形面积是长乘宽，圆面积是</p>
<span displaypfx="" class="mathjax-container">\[A=\pi r^2\]</span>
<p>在 AI 的目标检测（Object Detection）与实例分割（Instance Segmentation）中，一个高频几何量是交并比（Intersection over Union, IoU）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{IoU}=\frac{\text{Intersection Area}}{\text{Union Area}}\]</span>
<p>它衡量预测框/预测区域与真实标注的重叠程度。这里用到的不是高深数学，而是最朴素的面积与重叠概念。</p>
<div class="blog_h3"><span class="graybg">基础几何和AI</span></div>
<p>若把这些内容压缩成一句话，它们在 AI 中分别承担不同角色：</p>
<ul>
<li>距离（Distance）：支撑近邻搜索、聚类、向量检索与损失函数中的相似度刻画。</li>
<li>角度（Angle）：支撑方向、夹角、余弦相似度（Cosine Similarity）与旋转直觉。</li>
<li>弧度（Radian）：支撑三角函数、周期建模、位置编码与频域表示。</li>
<li>比例与缩放（Scale）：支撑图像 resize、数据增强、特征金字塔与多尺度建模。</li>
<li>面积与重叠（Area &amp; Overlap）：支撑 IoU、检测框评估与分割质量度量。</li>
</ul>
<div class="blog_h2"><span class="graybg">解析几何（Analytic Geometry）</span></div>
<p>解析几何（Analytic Geometry）的核心做法是：选定坐标系（Coordinate System），用代数方程描述几何对象。一个几何对象可以被理解为“满足某个方程（或方程组）的所有点”的集合。</p>
<p>高中阶段最常见的两类：</p>
<ul>
<li>一次方程：直线（Line）/平面（Plane）/超平面（Hyperplane）。</li>
<li>二次方程：圆（Circle）与圆锥曲线（Conic Sections）；在三维中推广为二次曲面（Quadric Surfaces）。</li>
</ul>
<div class="blog_h3"><span class="graybg">坐标、距离与圆</span></div>
<p>在直角坐标系（Cartesian Coordinate System）中，点 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 用坐标 <span displaypfx="inline-" class="mathjax-container">\((x,y)\)</span> 表示。两点 <span displaypfx="inline-" class="mathjax-container">\(P_1(x_1,y_1)\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(P_2(x_2,y_2)\)</span> 的欧氏距离（Euclidean Distance）是：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}(P_1,P_2)=\sqrt{(x_1-x_2)^2+(y_1-y_2)^2}\]</span>
<p>圆（Circle）是到某个固定点距离恒定的点集；这个固定点叫圆心（Center）。若圆心为 <span displaypfx="inline-" class="mathjax-container">\((h,k)\)</span>、半径（Radius）为 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>，则圆的方程是：</p>
<span displaypfx="" class="mathjax-container">\[(x-h)^2+(y-k)^2=r^2\]</span>
<p>例：方程 <span displaypfx="inline-" class="mathjax-container">\(x^2+y^2-4x+6y-12=0\)</span> 通过配方（Completing the Square）可化为 <span displaypfx="inline-" class="mathjax-container">\((x-2)^2+(y+3)^2=25\)</span>，因此它表示圆心 <span displaypfx="inline-" class="mathjax-container">\((2,-3)\)</span>、半径 <span displaypfx="inline-" class="mathjax-container">\(5\)</span> 的圆。</p>
<div class="blog_h3"><span class="graybg">圆锥曲线（Conic Sections）：二次方程的几何形状</span></div>
<p>圆锥曲线（Conic Sections）最初来自“平面截圆锥”的几何构造，但在解析几何里，它们等价于二维的二次方程曲线（Second-degree Plane Curves，方程里变量的最高次数为 2）：</p>
<span displaypfx="" class="mathjax-container">\[Ax^2+Bxy+Cy^2+Dx+Ey+F=0\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(Bxy\)</span> 是交叉项（Cross Term）。交叉项的存在通常意味着曲线的主轴（Principal Axes）与坐标轴不对齐；通过旋转坐标轴（Rotation of Axes）可以把交叉项消掉，从而得到更“标准”的形状表达。</p>
<p>而一次项 <span displaypfx="inline-" class="mathjax-container">\(Dx+Ey\)</span> 与常数项 <span displaypfx="inline-" class="mathjax-container">\(F\)</span> 扮演的是另一类角色：它们通常不改变主轴方向，而主要影响图形在平面中的<span style="background-color: #c0c0c0;">位置与尺度</span>。更具体地说，一次项往往意味着曲线的中心/顶点不在原点；在消去交叉项之后，再通过平移坐标（Translation of Axes）与配方（Completing the Square）可以把一次项吸收到平方项里。常数项则相当于改变“等号右边的阈值”，会影响曲线是否有实点、整体大小以及离原点的偏置。简言之：<span style="background-color: #c0c0c0;">交叉项主要对应旋转，一次项主要对应平移，常数项主要对应整体偏移/尺度调整</span>。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/ellipse-term-effects.png"><img class="alignnone size-full wp-image-40767" src="https://blog.gmem.cc/wp-content/uploads/2026/03/ellipse-term-effects.png" alt="ellipse-term-effects" width="100%" /></a></p>
<p>下面给出最常用的四类圆锥曲线的标准方程（Standard Form）与直观定义：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">名称</td>
<td style="text-align: center;">标准方程</td>
<td style="text-align: center;">几何定义（直观）</td>
<td style="text-align: center;">形状关键词</td>
</tr>
</thead>
<tbody>
<tr>
<td>圆（Circle）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(x^2+y^2=r^2\)</span></td>
<td>到圆心距离恒为 <span displaypfx="inline-" class="mathjax-container">\(r\)</span></td>
<td>闭合；各向同性</td>
</tr>
<tr>
<td>椭圆（Ellipse）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{x^2}{a^2}+\frac{y^2}{b^2}=1\ (a\ge b&gt;0)\)</span></td>
<td>到两个焦点（Focus）距离和为常数</td>
<td>闭合；主轴/次轴</td>
</tr>
<tr>
<td>抛物线（Parabola）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(y^2=4px\ (p&gt;0)\)</span></td>
<td>到焦点（Focus）与准线（Directrix）距离相等</td>
<td>开口；无中心</td>
</tr>
<tr>
<td>双曲线（Hyperbola）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{x^2}{a^2}-\frac{y^2}{b^2}=1\)</span></td>
<td>到两个焦点（Focus）距离差的绝对值为常数</td>
<td>两支；有渐近线（Asymptotes）</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">椭圆（Ellipse）：两焦点“距离和恒定”</span></div>
<p>椭圆（Ellipse）可以用一句话定义：平面内到两个固定点 <span displaypfx="inline-" class="mathjax-container">\(F_1,F_2\)</span> 的距离之和为常数的点的集合。若该常数为 <span displaypfx="inline-" class="mathjax-container">\(2a\)</span>，则对椭圆上任意点 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 有：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}(P,F_1)+\mathrm{dist}(P,F_2)=2a\]</span>
<p>在以原点为中心、长轴沿 x 轴的标准位置下，椭圆方程是：</p>
<span displaypfx="" class="mathjax-container">\[\frac{x^2}{a^2}+\frac{y^2}{b^2}=1,\quad a\ge b&gt;0\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 分别是半长轴（Semi-major Axis）与半短轴（Semi-minor Axis）。焦距参数 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 定义为焦点到中心的距离，满足</p>
<span displaypfx="" class="mathjax-container">\[c^2=a^2-b^2\]</span>
<p>因此焦点坐标是 <span displaypfx="inline-" class="mathjax-container">\((\pm c,0)\)</span>。偏心率（Eccentricity）定义为 <span displaypfx="inline-" class="mathjax-container">\(e=c/a\)</span>，它量化“椭圆有多扁”： <span displaypfx="inline-" class="mathjax-container">\(e\in[0,1)\)</span>；当 <span displaypfx="inline-" class="mathjax-container">\(a=b\)</span> 时 <span displaypfx="inline-" class="mathjax-container">\(e=0\)</span>，椭圆退化为圆。</p>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(a=5,b=3\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(c=4\)</span>、<span displaypfx="inline-" class="mathjax-container">\(e=0.8\)</span>，椭圆为 <span displaypfx="inline-" class="mathjax-container">\(\frac{x^2}{25}+\frac{y^2}{9}=1\)</span>，焦点为 <span displaypfx="inline-" class="mathjax-container">\((\pm 4,0)\)</span>。</p>
<div class="blog_h3"><span class="graybg">三维推广：二次曲面（Quadric Surfaces）</span></div>
<p>在三维中，圆锥曲线推广为二次曲面（Quadric Surfaces）：满足三元二次方程的点集。最一般的形式是：</p>
<span displaypfx="" class="mathjax-container">\[Ax^2+By^2+Cz^2+Dxy+Exz+Fyz+Gx+Hy+Iz+J=0\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(Dxy,Exz,Fyz\)</span> 是交叉项（Cross Term），对应“坐标轴没有对齐到曲面的主轴方向”。通过平移（Translation）与旋转（Rotation）可以把它化为标准型（Standard Form）：平移等价于把原点挪到合适的位置（通常是“中心”附近），旋转等价于把坐标轴转到主轴方向，从而一眼看出是“球/椭球/抛物面/双曲面”等哪一类。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">名称</td>
<td style="text-align: center;">典型方程（标准型）</td>
<td style="text-align: center;">直观描述</td>
</tr>
</thead>
<tbody>
<tr>
<td>球（Sphere）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(x^2+y^2+z^2=r^2\)</span></td>
<td>到中心距离恒定的点集（3D 的圆）</td>
</tr>
<tr>
<td>椭球（Ellipsoid）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{x^2}{a^2}+\frac{y^2}{b^2}+\frac{z^2}{c^2}=1\)</span></td>
<td>三个方向缩放不同的“球”；仍然闭合</td>
</tr>
<tr>
<td>椭圆抛物面（Elliptic Paraboloid）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(z=\frac{x^2}{a^2}+\frac{y^2}{b^2}\)</span></td>
<td>“碗状”；水平截面是椭圆</td>
</tr>
<tr>
<td>双曲抛物面（Hyperbolic Paraboloid）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(z=\frac{x^2}{a^2}-\frac{y^2}{b^2}\)</span></td>
<td>“马鞍形”；沿一个方向上凸、另一个方向下凹</td>
</tr>
<tr>
<td>单叶双曲面（Hyperboloid of One Sheet）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{x^2}{a^2}+\frac{y^2}{b^2}-\frac{z^2}{c^2}=1\)</span></td>
<td>连通的一张曲面；截面随方向变化</td>
</tr>
<tr>
<td>双叶双曲面（Hyperboloid of Two Sheets）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(-\frac{x^2}{a^2}-\frac{y^2}{b^2}+\frac{z^2}{c^2}=1\)</span></td>
<td>上下分离的两张曲面</td>
</tr>
</tbody>
</table>
<p>二次曲面与优化中的“二次型/曲率”是同一套数学语言：把坐标轴旋到主轴方向后，表达式会变成各轴平方项的加权和/差，从而直接暴露“碗状（局部最小）”与“鞍形（Saddle）”的结构。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-quadric.png"><img class="alignnone size-full wp-image-40777" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-quadric.png" alt="plot-quadric" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">指数函数</span></div>
<p>指数函数（Exponential Function）最常用的是自然指数 <span displaypfx="inline-" class="mathjax-container">\(e^x\)</span>。指数运算把“加法结构”映射为“乘法结构”，对数（Logarithm）作为反函数则把乘法结构拉平成加法结构。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">性质/公式</td>
<td style="text-align: center;">表达式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>指数基本性质（Exponential Laws）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{a+b}=e^a e^b\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(e^{a-b}=\frac{e^a}{e^b}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a,b\in\mathbb{R}\)</span></td>
</tr>
<tr>
<td>指数运算律（Exponent Rules）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^0=1\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(a^m a^n=a^{m+n}\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\frac{a^m}{a^n}=a^{m-n}\)</span><br /><span displaypfx="inline-" class="mathjax-container">\((a^m)^n=a^{mn}\)</span><br /><span displaypfx="inline-" class="mathjax-container">\((ab)^n=a^n b^n\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a&gt;0,a\ne 1\)</span>；对整数指数最直接</td>
</tr>
<tr>
<td>自然常数（Euler's Number）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(e=\lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n\)</span></td>
<td>极限刻画连续复利（Continuous Compounding）</td>
</tr>
<tr>
<td>微积分性质</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{d}{dx}e^x=e^x\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\frac{d}{dx}\ln x=\frac{1}{x}\)</span></td>
<td>以 <span displaypfx="inline-" class="mathjax-container">\(e\)</span> 为底时形式最简</td>
</tr>
<tr>
<td>连续增长微分方程</td>
<td><span displaypfx="inline-" class="mathjax-container">\(y'(t)=y(t),\ y(0)=1\Rightarrow y(t)=e^t\)</span></td>
<td>“增长率与当前值成正比”</td>
</tr>
<tr>
<td>与对数互逆</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln(e^x)=x\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(e^{\ln x}=x\)</span></td>
<td>第二式要求 <span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span></td>
</tr>
</tbody>
</table>
<p>常用取值：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">输入</td>
<td style="text-align: center;"><span displaypfx="inline-" class="mathjax-container">\(e^x\)</span></td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x=0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^0=1\)</span></td>
<td>基准点</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x=1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^1=e\approx 2.71828\)</span></td>
<td>自然对数底</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x=-1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{-1}=\frac{1}{e}\approx 0.36788\)</span></td>
<td>常见衰减尺度</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x=\ln 2\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{\ln 2}=2\)</span></td>
<td>对数域与线性域互换时常用</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x=\ln 10\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{\ln 10}=10\)</span></td>
<td>与 <span displaypfx="inline-" class="mathjax-container">\(\log_{10}\)</span> 换底相关</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">对数函数</span></div>
<p>对数函数（Logarithm）<span displaypfx="inline-" class="mathjax-container">\(\log x\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span> 上严格单调递增（Strictly Increasing），因此 <span displaypfx="inline-" class="mathjax-container">\(-\log x\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span> 上严格单调递减（Strictly Decreasing）。</p>
<p>复合后的单调性由内层决定：若 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 在某区间上单调递增且 <span displaypfx="inline-" class="mathjax-container">\(f(x)&gt;0\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(-\log(f(x))\)</span> 在该区间上单调递减；若 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 不单调，则外层单调并不能推出整体单调。</p>
<p>在损失函数里常见的形式是 <span displaypfx="inline-" class="mathjax-container">\(-\log\sigma(z)\)</span>（<span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 为 sigmoid）。因为 sigmoid 单调递增，所以该损失对 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 单调递减：增大 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 会降低损失。需要注意的是，这只说明“对中间量 z 的单调性”；对模型参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的单调性一般不成立，因为 <span displaypfx="inline-" class="mathjax-container">\(z=z(\theta)\)</span> 是高维非线性函数。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">性质/公式</td>
<td style="text-align: center;">表达式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>对数运算律（Logarithm Rules）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\log(ab)=\log a+\log b\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\log\!\left(\frac{a}{b}\right)=\log a-\log b\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\log(a^k)=k\log a\)</span></td>
<td>同一底数；典型要求 <span displaypfx="inline-" class="mathjax-container">\(a&gt;0,b&gt;0\)</span></td>
</tr>
<tr>
<td>换底公式（Change of Base）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\log_a x=\frac{\ln x}{\ln a}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a&gt;0,a\ne 1,x&gt;0\)</span></td>
</tr>
<tr>
<td>与指数互逆</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^{\log_a x}=x\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\log_a(a^x)=x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span></td>
</tr>
</tbody>
</table>
<p>常用取值：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">表达式</td>
<td style="text-align: center;">值</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln 1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x=1\)</span> 为基准点</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln e\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(1\)</span></td>
<td>自然对数的定义性质</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln 2\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\approx 0.6931\)</span></td>
<td>二进制相关常数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln 10\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\approx 2.3026\)</span></td>
<td>十进制相关常数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\log_{10}2\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(=\frac{\ln 2}{\ln 10}\approx 0.3010\)</span></td>
<td>工程里常用于数量级估算</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\log_2 10\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(=\frac{\ln 10}{\ln 2}\approx 3.3219\)</span></td>
<td>bit 与十进制数量级换算</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\log_2 e\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(=\frac{1}{\ln 2}\approx 1.4427\)</span></td>
<td>nats 与 bits 换算常数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\log_{10}e\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(=\frac{1}{\ln 10}\approx 0.4343\)</span></td>
<td>自然对数与常用对数换算</td>
</tr>
</tbody>
</table>
<p>在语言模型 softmax 中，logit 经过指数再归一化：<span displaypfx="inline-" class="mathjax-container">\(\exp(\text{logit})\)</span> 把分数映射为正数权重；取 log 则把乘法结构拉平成加法结构，便于用和式写出似然与损失。</p>
<div class="blog_h2"><span class="graybg">幂函数</span></div>
<p>幂函数（Power Function）里常见的两个扩展是负指数（Negative Exponent）与分数指数（Rational Exponent）。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类型</td>
<td style="text-align: center;">公式</td>
<td style="text-align: center;">条件/备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>负指数（Negative Exponent）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^{-n}=\frac{1}{a^n}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a\ne 0\)</span>；<span displaypfx="inline-" class="mathjax-container">\(n\)</span> 为正整数</td>
</tr>
<tr>
<td>分数指数（Rational Exponent）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^{p/q}=\sqrt[q]{a^p}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(q&gt;0,\gcd(p,q)=1\)</span>；实数域通常要求 <span displaypfx="inline-" class="mathjax-container">\(a&gt;0\)</span></td>
</tr>
<tr>
<td>例</td>
<td><span displaypfx="inline-" class="mathjax-container">\(2^{-3}=\frac{1}{8}\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(9^{1/2}=3\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(8^{2/3}=4\)</span></td>
<td>偶次根要求被开方数非负</td>
</tr>
</tbody>
</table>
<p>常用取值：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">表达式</td>
<td style="text-align: center;">值</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(2^{-3}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(=\frac{1}{8}=0.125\)</span></td>
<td>负指数转倒数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(10^{-3}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(=0.001\)</span></td>
<td>毫（<span displaypfx="inline-" class="mathjax-container">\(10^{-3}\)</span>）尺度</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(2^{1/2}=\sqrt{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\approx 1.4142\)</span></td>
<td>最常见的无理数根</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(2^{-1/2}=\frac{1}{\sqrt{2}}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\approx 0.7071\)</span></td>
<td>正交归一化、幅度缩放常用</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(10^{1/2}=\sqrt{10}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\approx 3.1623\)</span></td>
<td>对数刻度下的“半个数量级”</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(10^{-1/2}=\frac{1}{\sqrt{10}}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\approx 0.3162\)</span></td>
<td>与上式互为倒数</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">三角函数</span></div>
<div class="blog_h3"><span class="graybg">基本三角恒等式</span></div>
<p>三角函数（Trigonometric Functions）可以用单位圆（Unit Circle）定义：在圆上角度为 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的点坐标是 <span displaypfx="inline-" class="mathjax-container">\((\cos\theta,\sin\theta)\)</span>。由此得到最基本恒等式：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center; width: 50%;">公式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>基本恒等式</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin^2\theta+\cos^2\theta=1\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\tan\theta=\frac{\sin\theta}{\cos\theta}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\cos\theta\ne 0\)</span> 时定义 <span displaypfx="inline-" class="mathjax-container">\(\tan\theta\)</span></td>
</tr>
<tr>
<td>与 <span displaypfx="inline-" class="mathjax-container">\(\tan,\cot\)</span> 相关</td>
<td><span displaypfx="inline-" class="mathjax-container">\(1+\tan^2\theta=\sec^2\theta\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(1+\cot^2\theta=\csc^2\theta\)</span></td>
<td>定义域同 <span displaypfx="inline-" class="mathjax-container">\(\tan,\cot\)</span></td>
</tr>
<tr>
<td>和差公式（Angle Addition）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin(\alpha\pm\beta)=\sin\alpha\cos\beta\pm\cos\alpha\sin\beta\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\cos(\alpha\pm\beta)=\cos\alpha\cos\beta\mp\sin\alpha\sin\beta\)</span></td>
<td>傅里叶分析、RoPE 等直觉常用</td>
</tr>
<tr>
<td>二倍角（Double-Angle）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin 2\theta=2\sin\theta\cos\theta\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\cos 2\theta=\cos^2\theta-\sin^2\theta=1-2\sin^2\theta=2\cos^2\theta-1\)</span></td>
<td>同一恒等式的不同等价写法</td>
</tr>
<tr>
<td>周期性（Periodicity）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin(\theta+2\pi)=\sin\theta\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\cos(\theta+2\pi)=\cos\theta\)</span></td>
<td>一个周期（Period）为 <span displaypfx="inline-" class="mathjax-container">\(2\pi\)</span></td>
</tr>
<tr>
<td>常用极限（<span displaypfx="inline-" class="mathjax-container">\(x\to 0\)</span>）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\lim_{x\to 0}\frac{\sin x}{x}=1\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\lim_{x\to 0}\frac{\tan x}{x}=1\)</span><br /><span displaypfx="inline-" class="mathjax-container">\(\lim_{x\to 0}\frac{1-\cos x}{x^2}=\frac{1}{2}\)</span></td>
<td>推导导数与近似时高频出现</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">常用特殊角（Special Angles）</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>（弧度）</td>
<td style="text-align: center;">角度制</td>
<td style="text-align: center;"><span displaypfx="inline-" class="mathjax-container">\(\sin\theta\)</span></td>
<td style="text-align: center;"><span displaypfx="inline-" class="mathjax-container">\(\cos\theta\)</span></td>
<td style="text-align: center;"><span displaypfx="inline-" class="mathjax-container">\(\tan\theta\)</span></td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td>0°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\pi/6\)</span></td>
<td>30°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{\sqrt{3}}{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{\sqrt{3}}\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\pi/4\)</span></td>
<td>45°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{\sqrt{2}}{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{\sqrt{2}}{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(1\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\pi/3\)</span></td>
<td>60°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{\sqrt{3}}{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sqrt{3}\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\pi/2\)</span></td>
<td>90°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td>未定义（undefined）</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\pi\)</span></td>
<td>180°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(-1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(3\pi/2\)</span></td>
<td>270°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(-1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td>未定义（undefined）</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(2\pi\)</span></td>
<td>360°</td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(1\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">欧拉公式</span></div>
<p>欧拉公式（Euler's Formula）把指数与三角函数在复数域（Complex Domain）里统一起来：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">结论</td>
<td style="text-align: center;">公式</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>欧拉公式（Euler's Formula）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{i\theta}=\cos\theta+i\sin\theta\)</span></td>
<td>把“旋转”写成复指数</td>
</tr>
<tr>
<td>辐角相加对应指数相乘</td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{i\theta_1}e^{i\theta_2}=e^{i(\theta_1+\theta_2)}\)</span></td>
<td>复数乘法：模相乘、辐角相加</td>
</tr>
<tr>
<td>欧拉恒等式（Euler's Identity）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^{i\pi}+1=0\)</span></td>
<td>连接 <span displaypfx="inline-" class="mathjax-container">\(e,i,\pi,1,0\)</span></td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">基础函数图像</span></div>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/image-of-common-fns.jpg"><img class="alignnone size-full wp-image-40557" src="https://blog.gmem.cc/wp-content/uploads/2026/03/image-of-common-fns.jpg" alt="image-of-common-fns" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">复数</span></div>
<p>一个常见疑问是：既然 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^2\)</span> 里的二维向量已经能表示平面上的点 <span displaypfx="inline-" class="mathjax-container">\((x,y)\)</span>，为什么还需要复数？表面上看，二维向量 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 与复数 <span displaypfx="inline-" class="mathjax-container">\(a+bi\)</span> 确实对应同一个平面点；但它们的<span style="background-color: #c0c0c0;">代数结构</span>完全不同，关键差别在于“乘法”是否自然、闭合且可逆。</p>
<p>对二维向量而言，加法非常自然，但乘法并不形成一个像实数那样稳定的数系：点乘（Inner Product）会把两个向量变成标量，叉乘（Cross Product）又会把结果带到垂直方向；因此二维向量空间本身并没有一个同时兼顾<span style="background-color: #c0c0c0;">封闭性（Closure）</span>与<span style="background-color: #c0c0c0;">可除性</span>的内建乘法。复数则不同：两个复数相乘后仍是复数，非零复数还总能做除法。这使得复平面不只是“几何上的二维平面”，而是一个可自由做加减乘除的完整数系。</p>
<p>这带来两个二维向量本身不具备的优势。第一，复数把二维旋转直接写进了乘法：若 <span displaypfx="inline-" class="mathjax-container">\(z=re^{i\theta}\)</span>，则乘以另一个复数时会自动实现“模相乘、角相加”。换句话说，<span style="background-color: #c0c0c0;">复数乘法天然就是缩放 + 旋转</span>；而若只用二维向量，通常还需要额外引入旋转矩阵。第二，复数让多项式方程的可解性闭合：例如 <span displaypfx="inline-" class="mathjax-container">\(x^2+1=0\)</span> 在实数域无解，但在复数域有解 <span displaypfx="inline-" class="mathjax-container">\(\pm i\)</span>。更深一层地，代数基本定理（Fundamental Theorem of Algebra）说明任意非常数多项式在复数域里都有根，因此复数成为代数方程的自然终点。</p>
<p>因此，二维向量更像是在描述“箭头、位移、速度、受力”的线性对象；复数则是在同一个平面上额外安装了一套兼容乘法、旋转与方程求解的代数机制。对于信号处理（Signal Processing）、交流电分析、傅里叶变换（Fourier Transform）、量子力学以及很多 AI 中的频域方法，复数都不是“二维向量的重复发明”，而是一个更强的二维代数系统。</p>
<div class="blog_h3"><span class="graybg">复数的表示：直角坐标（Rectangular Form）</span></div>
<p>复数（Complex Number）写作 <span displaypfx="inline-" class="mathjax-container">\(z=a+bi\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(a,b\in\mathbb{R}\)</span>，虚数单位（Imaginary Unit）满足 <span displaypfx="inline-" class="mathjax-container">\(i^2=-1\)</span>。把 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 视为二维平面上的点 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span>，就得到直角坐标（Rectangular Form）。</p>
<div class="blog_h3"><span class="graybg">复数的表示：极坐标（Polar Form）</span></div>
<p>同一个点也可用极坐标（Polar Form）表示：令 <span displaypfx="inline-" class="mathjax-container">\(r=|z|\)</span> 为模（Modulus），<span displaypfx="inline-" class="mathjax-container">\(\theta=\arg(z)\)</span> 为辐角（Argument），则</p>
<span displaypfx="" class="mathjax-container">\[z=r(\cos\theta+i\sin\theta)=re^{i\theta}\]</span>
<p>两种坐标之间的转换：</p>
<span displaypfx="" class="mathjax-container">\[r=\sqrt{a^2+b^2},\quad a=r\cos\theta,\quad b=r\sin\theta\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 通常用 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{atan2}(b,a)\)</span> 计算，并且 <span displaypfx="inline-" class="mathjax-container">\(\arg(z)\)</span> 不是唯一的：加上任意 <span displaypfx="inline-" class="mathjax-container">\(2\pi k\)</span>（<span displaypfx="inline-" class="mathjax-container">\(k\in\mathbb{Z}\)</span>）表示同一个方向。</p>
<p>例：<span displaypfx="inline-" class="mathjax-container">\(z=1+i\)</span> 的模是 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{2}\)</span>，辐角是 <span displaypfx="inline-" class="mathjax-container">\(\pi/4\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(z=\sqrt{2}\,e^{i\pi/4}\)</span>。</p>
<p>棣莫弗公式（De Moivre's Formula）给出幂运算的快捷形式：</p>
<span displaypfx="" class="mathjax-container">\[(\cos\theta+i\sin\theta)^n=\cos(n\theta)+i\sin(n\theta)\]</span>
<div class="blog_h3"><span class="graybg">共轭与模</span></div>
<p>复数 <span displaypfx="inline-" class="mathjax-container">\(z = a + bi\)</span> 的模（Modulus）定义为 <span displaypfx="inline-" class="mathjax-container">\(|z|=\sqrt{a^2+b^2}\)</span>，表示复平面（Complex Plane）中点 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 到原点的欧氏距离（Euclidean Distance）。</p>
<p>共轭（Conjugate）记作 <span displaypfx="inline-" class="mathjax-container">\(\bar z = a-bi\)</span>。几何上，它把点 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 关于实轴（Real Axis）镜像到 <span displaypfx="inline-" class="mathjax-container">\((a,-b)\)</span>；数值上，它把“相位（Phase）”取反而保持“幅值（Magnitude）”不变。</p>
<p>共轭与模的核心关系是 <span displaypfx="inline-" class="mathjax-container">\(z\bar z = |z|^2\)</span>，展开即可验证：</p>
<span displaypfx="" class="mathjax-container">\[(a+bi)(a-bi)=a^2+b^2=|z|^2\]</span>
<p>这个恒等式的一个直接用途是复数除法：为了避免分母含有虚部，把分母乘以共轭进行“有理化（Rationalization）”。</p>
<span displaypfx="" class="mathjax-container">\[\frac{a+bi}{c+di}=\frac{(a+bi)(c-di)}{(c+di)(c-di)}=\frac{(a+bi)(c-di)}{c^2+d^2}\]</span>
<p>例： <span displaypfx="inline-" class="mathjax-container">\(\frac{1+2i}{3-4i}=\frac{(1+2i)(3+4i)}{3^2+4^2}=\frac{-5+10i}{25}=-\frac{1}{5}+\frac{2}{5}i\)</span>。这里分母变成实数，是因为 <span displaypfx="inline-" class="mathjax-container">\((3-4i)(3+4i)=3^2+4^2\)</span>。</p>
<div class="blog_h3"><span class="graybg">复数乘法与旋转</span></div>
<p>把复数写成极坐标（Polar Form）：<span displaypfx="inline-" class="mathjax-container">\(z=r(\cos\theta+i\sin\theta)=re^{i\theta}\)</span>。此时复数乘法的几何意义非常直接：</p>
<span displaypfx="" class="mathjax-container">\[z_1 z_2 = (r_1 e^{i\theta_1})(r_2 e^{i\theta_2}) = (r_1 r_2)e^{i(\theta_1+\theta_2)}\]</span>
<p>也就是说：<span style="background-color: #c0c0c0;">模相乘、辐角相加</span>。乘法同时完成缩放（Scaling）与旋转（Rotation）。</p>
<p>例：乘以 <span displaypfx="inline-" class="mathjax-container">\(i=e^{i\pi/2}\)</span> 会把任意复数逆时针旋转 90° 且不改变模；乘以 <span displaypfx="inline-" class="mathjax-container">\(-1=e^{i\pi}\)</span> 会旋转 180°。例如 <span displaypfx="inline-" class="mathjax-container">\((1+2i)\cdot i=-2+i\)</span>，几何上就是把点 <span displaypfx="inline-" class="mathjax-container">\((1,2)\)</span> 旋到 <span displaypfx="inline-" class="mathjax-container">\((-2,1)\)</span>。</p>
<div class="blog_h2"><span class="graybg">数列与级数</span></div>
<p>求和符号（Summation Symbol）<span displaypfx="inline-" class="mathjax-container">\(\sum\)</span> 与乘积符号（Product Symbol）<span displaypfx="inline-" class="mathjax-container">\(\prod\)</span> 是数列与级数推导里最常见的两个“聚合”记号：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{i=1}^{n} a_i=a_1+a_2+\cdots+a_n,\quad \prod_{i=1}^{n} a_i=a_1\cdot a_2\cdots a_n\]</span>
<p>英文里通常把 <span displaypfx="inline-" class="mathjax-container">\(\sum\)</span> 读作 “sigma” 或 “summation”，把 <span displaypfx="inline-" class="mathjax-container">\(\prod\)</span> 读作 “capital pi” 或 “product”。</p>
<div class="blog_h3"><span class="graybg">等差数列 / 等比数列</span></div>
<p>数列（Sequence）是一列按整数下标排列的数 <span displaypfx="inline-" class="mathjax-container">\(\{a_n\}_{n\ge 1}\)</span>。最常见的两类是等差数列（Arithmetic Sequence）与等比数列（Geometric Sequence）。它们都可用“递推定义 + 通项公式 + 前 n 项和”三件套描述。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类型</td>
<td style="text-align: center;">递推定义</td>
<td style="text-align: center;">通项（<span displaypfx="inline-" class="mathjax-container">\(a_n\)</span>）</td>
<td style="width: 35%; text-align: center;">前 n 项和（<span displaypfx="inline-" class="mathjax-container">\(S_n=\sum_{k=1}^{n} a_k\)</span>）</td>
</tr>
</thead>
<tbody>
<tr>
<td>等差数列（Arithmetic）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a_{n}=a_{n-1}+d\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a_n=a_1+(n-1)d\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(S_n=\frac{n}{2}(a_1+a_n)=\frac{n}{2}\left(2a_1+(n-1)d\right)\)</span></td>
</tr>
<tr>
<td>等比数列（Geometric）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(a_{n}=ra_{n-1}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a_n=a_1 r^{n-1}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(S_n=\begin{cases}\frac{a_1(1-r^n)}{1-r},&amp; r\ne 1 \\ na_1,&amp; r=1\end{cases}\)</span></td>
</tr>
</tbody>
</table>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(a_1=1,d=2\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(a_n=2n-1\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(S_n=n^2\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(a_1=1,r=\frac{1}{2}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(S_n=2\left(1-2^{-n}\right)\)</span>，并随 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 增大趋近于 2。</p>
<div class="blog_h3"><span class="graybg">无穷级数与收敛</span></div>
<p>无穷级数（Infinite Series）<span displaypfx="inline-" class="mathjax-container">\(\sum_{n=1}^{\infty} a_n\)</span> 的核心对象是部分和（Partial Sum）序列 <span displaypfx="inline-" class="mathjax-container">\(S_N=\sum_{n=1}^{N} a_n\)</span>。若极限 <span displaypfx="inline-" class="mathjax-container">\(\lim_{N\to\infty} S_N\)</span> 存在且为有限值，则级数收敛（Convergence）；否则发散（Divergence）。</p>
<p>必要条件：若 <span displaypfx="inline-" class="mathjax-container">\(\sum_{n=1}^{\infty} a_n\)</span> 收敛，则必有 <span displaypfx="inline-" class="mathjax-container">\(\lim_{n\to\infty} a_n=0\)</span>。反之不成立（例如调和级数 <span displaypfx="inline-" class="mathjax-container">\(\sum 1/n\)</span> 发散）。</p>
<p>绝对收敛（Absolute Convergence）指 <span displaypfx="inline-" class="mathjax-container">\(\sum |a_n|\)</span> 收敛；绝对收敛必推出原级数收敛。仅 <span displaypfx="inline-" class="mathjax-container">\(\sum a_n\)</span> 收敛但 <span displaypfx="inline-" class="mathjax-container">\(\sum |a_n|\)</span> 发散则为条件收敛（Conditional Convergence），此时项的重排可能改变和（甚至导致发散）。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/infinite-series.jpg"><img class="alignnone size-full wp-image-40597" src="https://blog.gmem.cc/wp-content/uploads/2026/03/infinite-series.jpg" alt="infinite-series" width="100%" /></a></p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">判别法</td>
<td style="text-align: center;">条件/计算量</td>
<td style="text-align: center;">结论</td>
</tr>
</thead>
<tbody>
<tr>
<td>几何级数（Geometric Series）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sum_{n=0}^{\infty} ar^n\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(|r|&lt;1\)</span> 时收敛，且和为 <span displaypfx="inline-" class="mathjax-container">\(\frac{a}{1-r}\)</span>；<span displaypfx="inline-" class="mathjax-container">\(|r|\ge 1\)</span> 时发散</td>
</tr>
<tr>
<td>p-级数（p-series）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sum_{n=1}^{\infty}\frac{1}{n^p}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(p&gt;1\)</span> 收敛；<span displaypfx="inline-" class="mathjax-container">\(p\le 1\)</span> 发散（调和级数为 <span displaypfx="inline-" class="mathjax-container">\(p=1\)</span>）</td>
</tr>
<tr>
<td>比较判别（Comparison）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\le a_n\le b_n\)</span>（充分大时）</td>
<td>若 <span displaypfx="inline-" class="mathjax-container">\(\sum b_n\)</span> 收敛，则 <span displaypfx="inline-" class="mathjax-container">\(\sum a_n\)</span> 收敛；若 <span displaypfx="inline-" class="mathjax-container">\(\sum a_n\)</span> 发散，则 <span displaypfx="inline-" class="mathjax-container">\(\sum b_n\)</span> 发散</td>
</tr>
<tr>
<td>比值判别（Ratio Test）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(L=\limsup_{n\to\infty}\left|\frac{a_{n+1}}{a_n}\right|\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(L&lt;1\)</span> 绝对收敛；<span displaypfx="inline-" class="mathjax-container">\(L&gt;1\)</span>（或无穷大）发散；<span displaypfx="inline-" class="mathjax-container">\(L=1\)</span> 不定</td>
</tr>
<tr>
<td>根值判别（Root Test）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\rho=\limsup_{n\to\infty}\sqrt[n]{|a_n|}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\rho&lt;1\)</span> 绝对收敛；<span displaypfx="inline-" class="mathjax-container">\(\rho&gt;1\)</span> 发散；<span displaypfx="inline-" class="mathjax-container">\(\rho=1\)</span> 不定</td>
</tr>
<tr>
<td>交错级数（Alternating Series）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sum (-1)^{n-1}b_n\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(b_n\downarrow 0\)</span></td>
<td>收敛；截断误差满足 <span displaypfx="inline-" class="mathjax-container">\(|S-S_N|\le b_{N+1}\)</span></td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">调和级数与调和数（Harmonic Series / Harmonic Numbers）</span></div>
<p>调和级数（Harmonic Series）是 <span displaypfx="inline-" class="mathjax-container">\(\sum_{n=1}^{\infty}\frac{1}{n}\)</span>。它是最经典的“项趋于 0 但级数仍发散”的例子：虽然 <span displaypfx="inline-" class="mathjax-container">\(\lim_{n\to\infty}\frac{1}{n}=0\)</span>，但部分和会无界增长。</p>
<p>其部分和称为调和数（Harmonic Number）：</p>
<span displaypfx="" class="mathjax-container">\[H_n=\sum_{k=1}^{n}\frac{1}{k}\]</span>
<p>调和数的渐近行为与对数紧密相关：<span displaypfx="inline-" class="mathjax-container">\(H_n=\log n+\gamma+o(1)\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 是欧拉-马歇罗尼常数（Euler–Mascheroni constant）。因此很多“累积量随步数缓慢增长”的分析最终都会出现 <span displaypfx="inline-" class="mathjax-container">\(\log n\)</span>。</p>
<p>在 AI/优化里，调和级数最常见的用途是解释与验证学习率（Learning Rate）衰减的收敛条件。经典随机逼近（Stochastic Approximation）的一个常用充分条件是：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{t=1}^{\infty}\eta_t=\infty,\quad \sum_{t=1}^{\infty}\eta_t^2&lt;\infty\]</span>
<p>取 <span displaypfx="inline-" class="mathjax-container">\(\eta_t=\frac{1}{t}\)</span> 时，第一项对应调和级数发散（保证“走得足够远”），第二项对应 <span displaypfx="inline-" class="mathjax-container">\(\sum 1/t^2\)</span> 收敛（保证噪声的累计影响有限）。很多 SGD（Stochastic Gradient Descent）及在线学习（Online Learning）的理论推导，会用这组“一个发散、一个收敛”的对比来控制偏差与方差项。</p>
<div class="blog_h3"><span class="graybg">Taylor 展开</span></div>
<p>Taylor 展开（Taylor Expansion）用多项式在局部逼近光滑函数。Taylor 定理（Taylor's Theorem）强调“有限阶近似 + 余项（Remainder）”，Taylor 级数（Taylor Series）强调“无限项级数在收敛时等于原函数”。</p>
<span displaypfx="" class="mathjax-container">\[f(x)=\sum_{k=0}^{n}\frac{f^{(k)}(a)}{k!}(x-a)^k+R_{n+1}(x)\]</span>
<p>当余项在 <span displaypfx="inline-" class="mathjax-container">\(n\to\infty\)</span> 时收敛到 0，才有 <span displaypfx="inline-" class="mathjax-container">\(f(x)=\sum_{k=0}^{\infty}\frac{f^{(k)}(a)}{k!}(x-a)^k\)</span>。并非所有光滑函数都等于其 Taylor 级数。</p>
<p>例：在 <span displaypfx="inline-" class="mathjax-container">\(a=0\)</span> 展开，<span displaypfx="inline-" class="mathjax-container">\(e^x\approx 1+x+\frac{x^2}{2}\)</span>；当 <span displaypfx="inline-" class="mathjax-container">\(|x|\)</span> 很小时，用低阶项就能得到高精度近似。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/taylor-expansion.jpg"><img class="alignnone size-full wp-image-40583" src="https://blog.gmem.cc/wp-content/uploads/2026/03/taylor-expansion.jpg" alt="taylor-expansion" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">组合数学</span></div>
<p>组合数学（Combinatorics）研究离散结构的计数、构造与存在性。它的典型问题是“有多少种可能”：当选择空间巨大时，计数结果直接决定搜索/采样的复杂度；当把计数结果归一化为概率时，就得到二项分布、超几何分布等常见模型。</p>
<div class="blog_h3"><span class="graybg">排列与组合</span></div>
<p>排列（Permutation）与组合（Combination）的区别只在一个点：<span style="background-color: #c0c0c0;">是否区分顺序</span>。把“先选哪些元素”与“再怎么排序”分开理解，可以避免大量记忆负担。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">对象</td>
<td style="text-align: center;">记号</td>
<td style="width: 30%; text-align: center;">公式</td>
<td style="text-align: center;">直觉</td>
</tr>
</thead>
<tbody>
<tr>
<td>阶乘（Factorial）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(n!\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(n!=n(n-1)\cdots 2\cdot 1,\ 0!=1\)</span></td>
<td>把 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个互异元素全部排列的方式数</td>
</tr>
<tr>
<td>排列（k-permutation）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(P(n,k)\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(P(n,k)=\frac{n!}{(n-k)!}\)</span></td>
<td>从 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个里选 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个并排序：一步一步填位置</td>
</tr>
<tr>
<td>组合（k-combination）</td>
<td><span displaypfx="inline-" class="mathjax-container">\({n \choose k}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\({n \choose k}=\frac{n!}{k!(n-k)!}\)</span></td>
<td>从 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个里选 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个，不关心顺序：先选集合</td>
</tr>
<tr>
<td>排列与组合关系</td>
<td><span displaypfx="inline-" class="mathjax-container">\(P(n,k)\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(P(n,k)={n \choose k}\,k!\)</span></td>
<td>先选 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个元素，再把它们排序</td>
</tr>
</tbody>
</table>
<p>例：从 5 个候选特征里挑 3 个做一个无序子集，有 <span displaypfx="inline-" class="mathjax-container">\({5 \choose 3}=10\)</span> 种；如果还要给这 3 个特征排“处理顺序”，则变为排列 <span displaypfx="inline-" class="mathjax-container">\(P(5,3)=5\cdot4\cdot3=60\)</span> 种。</p>
<div class="blog_h3"><span class="graybg">二项式定理</span></div>
<p>二项式定理（Binomial Theorem）描述 <span displaypfx="inline-" class="mathjax-container">\((a+b)^n\)</span> 的展开结构：</p>
<span displaypfx="" class="mathjax-container">\[(a+b)^n=\sum_{k=0}^{n}{n \choose k}a^{n-k}b^k\]</span>
<p>系数 <span displaypfx="inline-" class="mathjax-container">\({n \choose k}\)</span> 的组合解释非常直接：把 <span displaypfx="inline-" class="mathjax-container">\((a+b)^n\)</span> 看作 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个括号相乘，每一项由“从每个括号里选 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>”组成。若最终选了 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 次 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>，就需要从 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个位置里挑出这 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个位置，因此系数是 <span displaypfx="inline-" class="mathjax-container">\({n \choose k}\)</span>。</p>
<p>例：<span displaypfx="inline-" class="mathjax-container">\((x+2)^4={4 \choose 0}x^4+{4 \choose 1}x^3\cdot2+{4 \choose 2}x^2\cdot2^2+{4 \choose 3}x\cdot2^3+{4 \choose 4}2^4=x^4+8x^3+24x^2+32x+16\)</span>。</p>
<p>二项式系数还满足递推（Pascal's Rule）：<span displaypfx="inline-" class="mathjax-container">\({n \choose k}={n-1 \choose k-1}+{n-1 \choose k}\)</span>。这等价于“选与不选某个固定元素”的分类讨论。</p>
<p>与概率的连接：若独立伯努利试验（Bernoulli Trial）成功概率为 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>，做 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 次恰好成功 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 次的概率是 <span displaypfx="inline-" class="mathjax-container">\({n \choose k}p^k(1-p)^{n-k}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\({n \choose k}\)</span> 计数的是“成功出现在哪些轮次”。</p>
<div class="blog_h2"><span class="graybg">不等式</span></div>
<p>不等式（Inequality）是把“难算/难优化/难比较”的表达式替换为“可控的上界或下界”的工具。机器学习中的许多目标函数与泛化分析，本质上都在用不等式把复杂量压到可操作的形式（例如把非线性函数用线性或二次上界近似）。</p>
<div class="blog_h3"><span class="graybg">基本不等式</span></div>
<p>基本不等式（Basic Inequalities）常见来源有两类：一类来自非负量（例如 <span displaypfx="inline-" class="mathjax-container">\((\cdot)^2\ge 0\)</span>），另一类来自范数（Norm）与凸性（Convexity）的结构性质。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">不等式</td>
<td style="text-align: center;">形式</td>
<td style="text-align: center;">典型用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>平方非负</td>
<td><span displaypfx="inline-" class="mathjax-container">\((a-b)^2\ge 0\Rightarrow a^2+b^2\ge 2ab\)</span></td>
<td>把乘积项 <span displaypfx="inline-" class="mathjax-container">\(ab\)</span> 转成平方项，便于求界或优化（常见于“配方”）</td>
</tr>
<tr>
<td>三角不等式（Triangle Inequality）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(|x+y|\le |x|+|y|\)</span></td>
<td>把“相加后的绝对值”上界成“绝对值之和”；用于误差传播与残差界</td>
</tr>
<tr>
<td>均值不等式（AM-GM）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{a+b}{2}\ge \sqrt{ab}\quad (a,b\ge 0)\)</span></td>
<td>在“和固定时乘积最大”或“乘积/几何平均”相关问题里给出最紧的经典界</td>
</tr>
</tbody>
</table>
<p>例（AM-GM 的“最大乘积”直觉）：在 <span displaypfx="inline-" class="mathjax-container">\(a,b\ge 0\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(a+b=10\)</span> 的约束下，乘积满足 <span displaypfx="inline-" class="mathjax-container">\(ab\le \left(\frac{a+b}{2}\right)^2=25\)</span>，并且当且仅当 <span displaypfx="inline-" class="mathjax-container">\(a=b=5\)</span> 取等号。</p>
<div class="blog_h3"><span class="graybg">Jensen 不等式</span></div>
<p>Jensen 不等式（Jensen's Inequality）回答一个很具体的问题：<span style="background-color: #c0c0c0;">“平均”与“非线性”交换顺序会发生什么</span>。它是把抽象的凸性（Convexity）变成可用结论的最常用工具之一。</p>
<p>先把术语说清楚：</p>
<ul>
<li>加权平均（Weighted Average）：给一组数 <span displaypfx="inline-" class="mathjax-container">\(x_1,\dots,x_n\)</span> 分配权重 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i\ge 0\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(\sum_i\lambda_i=1\)</span>，则加权平均是 <span displaypfx="inline-" class="mathjax-container">\(\sum_i\lambda_i x_i\)</span>（把 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i\)</span> 视为“占比”即可）。</li>
<li>凸函数（Convex Function）：函数 <span displaypfx="inline-" class="mathjax-container">\(\varphi\)</span> 若满足对任意 <span displaypfx="inline-" class="mathjax-container">\(x_1,x_2\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\lambda\in[0,1]\)</span> 都有 <span displaypfx="inline-" class="mathjax-container">\(\varphi(\lambda x_1+(1-\lambda)x_2)\le \lambda\varphi(x_1)+(1-\lambda)\varphi(x_2)\)</span>，则称 <span displaypfx="inline-" class="mathjax-container">\(\varphi\)</span> 是凸的。直观上：它“向上弯”，因此对它做平均会被“惩罚”。</li>
<li>随机变量（Random Variable）：一个在不同试验/样本上会取不同值的量；把 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 视为“每次取到的数”。</li>
<li>期望（Expectation）<span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[\cdot]\)</span>：可把它理解为“按概率加权的平均”。</li>
</ul>
<p>Jensen 的形式（离散加权平均）：若 <span displaypfx="inline-" class="mathjax-container">\(\varphi\)</span> 凸，则</p>
<span displaypfx="" class="mathjax-container">\[\varphi\!\left(\sum_i \lambda_i x_i\right)\le \sum_i \lambda_i \varphi(x_i)\]</span>
<p>等价的期望形式：对随机变量 <span displaypfx="inline-" class="mathjax-container">\(X\)</span>（且 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[\varphi(X)]\)</span> 存在），有</p>
<span displaypfx="" class="mathjax-container">\[\varphi(\mathbb{E}[X])\le \mathbb{E}[\varphi(X)]\]</span>
<p>如果 <span displaypfx="inline-" class="mathjax-container">\(\varphi\)</span> 是凹函数（Concave Function），不等号方向会反过来。</p>
<p>场景 1：凸惩罚下，“波动”本身有代价。设某个系统的代价函数是 <span displaypfx="inline-" class="mathjax-container">\(\varphi(t)=t^2\)</span>（二次惩罚，Convex Penalty），比较两种延迟（Latency）：</p>
<ul>
<li>稳定方案：每次都是 100ms，则平均代价是 <span displaypfx="inline-" class="mathjax-container">\(100^2=10000\)</span>。</li>
<li>波动方案：一半时间 50ms、一半时间 150ms（平均同样是 100ms），则平均代价是 <span displaypfx="inline-" class="mathjax-container">\(\frac{50^2+150^2}{2}=12500\)</span>。</li>
</ul>
<p>两者平均延迟一样，但二次代价更偏好稳定方案；这就是 Jensen 在 <span displaypfx="inline-" class="mathjax-container">\(\varphi(t)=t^2\)</span> 下的直接体现：<span displaypfx="inline-" class="mathjax-container">\(\left(\frac{50+150}{2}\right)^2\le \frac{50^2+150^2}{2}\)</span>。</p>
<p>场景 2：对数损失下，“偶尔很错”会被放大。分类里常用对数损失（Log Loss）<span displaypfx="inline-" class="mathjax-container">\(\varphi(p)=-\log p\)</span>（<span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是正确类别的预测概率），它在 <span displaypfx="inline-" class="mathjax-container">\((0,1]\)</span> 上是凸函数。假设一个模型在同一个样本上两次输出 <span displaypfx="inline-" class="mathjax-container">\(p=0.9\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(p=0.1\)</span>（一次很自信、一次几乎反过来），则</p>
<span displaypfx="" class="mathjax-container">\[-\log\!\left(\frac{0.9+0.1}{2}\right)=-\log 0.5\approx 0.693\le \frac{-\log 0.9-\log 0.1}{2}\approx 1.204\]</span>
<p>含义：在凸损失下，预测的波动会提高平均损失；这也是许多“用不等式构造上界/下界”方法（例如把难优化的期望目标变成可算的界）背后的数学原因。</p>
<p>何时取等号：当所有 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 相等（没有波动），或 <span displaypfx="inline-" class="mathjax-container">\(\varphi\)</span> 在相关区间上近似线性时，不等式可取等号。</p>
<div class="blog_h3"><span class="graybg">Cauchy-Schwarz 不等式</span></div>
<p>Cauchy-Schwarz 不等式（Cauchy–Schwarz Inequality）回答另一个非常基础的问题：<span style="background-color: #c0c0c0;">两组数“对齐相乘再求和”的结果，最多能有多大</span>。它是内积（Inner Product）与范数（Norm）体系的核心约束。</p>
<p>把术语说清楚后，这个不等式就不神秘：</p>
<ul>
<li>向量（Vector）：把一组数按顺序排成列表，例如 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}=(a_1,\dots,a_n)\)</span>。</li>
<li>点积/内积（Dot Product / Inner Product）：<span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}^\top\mathbf{b}=\sum_i a_i b_i\)</span>，可理解为“两组数在同一位置上的重叠程度”。</li>
<li>L2 范数（<span displaypfx="inline-" class="mathjax-container">\(\ell_2\)</span> Norm）：<span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\|_2=\sqrt{\sum_i a_i^2}\)</span>，几何上是向量长度（Length）。</li>
</ul>
<p>不等式本身是：</p>
<span displaypfx="" class="mathjax-container">\[|\mathbf{a}^\top\mathbf{b}|\le \|\mathbf{a}\|_2\,\|\mathbf{b}\|_2\]</span>
<p>几何解释：把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}^\top\mathbf{b}\)</span> 写成 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\|_2\|\mathbf{b}\|_2\cos\theta\)</span>，Cauchy-Schwarz 等价于 <span displaypfx="inline-" class="mathjax-container">\(|\cos\theta|\le 1\)</span>。当且仅当两向量同向或反向（线性相关（Linearly Dependent））时取等号。</p>
<p>场景 1：为什么检索里常用“余弦相似度（Cosine Similarity）”。在向量检索中，常用点积 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{q}^\top\mathbf{d}\)</span> 衡量查询向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{q}\)</span> 与文档向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{d}\)</span> 的相似度。但点积同时受“方向”和“长度”影响：如果某个向量范数很大，即使方向一般，点积也可能很大。</p>
<p>一个最小例子：令查询 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{q}=(1,1)\)</span>，两篇候选文档向量为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{d}_1=(100,0)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{d}_2=(2,2)\)</span>。点积分别是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{q}^\top\mathbf{d}_1=100\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{q}^\top\mathbf{d}_2=4\)</span>，点积会更偏向 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{d}_1\)</span>。但如果把向量归一化到单位范数（Unit Norm）再比较，则</p>
<span displaypfx="" class="mathjax-container">\[\cos(\mathbf{q},\mathbf{d}_1)=\frac{\mathbf{q}^\top\mathbf{d}_1}{\|\mathbf{q}\|_2\|\mathbf{d}_1\|_2}=\frac{1}{\sqrt{2}},\quad \cos(\mathbf{q},\mathbf{d}_2)=\frac{\mathbf{q}^\top\mathbf{d}_2}{\|\mathbf{q}\|_2\|\mathbf{d}_2\|_2}=1\]</span>
<p>归一化后 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{d}_2\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{q}\)</span> 方向完全一致，更符合“语义对齐”的直觉。Cauchy-Schwarz 保证余弦相似度一定落在 [-1,1]，从而成为一个尺度稳定、可解释的相似度。</p>
<p>场景 2：为什么相关系数（Correlation Coefficient）不可能超过 1。把两组已中心化（Centered，均值为 0）的数据序列看成向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x},\mathbf{y}\)</span>，它们的“协方差方向”可以写成点积 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top\mathbf{y}\)</span>，而各自的尺度由 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_2,\|\mathbf{y}\|_2\)</span> 给出。Cauchy-Schwarz 直接推出</p>
<span displaypfx="" class="mathjax-container">\[\left|\frac{\mathbf{x}^\top\mathbf{y}}{\|\mathbf{x}\|_2\|\mathbf{y}\|_2}\right|\le 1\]</span>
<p>这就是“线性相关强度最多 100%”的数学原因；取等号对应完全线性关系，即 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}=c\mathbf{x}\)</span>。</p>
<div class="blog_h2"><span class="graybg">集合论基础</span></div>
<p>集合论（Set Theory）是现代数学语言的底座：几乎所有“对象 + 结构”的定义都能归结为集合及其上的运算。工程语境里，把对象抽象为集合的好处是：边界清晰、可组合、可用代数规则推导。</p>
<div class="blog_h3"><span class="graybg">集合运算（并 / 交 / 补）</span></div>
<p>集合（Set）是元素（Element）的无序聚合。记 <span displaypfx="inline-" class="mathjax-container">\(x\in A\)</span> 表示 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 属于集合 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，记 <span displaypfx="inline-" class="mathjax-container">\(A\subseteq B\)</span> 表示子集（Subset）。常用的运算如下。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">运算</td>
<td style="text-align: center; width: 150px;">记号</td>
<td style="text-align: center;">定义</td>
<td style="text-align: center;">例子</td>
</tr>
</thead>
<tbody>
<tr>
<td>并（Union）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(A\cup B\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\{x\mid x\in A\ \text{或}\ x\in B\}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\{1,2,3\}\cup\{3,4\}=\{1,2,3,4\}\)</span></td>
</tr>
<tr>
<td>交（Intersection）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(A\cap B\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\{x\mid x\in A\ \text{且}\ x\in B\}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\{1,2,3\}\cap\{3,4\}=\{3\}\)</span></td>
</tr>
<tr>
<td>差（Difference）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(A\setminus B\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\{x\mid x\in A,\ x\notin B\}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\{1,2,3\}\setminus\{3,4\}=\{1,2\}\)</span></td>
</tr>
<tr>
<td>补（Complement）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(A^c\)</span></td>
<td>相对于全集 <span displaypfx="inline-" class="mathjax-container">\(U\)</span>： <span displaypfx="inline-" class="mathjax-container">\(A^c=U\setminus A\)</span></td>
<td>若 <span displaypfx="inline-" class="mathjax-container">\(U=\{1,2,3,4\}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\{1,2,3\}^c=\{4\}\)</span></td>
</tr>
</tbody>
</table>
<p>De Morgan 律（De Morgan's Laws）是“补运算”与“并/交”之间的互换规则：</p>
<span displaypfx="" class="mathjax-container">\[(A\cup B)^c=A^c\cap B^c,\quad (A\cap B)^c=A^c\cup B^c\]</span>
<p>计数场景常用容斥原理（Inclusion–Exclusion）：<span displaypfx="inline-" class="mathjax-container">\(|A\cup B|=|A|+|B|-|A\cap B|\)</span>。例：一个数据集里“命中规则 A”的样本有 120 个，“命中规则 B”的有 80 个，同时命中的有 30 个，则命中至少一个规则的样本数是 <span displaypfx="inline-" class="mathjax-container">\(120+80-30=170\)</span>。</p>
<div class="blog_h3"><span class="graybg">映射与关系</span></div>
<p>映射（Mapping / Function）用来描述“从输入到输出”的确定性规则。写作 <span displaypfx="inline-" class="mathjax-container">\(f:A\to B\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 是定义域（Domain），<span displaypfx="inline-" class="mathjax-container">\(B\)</span> 是陪域（Codomain）。对 <span displaypfx="inline-" class="mathjax-container">\(x\in A\)</span>，输出写作 <span displaypfx="inline-" class="mathjax-container">\(f(x)\in B\)</span>。像集（Image / Range）为 <span displaypfx="inline-" class="mathjax-container">\(f(A)=\{f(x)\mid x\in A\}\)</span>。</p>
<p>关系（Relation）是更一般的概念：它不要求“每个输入对应唯一输出”。在集合论里，一个二元关系 <span displaypfx="inline-" class="mathjax-container">\(R\)</span> 可以看成笛卡尔积（Cartesian Product）上的子集：</p>
<span displaypfx="" class="mathjax-container">\[R\subseteq A\times B,\quad (a,b)\in R\ \text{表示}\ a\ R\ b\]</span>
<p>典型例子：</p>
<ul>
<li>等价关系（Equivalence Relation）：满足自反、对称、传递。例如定义 <span displaypfx="inline-" class="mathjax-container">\(x\sim y\Leftrightarrow x-y\)</span> 能被 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 整除（同余），它把整数划分为若干等价类（Equivalence Class）。</li>
<li>偏序（Partial Order）：满足自反、反对称、传递。例如 <span displaypfx="inline-" class="mathjax-container">\(\le\)</span> 是实数上的偏序；集合的包含关系 <span displaypfx="inline-" class="mathjax-container">\(\subseteq\)</span> 也是偏序。</li>
<li>一般关系：例如“相似度大于阈值”定义了一个关系，但它通常不具备传递性，因此不是等价关系。</li>
</ul>
<p>把关系写成矩阵/邻接矩阵（Adjacency Matrix）是常用表示：若 <span displaypfx="inline-" class="mathjax-container">\(A=B=\{1,\ldots,n\}\)</span>，定义 <span displaypfx="inline-" class="mathjax-container">\(M_{ij}=1\)</span> 当且仅当 <span displaypfx="inline-" class="mathjax-container">\((i,j)\in R\)</span>。在图论（Graph Theory）与推荐/检索（Retrieval）里，这种表示会直接进入线性代数计算。</p>
<div class="blog_h1"><span class="graybg">线性代数</span></div>
<div class="blog_h2"><span class="graybg">向量</span></div>
<div class="blog_h3"><span class="graybg">向量加减法（Vector Addition / Subtraction）</span></div>
<p>在 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^n\)</span> 中，向量加法与减法按分量（Component-wise）进行。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}=(a_1,\ldots,a_n)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}=(b_1,\ldots,b_n)\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{a}+\mathbf{b}=(a_1+b_1,\ldots,a_n+b_n),\quad \mathbf{a}-\mathbf{b}=(a_1-b_1,\ldots,a_n-b_n)\]</span>
<p>几何上，加法对应向量合成；减法 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}-\mathbf{b}\)</span> 是从 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 指向 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\)</span> 的位移向量。</p>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}=(1,2)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}=(3,-1)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}+\mathbf{b}=(4,1)\)</span>，<span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}-\mathbf{b}=(-2,3)\)</span>。</p>
<p>在 AI 里，残差连接（Residual Connection）是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}+f(\mathbf{x})\)</span>；梯度下降（Gradient Descent）的更新可写成 <span displaypfx="inline-" class="mathjax-container">\(\theta\leftarrow\theta-\eta\nabla L(\theta)\)</span>。它们都直接依赖向量加减法。</p>
<div class="blog_h3"><span class="graybg">点积（Dot Product）</span></div>
<p>点积（Dot Product）把两个向量映射为标量（Scalar），常用于相似度、投影和方向一致性判断。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a},\mathbf{b}\in\mathbb{R}^n\)</span>，定义为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\cdot\mathbf{b}=\sum_{i=1}^n a_i b_i\)</span>。当 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a},\mathbf{b}\ne\mathbf{0}\)</span> 时，也可写为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\cdot\mathbf{b}=\|\mathbf{a}\|\|\mathbf{b}\|\cos\theta\)</span>。</p>
<p>点积满足交换律（Commutativity）：<span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\cdot\mathbf{b}=\mathbf{b}\cdot\mathbf{a}\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\cdot\mathbf{b}=0\)</span>，两向量正交（Orthogonal）。</p>
<p>向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 方向上的标量投影是 <span displaypfx="inline-" class="mathjax-container">\(\frac{\mathbf{a}\cdot\mathbf{b}}{\|\mathbf{b}\|}\)</span>。投影更常用的是向量形式（Vector Projection）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{proj}_{\mathbf{b}}(\mathbf{a})=\frac{\mathbf{a}\cdot\mathbf{b}}{\|\mathbf{b}\|^2}\mathbf{b}\]</span>
<p>当把向量归一化到单位范数（Unit Norm）后，点积就等于余弦相似度（Cosine Similarity）：<span displaypfx="inline-" class="mathjax-container">\(\cos\theta=\mathbf{\hat a}\cdot\mathbf{\hat b}\)</span>，这也是检索与表示学习中常见的相似度度量。</p>
<p>单位向量（Unit Vector）是范数为 1 的向量，常用来“只表示方向”。对非零向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\)</span>，其单位向量是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{\hat a}=\frac{\mathbf{a}}{\|\mathbf{a}\|}\)</span>。</p>
<p>方向角（Direction Angles）/方向余弦（Direction Cosines）描述单位向量与各坐标轴的夹角：在三维中，若单位向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}=(u_x,u_y,u_z)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(u_x=\cos\alpha\)</span>、<span displaypfx="inline-" class="mathjax-container">\(u_y=\cos\beta\)</span>、<span displaypfx="inline-" class="mathjax-container">\(u_z=\cos\gamma\)</span>，并满足 <span displaypfx="inline-" class="mathjax-container">\(\cos^2\alpha+\cos^2\beta+\cos^2\gamma=1\)</span>。</p>
<p>点积之所以会出现“乘积”，是因为它等于“被投影长度 × 参照向量长度”： <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\cdot\mathbf{b}=\|\mathbf{b}\|\cdot \mathrm{projLen}_{\mathbf{b}}(\mathbf{a})\)</span>。当两向量同向时，投影长度就是 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\|\)</span>，于是点积变为 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\|\|\mathbf{b}\|\)</span>。</p>
<div class="blog_h4"><span class="graybg">二维向量的复数表示</span></div>
<p>在二维空间里，向量 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 可以写成复数 <span displaypfx="inline-" class="mathjax-container">\(z=a+bi\)</span>。这不是把向量“变成另一种对象”，而是给同一个二维量换一种记法：实部对应 x 轴分量，虚部对应 y 轴分量。</p>
<p>若另一向量 <span displaypfx="inline-" class="mathjax-container">\((c,d)\)</span> 写成 <span displaypfx="inline-" class="mathjax-container">\(w=c+di\)</span>，则复共轭乘积为</p>
<span displaypfx="" class="mathjax-container">\[z\bar w=(a+bi)(c-di)=(ac+bd)+i(bc-ad)\]</span>
<p>其中实部 <span displaypfx="inline-" class="mathjax-container">\(ac+bd\)</span> 恰好就是二维向量的点积：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{a}\cdot\mathbf{b}=ac+bd=\mathrm{Re}(z\bar w)\]</span>
<p>若再把它们写成极坐标形式 <span displaypfx="inline-" class="mathjax-container">\(z=r_1e^{i\theta_1}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(w=r_2e^{i\theta_2}\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{a}\cdot\mathbf{b}=\mathrm{Re}(z\bar w)=r_1r_2\cos(\theta_1-\theta_2)\]</span>
<p>这里实部的来源可以直接从指数形式读出：因为 <span displaypfx="inline-" class="mathjax-container">\(\bar w=r_2e^{-i\theta_2}\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(z\bar w=r_1r_2e^{i(\theta_1-\theta_2)}\)</span>。再用欧拉公式 <span displaypfx="inline-" class="mathjax-container">\(e^{i\phi}=\cos\phi+i\sin\phi\)</span> 展开，就得到 <span displaypfx="inline-" class="mathjax-container">\(z\bar w=r_1r_2\big(\cos(\theta_1-\theta_2)+i\sin(\theta_1-\theta_2)\big)\)</span>；其中实部正是 <span displaypfx="inline-" class="mathjax-container">\(r_1r_2\cos(\theta_1-\theta_2)\)</span>。</p>
<p>因此，点积既可以看成分量乘加，也可以看成“复数乘积取实部”。后一种写法把<span style="background-color: #c0c0c0;">长度</span>与<span style="background-color: #c0c0c0;">相位差</span>放进同一个式子里，在位置编码、旋转表示与频域分析中尤其方便。后文 RoPE 的复数视角正是沿用这层关系：二维块先写成复数，再让相位随位置旋转。</p>
<p>例：令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}=(3,4)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}=(4,0)\)</span>。则 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\cdot\mathbf{b}=12\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\|=5\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{b}\|=4\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(\cos\theta=\frac{12}{20}=0.6\)</span>。而 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 方向上的标量投影是 <span displaypfx="inline-" class="mathjax-container">\(\frac{12}{4}=3\)</span>，恰好对应 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\)</span> 的 x 分量。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/vector-ops.jpg"><img class="alignnone size-full wp-image-40599" src="https://blog.gmem.cc/wp-content/uploads/2026/03/vector-ops.jpg" alt="vector-ops" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">叉积（Cross Product）</span></div>
<p>叉积（Cross Product）定义在三维空间（3D Space）。结果是同时垂直于两个输入向量的向量，大小为 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\times\mathbf{b}\|=\|\mathbf{a}\|\|\mathbf{b}\|\sin\theta\)</span>，等于两向量张成平行四边形的面积。</p>
<p>计算上，若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}=(a_1,a_2,a_3)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}=(b_1,b_2,b_3)\)</span>，则：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{a}\times\mathbf{b}=(a_2b_3-a_3b_2,\ a_3b_1-a_1b_3,\ a_1b_2-a_2b_1)\]</span>
<p>例： <span displaypfx="inline-" class="mathjax-container">\((1,0,0)\times(0,1,0)=(0,0,1)\)</span>。这个例子在几何上对应两个单位正交基向量张成的“正方形面积为 1”，方向由右手定则给出。</p>
<p>叉积为零当且仅当两向量平行（Parallel/Colinear）或至少有一个为零向量：这是因为 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\times\mathbf{b}\|=\|\mathbf{a}\|\|\mathbf{b}\|\sin\theta\)</span>，为 0 只能来自 <span displaypfx="inline-" class="mathjax-container">\(\sin\theta=0\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{a}\|=0\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{b}\|=0\)</span>。</p>
<p>方向由右手定则（Right-Hand Rule）确定：四指从 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\)</span> 旋向 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span>，拇指方向即 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\times\mathbf{b}\)</span> 方向。旋转“正负”不是绝对物理事实，而是由坐标系（Coordinate System）与观察方向（View Direction）约定决定。</p>
<p>力矩（Torque）可作为叉积方向的直观例子： <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\tau}=\mathbf{r}\times\mathbf{F}\)</span>。这里保留物理解释的唯一目的，是帮助理解叉积的方向性。</p>
<div class="blog_h2"><span class="graybg">基（Basis）</span></div>
<p>在 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^n\)</span> 中，一组向量 <span displaypfx="inline-" class="mathjax-container">\(\{\mathbf{b}_1,\ldots,\mathbf{b}_n\}\)</span> 若线性无关（Linearly Independent）且张成（Span）整个空间，则称为一组基（Basis）。任何向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 都能唯一表示为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=\sum_{i=1}^{n} c_i\mathbf{b}_i\)</span>；系数向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}=(c_1,\ldots,c_n)^\top\)</span> 就是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 在该基下的坐标（Coordinates）。</p>
<p>把基向量按列堆成矩阵 <span displaypfx="inline-" class="mathjax-container">\(B=[\mathbf{b}_1\ \cdots\ \mathbf{b}_n]\in\mathbb{R}^{n\times n}\)</span>，则坐标与原向量满足 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=B\mathbf{c}\)</span>。换基（Change of Basis）在推导里本质就是在不同 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 之间切换坐标表示。</p>
<div class="blog_h3"><span class="graybg">换基（Change of Basis）：同一个向量，不同坐标</span></div>
<p>“换基”最容易被误解成“把向量变形”。它真正做的事情是：<span style="background-color: #c0c0c0;">几何向量（Geometric Vector）不变，参考基（Basis）变了，因此坐标（Coordinates）变了</span>。直观上，可把几何向量理解为平面/空间里的一支箭头。</p>
<p>为了不混淆“向量本身”和“向量的坐标表示”，可以用一个约定把它们分开：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span>：把同一个几何向量用标准基（Standard Basis）写出来的分量列向量（数值计算里最常用的表示）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}\)</span>：把同一个几何向量用某组新基 <span displaypfx="inline-" class="mathjax-container">\(\{\mathbf{b}_i\}\)</span> 写出来的坐标向量（Coordinate Vector），也就是“在新基上要乘的系数”。</li>
</ul>
<p>把新基向量在标准基下的分量按列组成 <span displaypfx="inline-" class="mathjax-container">\(B=[\mathbf{b}_1\ \cdots\ \mathbf{b}_n]\)</span>（可称为基矩阵（Basis Matrix）），则</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}=B\mathbf{c}\]</span>
<p>读法：右边 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}\)</span> 是“在新基下的坐标”，左边 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 是“在标准基下的分量”。乘上 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 就把“新基坐标”换算回“标准基分量”。</p>
<p>反过来，如果你已知标准基下的分量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span>，想求新基坐标 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}\)</span>，就需要解线性方程组 <span displaypfx="inline-" class="mathjax-container">\(B\mathbf{c}=\mathbf{x}\)</span>。由于基向量线性无关，矩阵 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 必可逆（Invertible），因此可写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{c}=B^{-1}\mathbf{x}\]</span>
<p>更一般地，若旧基矩阵为 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>、新基矩阵为 <span displaypfx="inline-" class="mathjax-container">\(C\)</span>，同一几何向量满足 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=B\mathbf{c}_{B}=C\mathbf{c}_{C}\)</span>，于是坐标之间的换算是：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{c}_{C}=C^{-1}B\,\mathbf{c}_{B}\]</span>
<p>矩阵 <span displaypfx="inline-" class="mathjax-container">\(P=C^{-1}B\)</span> 常被称为换基矩阵（Change-of-basis Matrix）：它把“旧基坐标”直接映射为“新基坐标”。</p>
<p>例：在 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^2\)</span> 取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}_1=(1,0)^\top,\ \mathbf{b}_2=(1,1)^\top\)</span>。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(2,3)^\top\)</span>，解 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=c_1\mathbf{b}_1+c_2\mathbf{b}_2\)</span> 得 <span displaypfx="inline-" class="mathjax-container">\(c_2=3,\ c_1=-1\)</span>，因此该基下坐标为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}=(-1,3)^\top\)</span>。同一个几何向量，在不同基下的坐标会不同。</p>
<p>把基矩阵写出来会更便于计算： <span displaypfx="inline-" class="mathjax-container">\(B=[\mathbf{b}_1\ \mathbf{b}_2]=\begin{bmatrix}1 &amp; 1\\ 0 &amp; 1\end{bmatrix}\)</span>。此时 <span displaypfx="inline-" class="mathjax-container">\(B\mathbf{c}=\begin{bmatrix}1 &amp; 1\\ 0 &amp; 1\end{bmatrix}\begin{bmatrix}-1\\ 3\end{bmatrix}=\begin{bmatrix}2\\ 3\end{bmatrix}=\mathbf{x}\)</span>；反过来，解 <span displaypfx="inline-" class="mathjax-container">\(B\mathbf{c}=\mathbf{x}\)</span> 等价于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}=B^{-1}\mathbf{x}\)</span>，因此“换基”在计算上就是解一个线性方程组。</p>
<div class="blog_h3"><span class="graybg">标准基（Standard Basis）</span></div>
<p>标准基（Standard Basis）是最常用的一组基。第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个标准基向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{e}_i\)</span> 只有第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量为 1，其余为 0：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{e}_1=(1,0,\ldots,0)^\top,\ \mathbf{e}_2=(0,1,0,\ldots,0)^\top,\ \ldots,\ \mathbf{e}_n=(0,\ldots,0,1)^\top\]</span>
<p>例：在 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^2\)</span> 中，<span displaypfx="inline-" class="mathjax-container">\((2,3)^\top=2\mathbf{e}_1+3\mathbf{e}_2\)</span>。把 <span displaypfx="inline-" class="mathjax-container">\(\{\mathbf{e}_i\}\)</span> 作为列组成矩阵就是单位矩阵 <span displaypfx="inline-" class="mathjax-container">\(I\)</span>，因此标准基下 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=I\mathbf{x}\)</span> 对应“坐标与分量一致”。</p>
<div class="blog_h3"><span class="graybg">正交基（Orthogonal Basis）</span></div>
<p>正交基（Orthogonal Basis）是指基向量两两正交：对 <span displaypfx="inline-" class="mathjax-container">\(i\ne j\)</span> 有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}_i^\top\mathbf{b}_j=0\)</span>。它不要求单位长度。</p>
<p>正交基的一个关键性质是：坐标可以用投影直接算出。若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=\sum_i c_i\mathbf{b}_i\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(\{\mathbf{b}_i\}\)</span> 正交，则</p>
<span displaypfx="" class="mathjax-container">\[c_i=\frac{\mathbf{x}^\top\mathbf{b}_i}{\mathbf{b}_i^\top\mathbf{b}_i}\]</span>
<p>例：取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}_1=(1,1)^\top,\ \mathbf{b}_2=(1,-1)^\top\)</span>，它们点积为 0。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(2,1)^\top\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(c_1=\frac{3}{2},\ c_2=\frac{1}{2}\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=\frac{3}{2}\mathbf{b}_1+\frac{1}{2}\mathbf{b}_2\)</span>。</p>
<div class="blog_h3"><span class="graybg">正交标准基（Orthonormal Basis）</span></div>
<p>正交标准基（Orthonormal Basis）要求两两正交且每个基向量单位长度： <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{u}_i\|_2=1\)</span>，并满足 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}_i^\top\mathbf{u}_j=\delta_{ij}\)</span>（克罗内克 delta（Kronecker Delta））。</p>
<p>此时坐标就是内积/投影：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=\sum_i c_i\mathbf{u}_i\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(c_i=\mathbf{x}^\top\mathbf{u}_i\)</span>。计算上，这等价于把向量投影到各基方向。</p>
<p>例：令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}_1=\frac{1}{\sqrt{2}}(1,1)^\top,\ \mathbf{u}_2=\frac{1}{\sqrt{2}}(1,-1)^\top\)</span>。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(2,1)^\top\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(c_1=\frac{3}{\sqrt{2}},\ c_2=\frac{1}{\sqrt{2}}\)</span>，于是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=c_1\mathbf{u}_1+c_2\mathbf{u}_2\)</span>。</p>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(\{\mathbf{u}_i\}\)</span> 按列组成矩阵 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(Q^\top Q=I\)</span>，并且坐标变换可写成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}=Q^\top\mathbf{x}\)</span>、重构为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=Q\mathbf{c}\)</span>。在 PCA（Principal Component Analysis）与 SVD 中，主方向/奇异向量就是正交标准基；投影与重构只需要转置，不需要显式求逆。</p>
<div class="blog_h2"><span class="graybg">矩阵运算</span></div>
<p>矩阵运算（Matrix Operations）是机器学习（Machine Learning）中最核心的计算骨架。前向传播、反向传播和参数更新都可以表示为矩阵与向量的组合。</p>
<div class="blog_h3"><span class="graybg">加法与广播（Addition / Broadcasting）</span></div>
<p>矩阵加法按元素（Element-wise）进行：若 <span displaypfx="inline-" class="mathjax-container">\(X,B\in\mathbb{R}^{m\times n}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\((X+B)_{ij}=X_{ij}+B_{ij}\)</span>。减法同理。</p>
<p>在深度学习框架里常见的是广播（Broadcasting）：例如对 batch 特征 <span displaypfx="inline-" class="mathjax-container">\(X\in\mathbb{R}^{B\times d}\)</span> 与偏置 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\in\mathbb{R}^{d}\)</span>，写作 <span displaypfx="inline-" class="mathjax-container">\(Y=X+\mathbf{b}\)</span> 意味着把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 复制到每一行后再相加。这是线性层 <span displaypfx="inline-" class="mathjax-container">\(Y=XW+\mathbf{b}\)</span> 的标准形式。</p>
<div class="blog_h4"><span class="graybg">例：矩阵加法</span></div>
<span displaypfx="" class="mathjax-container">\[X=\begin{bmatrix}1 &amp; 2\\ 3 &amp; 4\end{bmatrix},\ B=\begin{bmatrix}10 &amp; 20\\ 30 &amp; 40\end{bmatrix}\Rightarrow X+B=\begin{bmatrix}11 &amp; 22\\ 33 &amp; 44\end{bmatrix}\]</span>
<div class="blog_h4"><span class="graybg">例：广播加偏置（Bias Broadcasting）</span></div>
<p>令 <span displaypfx="inline-" class="mathjax-container">\(X\in\mathbb{R}^{2\times 3}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\in\mathbb{R}^{3}\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[X=\begin{bmatrix}1 &amp; 2 &amp; 3\\ 4 &amp; 5 &amp; 6\end{bmatrix},\ \mathbf{b}=\begin{bmatrix}10 &amp; 20 &amp; 30\end{bmatrix}\]</span>
<p>广播的语义是“把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 复制到每一行”，因此</p>
<span displaypfx="" class="mathjax-container">\[Y=X+\mathbf{b}=\begin{bmatrix}11 &amp; 22 &amp; 33\\ 14 &amp; 25 &amp; 36\end{bmatrix}\]</span>
<p>从线性代数角度，把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 视作行向量，则广播等价于 <span displaypfx="inline-" class="mathjax-container">\(Y=X+\mathbf{1}\mathbf{b}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}\in\mathbb{R}^{B\times 1}\)</span> 是全 1 列向量。</p>
<div class="blog_h3"><span class="graybg">矩阵乘法（Matrix Multiplication）</span></div>
<p>矩阵乘法（Matrix Multiplication）在神经网络里通常对应线性层（Linear Layer）：<span displaypfx="inline-" class="mathjax-container">\(Y=XW\)</span>。从“每个输出维度是一个点积”来看更容易记：</p>
<span displaypfx="" class="mathjax-container">\[y_j=\sum_{i=1}^{d_{\text{in}}} x_i W_{ij}\]</span>
<p>在批处理（Batch）场景下，常用形状约定是 <span displaypfx="inline-" class="mathjax-container">\(X\in\mathbb{R}^{B\times d_{\text{in}}}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(W\in\mathbb{R}^{d_{\text{in}}\times d_{\text{out}}}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(Y=XW\in\mathbb{R}^{B\times d_{\text{out}}}\)</span>。矩阵乘法一般不满足交换律（Non-commutativity），形状不匹配时也无法相乘。</p>
<p>从几何（Geometry）角度看，矩阵定义了一个线性变换（Linear Transformation）：它把空间中的点/向量整体“变形”（旋转、缩放、剪切、投影等）。一种实用记法是看基向量（Basis Vectors）如何被映射：如果用列向量约定，矩阵的每一列就是某个基向量变换后的像。</p>
<p>例（二维）：标准基向量（Standard Basis Vectors）为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{e}_1=(1,0)^\top\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{e}_2=(0,1)^\top\)</span>。对</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}a &amp; c\\ b &amp; d\end{bmatrix}\]</span>
<p>有</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{e}_1=\begin{bmatrix}a\\ b\end{bmatrix},\quad A\mathbf{e}_2=\begin{bmatrix}c\\ d\end{bmatrix}\]</span>
<p>因此 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的第 1/2 列分别是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{e}_1,\mathbf{e}_2\)</span> 的像。对任意 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=x_1\mathbf{e}_1+x_2\mathbf{e}_2\)</span>，根据线性变换的线性性（Linearity）<span displaypfx="inline-" class="mathjax-container">\(A(\alpha\mathbf{u}+\beta\mathbf{v})=\alpha A\mathbf{u}+\beta A\mathbf{v}\)</span>，可得</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{x}=x_1A\mathbf{e}_1+x_2A\mathbf{e}_2=x_1\begin{bmatrix}a\\ b\end{bmatrix}+x_2\begin{bmatrix}c\\ d\end{bmatrix}\]</span>
<p>数值例子：取</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}2 &amp; 1\\ 0 &amp; 1\end{bmatrix}\]</span>
<p>则 <span displaypfx="inline-" class="mathjax-container">\(A\mathbf{e}_1=(2,0)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(A\mathbf{e}_2=(1,1)^\top\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(3,4)^\top=3\mathbf{e}_1+4\mathbf{e}_2\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{x}=3A\mathbf{e}_1+4A\mathbf{e}_2=3\begin{bmatrix}2\\ 0\end{bmatrix}+4\begin{bmatrix}1\\ 1\end{bmatrix}=\begin{bmatrix}10\\ 4\end{bmatrix}\]</span>
<p>这就是“看列向量理解变换”的核心：先画出两条基向量被送到哪里，整个网格会按相同线性组合随之平移/剪切/旋转/缩放。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/matrix-trans.jpg"><img class="alignnone size-full wp-image-40629" src="https://blog.gmem.cc/wp-content/uploads/2026/03/matrix-trans.jpg" alt="matrix-trans" width="100%" /></a></p>
<p>这对应两种等价视角：</p>
<ul>
<li>变换向量（Active View）：固定坐标轴，让向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 变成 <span displaypfx="inline-" class="mathjax-container">\(A\mathbf{x}\)</span>。</li>
<li>变换坐标轴（Passive View）：固定几何点，把坐标系按 <span displaypfx="inline-" class="mathjax-container">\(A^{-1}\)</span> 变换；同一个点在新坐标系下的坐标会变化。</li>
</ul>
<p>两种视角描述的是同一个线性映射，只是“变的对象”不同。在做特征变换/白化（Whitening）/坐标变换推导时，这个区分能避免符号混乱。</p>
<p>矩阵乘法只有在内维度相等时才有定义：<span displaypfx="inline-" class="mathjax-container">\((m\times n)(n\times p)=(m\times p)\)</span>。把向量视为列向量（<span displaypfx="inline-" class="mathjax-container">\(m\times 1\)</span>）或行向量（<span displaypfx="inline-" class="mathjax-container">\(1\times m\)</span>）后，外积与点积也都可以统一为矩阵乘法的特例。</p>
<ol>
<li>
<p>一般矩阵乘法：<span displaypfx="inline-" class="mathjax-container">\((m\times n)(n\times p)=(m\times p)\)</span>。</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}1 &amp; 2 &amp; 3\\ 4 &amp; 5 &amp; 6\end{bmatrix}\in\mathbb{R}^{2\times 3},\quad B=\begin{bmatrix}7 &amp; 8\\ 9 &amp; 10\\ 11 &amp; 12\end{bmatrix}\in\mathbb{R}^{3\times 2}\]</span>
<span displaypfx="" class="mathjax-container">\[AB=\begin{bmatrix}58 &amp; 64\\ 139 &amp; 154\end{bmatrix}\in\mathbb{R}^{2\times 2}\]</span>
</li>
<li>
<p>外积（Outer Product）：<span displaypfx="inline-" class="mathjax-container">\((m\times 1)(1\times n)=(m\times n)\)</span>。</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{u}=\begin{bmatrix}1\\ 2\\ 3\end{bmatrix}\in\mathbb{R}^{3\times 1},\quad \mathbf{v}=\begin{bmatrix}4 &amp; 5\end{bmatrix}\in\mathbb{R}^{1\times 2}\]</span>
<span displaypfx="" class="mathjax-container">\[\mathbf{u}\mathbf{v}=\begin{bmatrix}4 &amp; 5\\ 8 &amp; 10\\ 12 &amp; 15\end{bmatrix}\in\mathbb{R}^{3\times 2}\]</span>
<p>这是一个秩一（Rank-1）矩阵：它把“列向量 × 行向量”变成矩阵；在低秩近似、注意力权重构造与二阶统计量里都很常见。</p>
</li>
<li>
<p>点积（Dot Product）：<span displaypfx="inline-" class="mathjax-container">\((1\times m)(m\times 1)=(1\times 1)\)</span>。</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{r}=\begin{bmatrix}1 &amp; 2 &amp; 3\end{bmatrix}\in\mathbb{R}^{1\times 3},\quad \mathbf{c}=\begin{bmatrix}4\\ 5\\ 6\end{bmatrix}\in\mathbb{R}^{3\times 1}\]</span>
<span displaypfx="" class="mathjax-container">\[\mathbf{r}\mathbf{c}=\begin{bmatrix}32\end{bmatrix}\in\mathbb{R}^{1\times 1}\equiv 32\]</span>
<p>结果是标量 <span displaypfx="inline-" class="mathjax-container">\(32\)</span>，等价于点积： <span displaypfx="inline-" class="mathjax-container">\(\mathbf{r}\mathbf{c}=\sum_{i=1}^{m} r_i c_i\)</span>。在实现里常写成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top\mathbf{y}\)</span>。</p>
</li>
</ol>
<div class="blog_h3"><span class="graybg">仿射变换与仿射子空间</span></div>
<p>仿射（Affine）是线性（Linear）的一个自然扩展。线性变换要求 <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})=A\mathbf{x}\)</span>，必须把原点 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{0}\)</span> 映到原点；仿射变换则允许再加一个平移项：</p>
<span displaypfx="" class="mathjax-container">\[f(\mathbf{x})=A\mathbf{x}+\mathbf{b}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 是线性部分，负责旋转、缩放、剪切、投影等形变； <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 是平移项（Translation / Bias），负责把整个空间整体推走一个位移。因此，仿射变换可以理解为“先做线性变换，再做平移”。</p>
<p>几何上，线性变换像在原点固定不动的前提下拉伸或扭转整张网格；仿射变换则像先把网格按 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 变形，再把整张网格连同原点一起平移到别的位置。两者最核心的区别是：<span style="background-color: #c0c0c0;">线性变换保原点，仿射变换不一定保原点</span>。</p>
<p>最简单的一维例子是 <span displaypfx="inline-" class="mathjax-container">\(f(x)=2x\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(g(x)=2x+3\)</span>。前者是线性的，因为 <span displaypfx="inline-" class="mathjax-container">\(f(0)=0\)</span>；后者是仿射的，因为它先把数轴按 2 倍拉伸，再整体平移 3 个单位，所以 <span displaypfx="inline-" class="mathjax-container">\(g(0)=3\)</span>，不再经过原点。二维里也是同样： <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})=\mathbf{x}\)</span> 是恒等线性变换，而 <span displaypfx="inline-" class="mathjax-container">\(g(\mathbf{x})=\mathbf{x}+\begin{bmatrix}1\\2\end{bmatrix}\)</span> 会把整张平面网格整体向右平移 1、向上平移 2。</p>
<p>仿射函数（Affine Function）在优化与机器学习里极其常见。一维情形的 <span displaypfx="inline-" class="mathjax-container">\(f(x)=ax+b\)</span> 就是最简单的仿射函数；高维里则写成 <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})=\mathbf{w}^\top\mathbf{x}+b\)</span>。因此，神经网络里通常口头说“线性层（Linear Layer）”，但如果带偏置 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>，更严格的数学名称其实是仿射层（Affine Layer）：</p>
<span displaypfx="" class="mathjax-container">\[Y=XW+\mathbf{1}b^\top\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 是输入矩阵， <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 是线性变换矩阵， <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是偏置向量， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}\)</span> 是把偏置广播到每个样本上的全 1 列向量。若没有偏置，才是严格意义上的线性映射。</p>
<p>仿射还有一个重要性质：它保持仿射组合（Affine Combination）。若 <span displaypfx="inline-" class="mathjax-container">\(\sum_i \alpha_i=1\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[f\!\left(\sum_i \alpha_i \mathbf{x}_i\right)=\sum_i \alpha_i f(\mathbf{x}_i)\]</span>
<p>这意味着直线、平面、平行关系和凸组合结构在仿射变换下会被保留；因此很多几何对象在经过仿射变换后，仍然保持“像线还是线，像平面还是平面”的基本类型，但位置和方向可能改变。</p>
<p>仿射子空间（Affine Subspace）则是“线性子空间整体平移后得到的集合”。若 <span displaypfx="inline-" class="mathjax-container">\(U\)</span> 是一个线性子空间， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_0\)</span> 是空间中的某个固定点，则</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{A}=\mathbf{x}_0+U=\{\mathbf{x}_0+\mathbf{u}:\mathbf{u}\in U\}\]</span>
<p>就是一个仿射子空间。它和线性子空间的差别也在于是否经过原点：线性子空间必须包含原点，仿射子空间不必。例如二维中的直线 <span displaypfx="inline-" class="mathjax-container">\(x+y=1\)</span> 就是一个仿射子空间；它的方向部分与线性子空间 <span displaypfx="inline-" class="mathjax-container">\(x+y=0\)</span> 相同，但整条直线被平移开了，因此不经过原点。</p>
<p>这正是为什么超平面 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=0\)</span> 被称为仿射子空间而不是线性子空间：当 <span displaypfx="inline-" class="mathjax-container">\(b\ne 0\)</span> 时，它通常不经过原点，只是由某个线性超平面整体平移而来。优化里的等式约束 <span displaypfx="inline-" class="mathjax-container">\(h_j(x)=0\)</span> 若要求 <span displaypfx="inline-" class="mathjax-container">\(h_j\)</span> 是仿射函数，含义也正是“约束边界仍然保持平直结构，但允许存在偏置和平移”。</p>
<p>更进一步，双仿射（Biaffine）也是同一族概念的延伸。它通常在两个向量之间做一个双线性项 <span displaypfx="inline-" class="mathjax-container">\(h_i^\top U h_j\)</span>，再加上线性项和偏置项，因此既包含“两个变量之间的乘性交互”，也包含仿射偏置修正。理解了仿射，就能把“线性 / 仿射 / 双线性 / 双仿射”看成一条逐步加复杂度的函数族谱。</p>
<div class="blog_h4"><span class="graybg">线性到双仿射的公式族谱</span></div>
<p>把输出视为一个标量时，这条族谱可以写成一组并列的标准形式：</p>
<span displaypfx="" class="mathjax-container">\[f_{\mathrm{linear}}(\mathbf{x})=\mathbf{w}^\top \mathbf{x}\]</span>
<span displaypfx="" class="mathjax-container">\[f_{\mathrm{affine}}(\mathbf{x})=\mathbf{w}^\top \mathbf{x}+b\]</span>
<span displaypfx="" class="mathjax-container">\[s_{\mathrm{bilinear}}(\mathbf{x},\mathbf{z})=\mathbf{x}^\top U\mathbf{z}\]</span>
<span displaypfx="" class="mathjax-container">\[s_{\mathrm{biaffine}}(\mathbf{x},\mathbf{z})=\mathbf{x}^\top U\mathbf{z}+\mathbf{a}^\top\mathbf{x}+\mathbf{c}^\top\mathbf{z}+b\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\in\mathbb{R}^{m},\mathbf{z}\in\mathbb{R}^{n}\)</span> 是两个输入向量， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},\mathbf{a}\in\mathbb{R}^{m}\)</span>， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}\in\mathbb{R}^{n}\)</span>， <span displaypfx="inline-" class="mathjax-container">\(U\in\mathbb{R}^{m\times n}\)</span>， <span displaypfx="inline-" class="mathjax-container">\(b\in\mathbb{R}\)</span>。这里的 <span displaypfx="inline-" class="mathjax-container">\(U\)</span> 是双线性项的参数矩阵（Parameter Matrix）：它不作用于单个向量本身，而是给“ <span displaypfx="inline-" class="mathjax-container">\(x_p\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(z_q\)</span> 同时出现”这类二元交互分配权重。把式子展开后可写成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top U\mathbf{z}=\sum_{p=1}^{m}\sum_{q=1}^{n}x_p\,U_{pq}\,z_q\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(U_{pq}\)</span> 直接控制第 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 个 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 特征与第 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 个 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\)</span> 特征之间的交互强度。 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}^\top\mathbf{x}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}^\top\mathbf{z}\)</span> 是单边线性项，分别描述各自独立的角色偏好； <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是全局基线偏置。</p>
<p>把这个定义写成一个最小的 2×3 例子，会更容易直接看出“连接强度”来自哪里。令</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}=\begin{bmatrix}x_1\\x_2\end{bmatrix},\quad \mathbf{z}=\begin{bmatrix}z_1\\z_2\\z_3\end{bmatrix},\quad U=\begin{bmatrix}u_{11} &amp; u_{12} &amp; u_{13}\\u_{21} &amp; u_{22} &amp; u_{23}\end{bmatrix}\]</span>
<p>则双线性项为</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top U\mathbf{z}=\begin{bmatrix}x_1 &amp; x_2\end{bmatrix}\begin{bmatrix}u_{11} &amp; u_{12} &amp; u_{13}\\u_{21} &amp; u_{22} &amp; u_{23}\end{bmatrix}\begin{bmatrix}z_1\\z_2\\z_3\end{bmatrix}\]</span>
<span displaypfx="" class="mathjax-container">\[=x_1u_{11}z_1+x_1u_{12}z_2+x_1u_{13}z_3+x_2u_{21}z_1+x_2u_{22}z_2+x_2u_{23}z_3\]</span>
<p>这一步把抽象的矩阵乘法拆成了六条显式连接： <span displaypfx="inline-" class="mathjax-container">\(u_{11}\)</span> 控制 <span displaypfx="inline-" class="mathjax-container">\(x_1\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(z_1\)</span> 的连接强度， <span displaypfx="inline-" class="mathjax-container">\(u_{23}\)</span> 控制 <span displaypfx="inline-" class="mathjax-container">\(x_2\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(z_3\)</span> 的连接强度。若某个 <span displaypfx="inline-" class="mathjax-container">\(u_{pq}\)</span> 很大且为正，只要对应的 <span displaypfx="inline-" class="mathjax-container">\(x_p\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(z_q\)</span> 同时取大值，这一对特征就会显著抬高总分；若某个 <span displaypfx="inline-" class="mathjax-container">\(u_{pq}\)</span> 为负，则说明这对特征的共同出现会压低分数。</p>
<p>在同一个例子里，双仿射只是在双线性项之外再加上单边项与偏置：</p>
<span displaypfx="" class="mathjax-container">\[s_{\mathrm{biaffine}}(\mathbf{x},\mathbf{z})=\mathbf{x}^\top U\mathbf{z}+\mathbf{a}^\top\mathbf{x}+\mathbf{c}^\top\mathbf{z}+b\]</span>
<span displaypfx="" class="mathjax-container">\[=\mathbf{x}^\top U\mathbf{z}+(a_1x_1+a_2x_2)+(c_1z_1+c_2z_2+c_3z_3)+b\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(a_1,a_2\)</span> 描述 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 一侧各特征自身的偏好， <span displaypfx="inline-" class="mathjax-container">\(c_1,c_2,c_3\)</span> 描述 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\)</span> 一侧各特征自身的偏好， <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 给出无条件基线分数。于是，双线性回答的是“这两组特征配在一起有多合适”，双仿射回答的则是“它们配在一起有多合适，并且各自本身是否已经带有倾向”。</p>
<p>若输出不是一个标量分数，而是 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个关系类别或标签分数，则通常不只使用一个 <span displaypfx="inline-" class="mathjax-container">\(U\)</span>，而是为每个类别准备一张交互矩阵 <span displaypfx="inline-" class="mathjax-container">\(U^{(k)}\)</span>，或等价地把它们堆成三阶张量 <span displaypfx="inline-" class="mathjax-container">\(U\in\mathbb{R}^{K\times m\times n}\)</span>。这时每个类别都拥有自己的一套“特征两两交互”权重。</p>
<p>从函数结构看，线性与仿射的区别在于是否含偏置；双线性与双仿射的区别同样在于是否在交互项之外再加入单边项与偏置。固定 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\)</span> 时， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top U\mathbf{z}\)</span> 对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 是线性的， <span displaypfx="inline-" class="mathjax-container">\(s_{\text{biaffine}}(\mathbf{x},\mathbf{z})\)</span> 对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 则是仿射的；固定 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 时，对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\)</span> 也是同样的性质。这正是 “biaffine” 这个名字的数学含义。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/affine-biaffine-family.png"><img class="alignnone size-full" src="https://blog.gmem.cc/wp-content/uploads/2026/03/affine-biaffine-family.png" alt="affine-biaffine-family" width="1920" height="1203" /></a></p>
<p>图中上排用一维切片显示“是否经过原点”这一关键差异： <span displaypfx="inline-" class="mathjax-container">\(f(x)=ax\)</span> 必然经过原点，而 <span displaypfx="inline-" class="mathjax-container">\(f(x)=ax+b\)</span> 由于加入偏置项，整条直线沿输出轴发生平移。下排保持与公式族谱一致，仍写成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top U\mathbf{z}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top U\mathbf{z}+\mathbf{a}^\top\mathbf{x}+\mathbf{c}^\top\mathbf{z}+b\)</span>；图像本身展示的是把高维向量关系压到二维后的一个切片。纯双线性项的零等值线体现为对称的交互边界；加入单边项与偏置后，零等值线整体偏移，分数面出现倾斜与平移，表示模型不仅关心“是否匹配”，还关心两个对象各自单独的倾向。</p>
<p>在依存句法（Dependency Parsing）、关系抽取（Relation Extraction）和成对匹配（Pairwise Matching）任务中，这个结构尤其有用。纯双线性项只能表达“组合后是否相容”，双仿射则进一步允许模型学习“某个对象本身就更像 head / dependent”或“某个实体本身就更像某类关系的一端”。因此，双仿射通常既比纯仿射更能表达交互，又比纯双线性更稳定。</p>
<div class="blog_h3"><span class="graybg">转置（Transpose）</span></div>
<p>转置（Transpose）把行列互换：对 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{m\times n}\)</span>，其转置 <span displaypfx="inline-" class="mathjax-container">\(A^\top\in\mathbb{R}^{n\times m}\)</span> 满足 <span displaypfx="inline-" class="mathjax-container">\((A^\top)_{ij}=A_{ji}\)</span>。</p>
<p>它常用于对齐乘法形状、把点积写成矩阵乘法，以及在推导中“移动”矩阵：例如 <span displaypfx="inline-" class="mathjax-container">\((AB)^\top=B^\top A^\top\)</span>。Transformer 注意力中的 <span displaypfx="inline-" class="mathjax-container">\(QK^\top\)</span> 就是典型的“先转置再相乘”。</p>
<div class="blog_h4"><span class="graybg">例：行列互换与形状变化</span></div>
<p>令</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}1 &amp; 2 &amp; 3\\ 4 &amp; 5 &amp; 6\end{bmatrix}\in\mathbb{R}^{2\times 3}\]</span>
<p>则</p>
<span displaypfx="" class="mathjax-container">\[A^\top=\begin{bmatrix}1 &amp; 4\\ 2 &amp; 5\\ 3 &amp; 6\end{bmatrix}\in\mathbb{R}^{3\times 2}\]</span>
<p>可以直接看到：原矩阵的第 1 行变成转置后的第 1 列；因此 <span displaypfx="inline-" class="mathjax-container">\((m\times n)^\top=(n\times m)\)</span>。</p>
<div class="blog_h3"><span class="graybg">Hadamard 乘积（Hadamard Product）</span></div>
<p>Hadamard 乘积（Hadamard Product）是逐元素（Element-wise）相乘：若 <span displaypfx="inline-" class="mathjax-container">\(X,M\)</span> 形状相同，则 <span displaypfx="inline-" class="mathjax-container">\((X\odot M)_{ij}=X_{ij}M_{ij}\)</span>。</p>
<p>典型用途是掩码（Masking）与门控（Gating）。例：令 <span displaypfx="inline-" class="mathjax-container">\(M\in\{0,1\}^{B\times d}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(X\odot M\)</span> 会把被屏蔽的特征位置直接置零；也可以用 <span displaypfx="inline-" class="mathjax-container">\(M\in[0,1]^{B\times d}\)</span> 做连续缩放。</p>
<div class="blog_h3"><span class="graybg">矩阵分解（Factorization / Decomposition）</span></div>
<p>矩阵分解（Decomposition）把矩阵写成更“易处理”的结构乘积，用于求解、降维与稳定计算。常见形式包括：</p>
<ul>
<li>SVD： <span displaypfx="inline-" class="mathjax-container">\(A=U\Sigma V^\top\)</span>，用于 PCA（Principal Component Analysis）、低秩近似与数值稳健求解。</li>
<li>QR： <span displaypfx="inline-" class="mathjax-container">\(A=QR\)</span>（<span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 正交、<span displaypfx="inline-" class="mathjax-container">\(R\)</span> 上三角），常用于最小二乘与正交化。</li>
<li>Cholesky：对对称正定（SPD）矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(A=LL^\top\)</span>，常用于高斯模型与二次优化中的快速求解。</li>
</ul>
<div class="blog_h3"><span class="graybg">迹（Trace）</span></div>
<p>矩阵的迹（Trace）定义为对角线元素之和：对方阵 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{n\times n}\)</span>，</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{tr}(A)=\sum_{i=1}^{n} A_{ii}\]</span>
<p>迹在推导里常用的性质是循环不变性（Cyclic Property）：<span displaypfx="inline-" class="mathjax-container">\(\mathrm{tr}(AB)=\mathrm{tr}(BA)\)</span>（形状匹配时）。一个高频等式是 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{tr}(A^\top A)=\|A\|_F^2\)</span>，它把“平方和”写成迹，便于做矩阵微分与正则化推导。</p>
<div class="blog_h3"><span class="graybg">范数（Matrix Norms）</span></div>
<p>范数（Norm）刻画矩阵的“大小”。最常用的是 Frobenius 范数：</p>
<span displaypfx="" class="mathjax-container">\[\|A\|_F=\sqrt{\sum_{i,j}A_{ij}^2}\]</span>
<p>它等价于把矩阵按元素展平后的 <span displaypfx="inline-" class="mathjax-container">\(\ell_2\)</span> 范数，常用于权重衰减（Weight Decay）/L2 正则。另一个常见的是谱范数（Spectral Norm）<span displaypfx="inline-" class="mathjax-container">\(\|A\|_2\)</span>（最大奇异值），用于控制 Lipschitz 常数与训练稳定性（如 spectral normalization）。</p>
<div class="blog_h3"><span class="graybg">外积与秩一更新（Outer Product / Rank-1 Update）</span></div>
<p>外积（Outer Product）把两个向量映射为矩阵：对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\in\mathbb{R}^{m}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\in\mathbb{R}^{n}\)</span>，</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{u}\mathbf{v}^\top\in\mathbb{R}^{m\times n}\]</span>
<p>它是一个秩一（Rank-1）矩阵。外积在统计与学习中常用于构造二阶量：例如样本协方差的无偏估计可写成中心化向量的外积平均 <span displaypfx="inline-" class="mathjax-container">\(\Sigma\approx \frac{1}{N}\sum_{k=1}^{N}(\mathbf{x}_k-\bar{\mathbf{x}})(\mathbf{x}_k-\bar{\mathbf{x}})^\top\)</span>。</p>
<p>例：令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}=\begin{bmatrix}1\\ 2\\ 3\end{bmatrix}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}=\begin{bmatrix}4\\ 5\end{bmatrix}\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{u}\mathbf{v}^\top=\begin{bmatrix}4 &amp; 5\\ 8 &amp; 10\\ 12 &amp; 15\end{bmatrix}\]</span>
<p>直观上，外积得到的矩阵每一列都是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\)</span> 的缩放：第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 列等于 <span displaypfx="inline-" class="mathjax-container">\(v_j\mathbf{u}\)</span>。因此所有列共线，矩阵的秩最多为 1（除非 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\)</span> 为零向量）。</p>
<p>把外积视为线性算子更直接：对任意 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\in\mathbb{R}^{n}\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{u}\mathbf{v}^\top)\mathbf{x}=\mathbf{u}(\mathbf{v}^\top\mathbf{x})\)</span>。这表示先沿 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\)</span> 做一次投影/打分得到标量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}^\top\mathbf{x}\)</span>，再沿 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\)</span> 方向输出。例：取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=\begin{bmatrix}1\\ 1\end{bmatrix}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}^\top\mathbf{x}=9\)</span>，从而 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{u}\mathbf{v}^\top)\mathbf{x}=9\mathbf{u}=\begin{bmatrix}9\\ 18\\ 27\end{bmatrix}\)</span>。</p>
<p>秩一更新（Rank-1 Update）则是把矩阵写成 <span displaypfx="inline-" class="mathjax-container">\(A\leftarrow A+\mathbf{u}\mathbf{v}^\top\)</span>：只引入一个方向上的低秩结构，常用于用较低代价注入统计量/二阶近似，或在保持主结构的前提下做小幅调整。</p>
<div class="blog_h2"><span class="graybg">行列式</span></div>
<p>行列式（Determinant）把一个方阵 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{n\times n}\)</span> 映射为标量 <span displaypfx="inline-" class="mathjax-container">\(\det(A)\)</span>。几何上，它是线性变换对体积的缩放因子（Volume Scaling Factor）：绝对值表示缩放倍数，符号表示是否翻转取向（Orientation Flip）。</p>
<p>二维情形最直观：若</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}a &amp; b\\ c &amp; d\end{bmatrix}\]</span>
<p>则</p>
<span displaypfx="" class="mathjax-container">\[\det(A)=ad-bc\]</span>
<p>例： <span displaypfx="inline-" class="mathjax-container">\(A=\begin{bmatrix}2 &amp; 1\\ 0 &amp; 3\end{bmatrix}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\det(A)=6\)</span>：面积被放大 6 倍。</p>
<p>关键结论：<span style="background-color: #c0c0c0;">方阵可逆（Invertible）当且仅当行列式非零</span>。当 <span displaypfx="inline-" class="mathjax-container">\(\det(A)=0\)</span> 时，变换会把体积压扁到低维（丢失信息），对应列向量线性相关（Linearly Dependent）。</p>
<p>常用性质：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\det(AB)=\det(A)\det(B)\)</span></li>
<li><span displaypfx="inline-" class="mathjax-container">\(\det(A^\top)=\det(A)\)</span></li>
<li><span displaypfx="inline-" class="mathjax-container">\(\det(I)=1\)</span></li>
</ul>
<p>若把特征值（Eigenvalues）记作 <span displaypfx="inline-" class="mathjax-container">\(\{\lambda_i\}_{i=1}^n\)</span>（按代数重数计），则 <span displaypfx="inline-" class="mathjax-container">\(\det(A)=\prod_i \lambda_i\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathrm{tr}(A)=\sum_i \lambda_i\)</span>。</p>
<div class="blog_h2"><span class="graybg">矩阵的秩</span></div>
<p>矩阵的秩（Rank）刻画“列（或行）里最多有多少个线性无关（Linearly Independent）的方向”。对 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{m\times n}\)</span>，秩定义为列空间（Column Space）的维数：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{rank}(A)=\dim(\mathrm{col}(A))\]</span>
<p>这一定义的直接含义是：若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rank}(A)=r\)</span>，那么 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的所有列向量都只能张成一个 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 维子空间，超出这个子空间的方向它完全无法表达。反过来，若某一列可以由其他列线性组合得到，它就不提供新的维度，因此不会增加秩。</p>
<p>它也等于行空间（Row Space）的维数（行秩=列秩）。把 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 看作线性映射 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^n\to\mathbb{R}^m\)</span>，秩就是输出子空间的维度：最多能输出多少个自由方向。</p>
<p>满秩（Full Rank）通常指 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rank}(A)=\min(m,n)\)</span>。对方阵 <span displaypfx="inline-" class="mathjax-container">\(n\times n\)</span> 而言，满秩等价于可逆（也等价于 <span displaypfx="inline-" class="mathjax-container">\(\det(A)\ne 0\)</span>）。</p>
<p>线性方程组（Linear System）<span displaypfx="inline-" class="mathjax-container">\(A\mathbf{x}=\mathbf{b}\)</span> 的解与秩直接相关：设增广矩阵为 <span displaypfx="inline-" class="mathjax-container">\(\left[A\,\middle|\,\mathbf{b}\right]\)</span>，则</p>
<ul>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rank}(A)\ne \mathrm{rank}\!\left(\left[A\,\middle|\,\mathbf{b}\right]\right)\)</span>，无解。</li>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rank}(A)=\mathrm{rank}\!\left(\left[A\,\middle|\,\mathbf{b}\right]\right)=n\)</span>（未知数个数），唯一解。</li>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rank}(A)=\mathrm{rank}\!\left(\left[A\,\middle|\,\mathbf{b}\right]\right)&lt;n\)</span>，无穷多解（存在自由变量）。</li>
</ul>
<p>与 SVD 的关系非常实用：<span style="background-color: #c0c0c0;">秩等于非零奇异值（Singular Values）的个数</span>，因此在数值计算里常用“奇异值是否接近 0”判断有效秩（Numerical Rank）。</p>
<div class="blog_h2"><span class="graybg">二次型（Quadratic Form）</span></div>
<p>在高中里，一元二次函数常写成 <span displaypfx="inline-" class="mathjax-container">\(ax^2+bx+c\)</span>。把“二次”推广到多元，并且只保留二次项（没有一次项与常数项），就得到二次型（Quadratic Form）。二维里最常见的形式是：</p>
<span displaypfx="" class="mathjax-container">\[q(x,y)=ax^2+bxy+cy^2\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(bxy\)</span> 是交叉项（Cross Term）：它把不同变量“耦合在一起”。在解析几何（Analytic Geometry）里，交叉项常对应二次曲线（Conic Section）的主轴（Principal Axes）不与坐标轴对齐（图形呈旋转/倾斜）。</p>
<p>若再把一次项与常数项加回来，就得到更一般的二次多项式（Quadratic Polynomial）<span displaypfx="inline-" class="mathjax-container">\(ax^2+bxy+cy^2+dx+ey+f\)</span>。这时：交叉项 <span displaypfx="inline-" class="mathjax-container">\(bxy\)</span> 主要反映主轴旋转；一次项 <span displaypfx="inline-" class="mathjax-container">\(dx+ey\)</span> 往往表示图形的中心/顶点从原点平移出去；常数项 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 则改变整体基准值，进而影响图形的截距、大小以及是否与 <span displaypfx="inline-" class="mathjax-container">\(q(x,y)=0\)</span> 相交。只有把一次项和常数项都去掉时，我们讨论的才是纯粹的二次型。</p>
<p>下面两幅图都基于<span style="background-color: #c0c0c0;">等值线（Level Set）</span>生成：先固定常数 <span displaypfx="inline-" class="mathjax-container">\(k\)</span>，在平面上求解 <span displaypfx="inline-" class="mathjax-container">\(q(x,y)=k\)</span> 得到等值线，再把不同 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 的结果叠加，并与 <span displaypfx="inline-" class="mathjax-container">\(z=q(x,y)\)</span> 的三维曲面对应显示。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/quadratic-forms.png"><img class="alignnone size-full wp-image-40671" src="https://blog.gmem.cc/wp-content/uploads/2026/03/quadratic-forms.png" alt="quadratic-forms" width="100%" /></a></p>
<p>第一幅展示无交叉项（<span displaypfx="inline-" class="mathjax-container">\(b=0\)</span>）的典型形态：等值线与坐标轴对齐，曲面主轴方向也与坐标轴一致。</p>
<p>&nbsp;</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/quadratic-forms-cross.png"><img class="alignnone size-full wp-image-40677" src="https://blog.gmem.cc/wp-content/uploads/2026/03/quadratic-forms-cross.png" alt="quadratic-forms-cross" width="100%" /></a></p>
<p>第二幅展示含交叉项（<span displaypfx="inline-" class="mathjax-container">\(b\ne 0\)</span>）的情形：等值线整体发生旋转/倾斜，三维曲面看起来更“不规则”。</p>
<p>在线性代数里，用矩阵（Matrix）把系数组织起来更方便。令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x_1,\ldots,x_n)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{n\times n}\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[q(\mathbf{x})=\mathbf{x}^\top A\mathbf{x}=\sum_{i=1}^{n}\sum_{j=1}^{n}A_{ij}x_i x_j\]</span>
<p>上面这条等式不是“记号游戏”，而是把矩阵乘法按分量（Component）展开后的结果。把它分两步看最清楚：</p>
<ol>
<li>先做一次矩阵-向量乘法：令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}=A\mathbf{x}\)</span>，则第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量是</li>
</ol>
<span displaypfx="" class="mathjax-container">\[y_i=(A\mathbf{x})_i=\sum_{j=1}^{n}A_{ij}x_j\]</span>
<ol start="2">
<li>再做一次点积： <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top\mathbf{y}=\sum_{i=1}^{n}x_i y_i\)</span>。把第 1 步的 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 代入，就得到</li>
</ol>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=\mathbf{x}^\top(A\mathbf{x})=\sum_{i=1}^{n}x_i\left(\sum_{j=1}^{n}A_{ij}x_j\right)=\sum_{i=1}^{n}\sum_{j=1}^{n}A_{ij}x_i x_j\]</span>
<p>这就是“双重求和（Double Summation）”的含义：每一个矩阵元素 <span displaypfx="inline-" class="mathjax-container">\(A_{ij}\)</span> 都在给二次项 <span displaypfx="inline-" class="mathjax-container">\(x_i x_j\)</span> 分配一个权重；当 <span displaypfx="inline-" class="mathjax-container">\(i=j\)</span> 时就是平方项 <span displaypfx="inline-" class="mathjax-container">\(x_i^2\)</span>，当 <span displaypfx="inline-" class="mathjax-container">\(i\ne j\)</span> 时就是交叉项 <span displaypfx="inline-" class="mathjax-container">\(x_i x_j\)</span>。</p>
<p>二维情形最直观：若希望展开后得到 <span displaypfx="inline-" class="mathjax-container">\(ax^2+bxy+cy^2\)</span>，可以取</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}a &amp; \frac{b}{2}\\ \frac{b}{2} &amp; c\end{bmatrix}\]</span>
<p>这里把向量写成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x,y)^\top\)</span>，按矩阵乘法展开一遍：</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{x}=\begin{bmatrix}a &amp; \frac{b}{2}\\ \frac{b}{2} &amp; c\end{bmatrix}\begin{bmatrix}x\\ y\end{bmatrix}=\begin{bmatrix}ax+\frac{b}{2}y\\ \frac{b}{2}x+cy\end{bmatrix}\]</span>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top(A\mathbf{x})=\begin{bmatrix}x &amp; y\end{bmatrix}\begin{bmatrix}ax+\frac{b}{2}y\\ \frac{b}{2}x+cy\end{bmatrix}=ax^2+\frac{b}{2}xy+\frac{b}{2}yx+cy^2=ax^2+bxy+cy^2\]</span>
<p>可以看到：交叉项 <span displaypfx="inline-" class="mathjax-container">\(xy\)</span> 的系数 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 实际来自两处对称位置 <span displaypfx="inline-" class="mathjax-container">\(A_{12}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(A_{21}\)</span> 的“合力”（各贡献一半）。这也会自然导向下一节的结论：二次型只依赖矩阵的对称部分。</p>
<p>例：取 <span displaypfx="inline-" class="mathjax-container">\(q(x,y)=5x^2-4xy+5y^2\)</span>，对应 <span displaypfx="inline-" class="mathjax-container">\(A=\begin{bmatrix}5 &amp; -2\\ -2 &amp; 5\end{bmatrix}\)</span>。这一类表达式不仅在几何里出现（椭圆（Ellipse）/双曲线（Hyperbola）），在优化与统计里也高频出现（曲率（Curvature）、距离度量（Distance Metric））。</p>
<div class="blog_h3"><span class="graybg">对称化（Symmetrization）</span></div>
<p>对任意方阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，二次型只依赖其对称部分：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=\mathbf{x}^\top\left(\frac{A+A^\top}{2}\right)\mathbf{x}\]</span>
<p>这句话的意思是：无论 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的非对称部分长什么样，只要 <span displaypfx="inline-" class="mathjax-container">\(\frac{A+A^\top}{2}\)</span> 不变，二次型 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}\)</span> 对所有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 的取值就完全不变。</p>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 拆开看更直观。定义对称部分（Symmetric Part）与反对称部分（Skew-symmetric Part）：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(S=\frac{A+A^\top}{2}\)</span>，满足 <span displaypfx="inline-" class="mathjax-container">\(S=S^\top\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(K=\frac{A-A^\top}{2}\)</span>，满足 <span displaypfx="inline-" class="mathjax-container">\(K^\top=-K\)</span>。</li>
</ul>
<p>这里“对称部分（Symmetric Part）”是一个<span style="background-color: #c0c0c0;">定义</span>：对任意方阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，把 <span displaypfx="inline-" class="mathjax-container">\(S=\frac{A+A^\top}{2}\)</span> 定义为它的对称部分。它与 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> <span style="background-color: #c0c0c0;">同型（同大小）</span>，并且一定是对称矩阵；它不是 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的某个“子矩阵”。</p>
<p>按元素（Entry-wise）写得更直观：对任意 <span displaypfx="inline-" class="mathjax-container">\(i,j\)</span>，</p>
<span displaypfx="" class="mathjax-container">\[S_{ij}=\frac{A_{ij}+A_{ji}}{2},\quad K_{ij}=\frac{A_{ij}-A_{ji}}{2}\]</span>
<p>也就是说：对称部分就是把每一对对称位置 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\((j,i)\)</span> 的元素取平均；反对称部分则记录它们的“差的一半”。因此 <span displaypfx="inline-" class="mathjax-container">\(A=S+K\)</span> 是把任意矩阵分解成“对称 + 反对称”的标准方式，并且这个分解是唯一的（Unique）。</p>
<p>为什么“取 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(A^\top\)</span> 的平均值”就得到对称部分？因为转置（Transpose）会把非对角元素成对交换： <span displaypfx="inline-" class="mathjax-container">\(A_{ij}\leftrightarrow A_{ji}\)</span>。把它们相加后，非对称性（即 <span displaypfx="inline-" class="mathjax-container">\(A_{ij}-A_{ji}\)</span>）会被抵消，只留下“对称的那一半”（即 <span displaypfx="inline-" class="mathjax-container">\(A_{ij}+A_{ji}\)</span>）。再除以 2，是为了把“加了两份”的量恢复到原始尺度：如果 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 本来就对称（<span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span>），那么 <span displaypfx="inline-" class="mathjax-container">\(\frac{A+A^\top}{2}=A\)</span>，不会把矩阵放大一倍。</p>
<p>于是 <span displaypfx="inline-" class="mathjax-container">\(A=S+K\)</span>，并且</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=\mathbf{x}^\top S\mathbf{x}+\mathbf{x}^\top K\mathbf{x}\]</span>
<p>关键点在于：对任意 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span>，都有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top K\mathbf{x}=0\)</span>。理由很短：它是一个标量，等于它自己的转置，而</p>
<span displaypfx="" class="mathjax-container">\[(\mathbf{x}^\top K\mathbf{x})^\top=\mathbf{x}^\top K^\top \mathbf{x}=\mathbf{x}^\top(-K)\mathbf{x}=-(\mathbf{x}^\top K\mathbf{x})\]</span>
<p>一个数如果等于它的相反数，只能是 0。于是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}=\mathbf{x}^\top S\mathbf{x}\)</span>，二次型确实只由对称部分决定。</p>
<p>二维展开能直接看到“只依赖对称部分”的具体含义。令 <span displaypfx="inline-" class="mathjax-container">\(A=\begin{bmatrix}a &amp; b\\ c &amp; d\end{bmatrix}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x_1,x_2)^\top\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=ax_1^2+(b+c)x_1x_2+dx_2^2\]</span>
<p>交叉项系数只出现 <span displaypfx="inline-" class="mathjax-container">\(b+c\)</span>（也就是 <span displaypfx="inline-" class="mathjax-container">\(A_{12}+A_{21}\)</span>），而差值 <span displaypfx="inline-" class="mathjax-container">\(b-c\)</span>（反对称部分）完全不会出现。</p>
<div class="blog_h4"><span class="graybg">例：两个不同矩阵，二次型完全一样</span></div>
<p>下面给一个“看得见”的数值例子。取</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}2 &amp; 4\\ -2 &amp; 4\end{bmatrix}\]</span>
<p>它显然不是对称矩阵（因为 <span displaypfx="inline-" class="mathjax-container">\(A_{12}=4\ne -2=A_{21}\)</span>）。计算它的对称部分：</p>
<span displaypfx="" class="mathjax-container">\[\frac{A+A^\top}{2}=\frac{1}{2}\left(\begin{bmatrix}2 &amp; 4\\ -2 &amp; 4\end{bmatrix}+\begin{bmatrix}2 &amp; -2\\ 4 &amp; 4\end{bmatrix}\right)=\begin{bmatrix}2 &amp; 1\\ 1 &amp; 4\end{bmatrix}=S\]</span>
<p>现在比较二次型。对任意 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x,y)^\top\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{x}=\begin{bmatrix}2x+4y\\ -2x+4y\end{bmatrix}\Rightarrow \mathbf{x}^\top A\mathbf{x}=x(2x+4y)+y(-2x+4y)=2x^2+2xy+4y^2\]</span>
<span displaypfx="" class="mathjax-container">\[S\mathbf{x}=\begin{bmatrix}2x+y\\ x+4y\end{bmatrix}\Rightarrow \mathbf{x}^\top S\mathbf{x}=x(2x+y)+y(x+4y)=2x^2+2xy+4y^2\]</span>
<p>两者对所有 <span displaypfx="inline-" class="mathjax-container">\((x,y)\)</span> 都完全相同；例如取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(1,2)^\top\)</span>，都有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}=\mathbf{x}^\top S\mathbf{x}=22\)</span>。这就直观解释了“二次型只依赖对称部分”的含义：反对称的那一半怎么改，都不会改变 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}\)</span> 的值。</p>
<p>因此讨论二次型时通常可假设 <span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span>。这也解释了为什么二次型与对称矩阵/半正定性（Positive Semi-Definite, PSD）紧密绑定。</p>
<div class="blog_h3"><span class="graybg">二次型的标准型（Standard Form）</span></div>
<div class="blog_h4"><span class="graybg">标准型与对角标准型是什么</span></div>
<p>二次型 <span displaypfx="inline-" class="mathjax-container">\(q(\mathbf{x})=\mathbf{x}^\top A\mathbf{x}\)</span> 本身是一个几何对象；它在不同坐标系（Coordinate System）/基（Basis）下的<span style="background-color: #c0c0c0;">矩阵表示</span>会不同：同一个几何对象，用不同坐标轴/基表示时，系数矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的元素会改变。</p>
<p>这里的标准型指的是：在一类允许的坐标变换（可逆线性变量替换（Invertible Linear Change of Variables））下，把同一个二次型写成某种<span style="background-color: #c0c0c0;">约定的简化代表</span>。不同教材的约定略有差异，但最常用的目标是：把交叉项（Cross Term）消掉，露出每个坐标轴方向上的“纯平方项”。</p>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=T\mathbf{y}\)</span> 理解成换基（Change of Basis）会更不容易出错： <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 是同一几何向量在新基下的坐标，矩阵 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 由新基向量在旧基下的坐标组成。因为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 可逆，两套坐标是一一对应的，可以互相换回：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{y}=T^{-1}\mathbf{x},\quad \mathbf{x}=T\mathbf{y}\]</span>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=T\mathbf{y}\)</span> 代入可得</p>
<span displaypfx="" class="mathjax-container">\[q(\mathbf{x})=\mathbf{x}^\top A\mathbf{x}=\mathbf{y}^\top(T^\top A T)\mathbf{y}\]</span>
<p>这里要求 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 可逆（Invertible），意味着这个变量代换不会把空间压缩到低维（不会丢维度）。因此在新坐标 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 下，二次型对应的系数矩阵变为 <span displaypfx="inline-" class="mathjax-container">\(T^\top A T\)</span>（这叫合同变换（Congruence Transformation））。可以把 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 理解为“旋转/缩放后的新坐标轴”在旧坐标里的表示：同一个二次型在新坐标系里的系数就由 <span displaypfx="inline-" class="mathjax-container">\(T^\top A T\)</span> 给出。标准型的目标就是选取合适的 <span displaypfx="inline-" class="mathjax-container">\(T\)</span>，把 <span displaypfx="inline-" class="mathjax-container">\(T^\top A T\)</span> 化到更简单的结构。</p>
<p>对角标准型（Diagonal Form）指把二次型写成“只有平方项、没有交叉项”的形式（也常称对角规范形（Diagonal Canonical Form））：</p>
<span displaypfx="" class="mathjax-container">\[q(\mathbf{x})=\sum_{i=1}^{n}\lambda_i y_i^2\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i\)</span> 是系数，<span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 是新坐标。对角标准型等价于：在新坐标系下，二次型对应的矩阵是对角矩阵（Diagonal Matrix）；“交叉项” <span displaypfx="inline-" class="mathjax-container">\(y_i y_j\)</span>（<span displaypfx="inline-" class="mathjax-container">\(i\ne j\)</span>）消失。</p>
<p>结论需要明确：标准型与原来的二次型描述的是<span style="background-color: #c0c0c0;">同一个二次型/同一组几何等值集合</span>，只是坐标系不同。给定可逆变换 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=T\mathbf{y}\)</span>，任何关于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 的几何描述都可以无损地翻译成关于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 的描述，并且可以随时“还原”回去。矩阵层面也一样：若 <span displaypfx="inline-" class="mathjax-container">\(A' = T^\top A T\)</span> 是标准型里的系数矩阵，则 <span displaypfx="inline-" class="mathjax-container">\(A=(T^{-1})^\top A' T^{-1}\)</span> 可把它变回原坐标下的表示。</p>
<p>接下来真正关心的是：<span style="background-color: #c0c0c0;">如何选 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 才能把交叉项消掉</span>。对二次型而言，一个关键简化是：二次型只依赖矩阵的对称部分，因此总可以先把 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 对称化为 <span displaypfx="inline-" class="mathjax-container">\(\frac{A+A^\top}{2}\)</span> 而不改变 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}\)</span> 的值。于是“消交叉项”的核心问题就变成：对实对称矩阵，能否通过一次正交变基（Orthogonal Change of Basis）把它对角化（Diagonalize）。</p>
<div class="blog_h4"><span class="graybg">怎么来的：换到特征向量基消掉交叉项</span></div>
<p>答案是肯定的；数学依据就是谱定理（Spectral Theorem，也常表述为“实对称矩阵可正交对角化（Orthogonal Diagonalization）”）。若 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{n\times n}\)</span> 是实对称矩阵（Real Symmetric Matrix, <span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span>），则存在正交矩阵（Orthogonal Matrix）<span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 与实对角矩阵（Real Diagonal Matrix）<span displaypfx="inline-" class="mathjax-container">\(\Lambda\)</span> 使得</p>
<span displaypfx="" class="mathjax-container">\[A=Q\Lambda Q^\top,\quad \text{等价于}\quad Q^\top A Q=\Lambda\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(Q=[\mathbf{v}_1,\ldots,\mathbf{v}_n]\)</span> 的列向量是一组单位特征向量（Orthonormal Eigenvectors），<span displaypfx="inline-" class="mathjax-container">\(\Lambda=\mathrm{diag}(\lambda_1,\ldots,\lambda_n)\)</span> 的对角元素是对应特征值（Eigenvalues）。该定理同时包含两个常用事实：特征值都是实数；并且可以选出一组两两正交的特征向量作为基。</p>
<p>这就是你熟悉的特征值分解（Eigendecomposition / Eigenvalue Decomposition）的对称矩阵特例。</p>
<p>一般情况下，如果矩阵可对角化（Diagonalizable），可以写成 <span displaypfx="inline-" class="mathjax-container">\(A=V\Lambda V^{-1}\)</span>（或 <span displaypfx="inline-" class="mathjax-container">\(V^{-1}AV=\Lambda\)</span>），其中 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 的列是特征向量；但 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 不一定正交（Orthogonal），甚至矩阵可能不可对角化（Non-diagonalizable）。</p>
<p>对称矩阵的额外好处是：可以把 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 选成正交矩阵 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(V^{-1}=Q^{-1}=Q^\top\)</span>，分解变成数值上更稳定、几何上更直观的 <span displaypfx="inline-" class="mathjax-container">\(A=Q\Lambda Q^\top\)</span>。</p>
<p>两个最小例子能把“<span displaypfx="inline-" class="mathjax-container">\(V\)</span> 不一定正交 / 甚至不可对角化”说得更具体：</p>
<ul>
<li>
<p><span style="background-color: #c0c0c0;">例 1：可对角化，但 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 不正交</span>。取</p>
<span displaypfx="" class="mathjax-container">\[A_1=\begin{bmatrix}2 &amp; 1\\ 0 &amp; 1\end{bmatrix}\]</span>
<p>它的特征值是 <span displaypfx="inline-" class="mathjax-container">\(\lambda_1=2,\lambda_2=1\)</span>（两个不同特征值意味着在二维里一定能找到两条线性无关的特征向量，因此可对角化）。对应一组特征向量可以取</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{v}_1=\begin{bmatrix}1\\ 0\end{bmatrix},\quad \mathbf{v}_2=\begin{bmatrix}1\\ -1\end{bmatrix}\]</span>
<p>它们并不正交，因为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}_1^\top\mathbf{v}_2=1\ne 0\)</span>。把它们按列组成矩阵 <span displaypfx="inline-" class="mathjax-container">\(V=[\mathbf{v}_1\ \mathbf{v}_2]\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[V=\begin{bmatrix}1 &amp; 1\\ 0 &amp; -1\end{bmatrix},\quad \Lambda=\begin{bmatrix}2 &amp; 0\\ 0 &amp; 1\end{bmatrix},\quad V^{-1}=\begin{bmatrix}1 &amp; 1\\ 0 &amp; -1\end{bmatrix}\]</span>
<p>注意：这个例子里 <span displaypfx="inline-" class="mathjax-container">\(V^{-1}\)</span> 恰好等于 <span displaypfx="inline-" class="mathjax-container">\(V\)</span>（只是代数上的巧合），但它仍然不是正交矩阵，因为正交要求 <span displaypfx="inline-" class="mathjax-container">\(V^{-1}=V^\top\)</span>，而这里并不成立。</p>
<p>并且确实有 <span displaypfx="inline-" class="mathjax-container">\(A_1=V\Lambda V^{-1}\)</span>。这个例子说明：一般矩阵即使可对角化，特征向量也未必能选成“互相垂直的方向”。</p>
</li>
<li>
<p><span style="background-color: #c0c0c0;">例 2：不可对角化（特征值重复，但特征向量不够）</span>。取</p>
<span displaypfx="" class="mathjax-container">\[A_2=\begin{bmatrix}1 &amp; 1\\ 0 &amp; 1\end{bmatrix}\]</span>
<p>它的特征值只有 <span displaypfx="inline-" class="mathjax-container">\(\lambda=1\)</span>（在二维里重复出现）。求特征向量需要解 <span displaypfx="inline-" class="mathjax-container">\((A_2-I)\mathbf{v}=\mathbf{0}\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[A_2-I=\begin{bmatrix}0 &amp; 1\\ 0 &amp; 0\end{bmatrix}\Rightarrow (A_2-I)\begin{bmatrix}x\\ y\end{bmatrix}=\begin{bmatrix}y\\ 0\end{bmatrix}=\begin{bmatrix}0\\ 0\end{bmatrix}\Rightarrow y=0\]</span>
<p>因此所有特征向量都形如 <span displaypfx="inline-" class="mathjax-container">\((x,0)^\top\)</span>，只有 1 个线性无关方向。要写成 <span displaypfx="inline-" class="mathjax-container">\(A_2=V\Lambda V^{-1}\)</span>，矩阵 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 必须可逆（Invertible），这要求有足够多（在二维里是 2 个）线性无关特征向量作为列；该矩阵做不到，所以它不可对角化。</p>
</li>
</ul>
<p>把这个定理放回二次型就能立刻看出“交叉项为什么会消失”。令坐标变换 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}=Q^\top\mathbf{x}\)</span>（把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 在特征向量基下的坐标记作 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span>），则</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=\mathbf{x}^\top(Q\Lambda Q^\top)\mathbf{x}=\mathbf{y}^\top\Lambda\mathbf{y}=\sum_{i=1}^{n}\lambda_i y_i^2\]</span>
<p>注意这里同时出现了两种“换坐标”的写法：线性变换里常写 <span displaypfx="inline-" class="mathjax-container">\(Q^{-1}AQ\)</span>（相似变换（Similarity Transformation）），二次型里写 <span displaypfx="inline-" class="mathjax-container">\(Q^\top A Q\)</span>（合同变换（Congruence Transformation））。对正交矩阵而言 <span displaypfx="inline-" class="mathjax-container">\(Q^{-1}=Q^\top\)</span>，所以它们在这里完全一致：同一个正交变换既给出特征值分解，也把二次型化到没有交叉项的对角标准型。</p>
<p>因此：在对称矩阵的情形，“换到特征向量基”与“把二次型旋转到主轴”是同一件事，只是用不同语言描述。</p>
<p>直观上，特征向量（Eigenvector）给出“主轴方向”（把坐标轴转到这些方向后，交叉项会消失），特征值（Eigenvalue）则是标准型里各平方项前的系数。</p>
<p>详细例子：取</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}5 &amp; -2\\ -2 &amp; 5\end{bmatrix}\]</span>
<p>它是对称矩阵，因此可正交对角化。其特征值与一组单位特征向量可以取为：</p>
<span displaypfx="" class="mathjax-container">\[\lambda_1=3,\ \mathbf{v}_1=\frac{1}{\sqrt{2}}\begin{bmatrix}1\\ 1\end{bmatrix};\quad \lambda_2=7,\ \mathbf{v}_2=\frac{1}{\sqrt{2}}\begin{bmatrix}1\\ -1\end{bmatrix}\]</span>
<p>把它们组成正交矩阵与对角矩阵：</p>
<span displaypfx="" class="mathjax-container">\[Q=[\mathbf{v}_1\ \mathbf{v}_2]=\frac{1}{\sqrt{2}}\begin{bmatrix}1 &amp; 1\\ 1 &amp; -1\end{bmatrix},\quad \Lambda=\begin{bmatrix}3 &amp; 0\\ 0 &amp; 7\end{bmatrix}\]</span>
<p>则 <span displaypfx="inline-" class="mathjax-container">\(A=Q\Lambda Q^\top\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 不是任意新变量，而是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 在特征向量基 <span displaypfx="inline-" class="mathjax-container">\(\{\mathbf{v}_1,\mathbf{v}_2\}\)</span> 下的坐标： <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=y_1\mathbf{v}_1+y_2\mathbf{v}_2=Q\mathbf{y}\)</span>。</p>
<p>由于 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 是正交矩阵（<span displaypfx="inline-" class="mathjax-container">\(Q^\top Q=I\)</span>），左乘 <span displaypfx="inline-" class="mathjax-container">\(Q^\top\)</span> 可得 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}=Q^\top\mathbf{x}\)</span>。这一步就是换基（Change of Basis）：把向量从标准坐标系表达改写为主轴坐标系表达。</p>
<p>因此可显式写出两组坐标关系：</p>
<span displaypfx="" class="mathjax-container">\[y_1=\frac{x_1+x_2}{\sqrt{2}},\quad y_2=\frac{x_1-x_2}{\sqrt{2}}\]</span>
<span displaypfx="" class="mathjax-container">\[x_1=\frac{y_1+y_2}{\sqrt{2}},\quad x_2=\frac{y_1-y_2}{\sqrt{2}}\]</span>
<p>代入标准型：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=\mathbf{y}^\top\Lambda\mathbf{y}=3y_1^2+7y_2^2\]</span>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 用 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 展开，可直接验证“交叉项被旋转消掉”：</p>
<span displaypfx="" class="mathjax-container">\[3y_1^2+7y_2^2=\frac{3}{2}(x_1+x_2)^2+\frac{7}{2}(x_1-x_2)^2=5x_1^2-4x_1x_2+5x_2^2\]</span>
<p>数值校验：取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(1,2)^\top\)</span>，原式为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}=17\)</span>；而 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}=Q^\top\mathbf{x}=\left(\frac{3}{\sqrt{2}},-\frac{1}{\sqrt{2}}\right)^\top\)</span>，代入 <span displaypfx="inline-" class="mathjax-container">\(3y_1^2+7y_2^2\)</span> 同样得到 17。</p>
<p>几何解释：正交矩阵 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 表示旋转/换基，把坐标轴对齐到“主轴方向”（特征向量）；对角矩阵 <span displaypfx="inline-" class="mathjax-container">\(\Lambda\)</span> 表示沿主轴的逐轴缩放（由特征值控制）。因此等值线在 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 坐标系里与轴对齐，形状由 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i\)</span> 决定。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(\Lambda\)</span> 中既有正特征值也有负特征值，则二次型是不定的（Indefinite）：沿某些方向 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 增大，沿另一些方向 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 减小。二维里它的等值线（Level Set）典型呈双曲线（Hyperbola）形状，优化里对应鞍点（Saddle Point）结构。例：令 <span displaypfx="inline-" class="mathjax-container">\(A=\begin{bmatrix}1 &amp; 2\\ 2 &amp; 1\end{bmatrix}\)</span>，其特征值为 3 与 -1，在某个正交坐标 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 下有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^\top A\mathbf{x}=3y_1^2-y_2^2\)</span>，可取正也可取负。</p>
<p>进一步允许一般可逆线性变换（不要求是旋转）时，可把对角项缩放为 <span displaypfx="inline-" class="mathjax-container">\(+1,-1,0\)</span>（Sylvester 惯性定理（Law of Inertia））：二次型被分解为若干正平方项、负平方项与零方向，其中正/负/零项的个数在合同变换下保持不变（换言之，“正方向有几个、负方向有几个、平坦方向有几个”是坐标变换改不掉的性质）；正/负项的个数也称为签名（Signature）。在优化里，它们分别对应局部最小、鞍点与平坦方向。</p>
<div class="blog_h3"><span class="graybg">二次型与机器学习（Quadratic Form in Machine Learning）</span></div>
<p>下面这些场景看起来不同，但核心都在计算“某个方向上的能量/代价”：给一个向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\)</span>，二次型 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}^\top A\mathbf{v}\)</span> 会告诉你它在矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 定义的几何里有多大、代价有多高。</p>
<ul>
<li>平方范数（Squared <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> Norm）：<span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_2^2=\mathbf{x}^\top I\mathbf{x}\)</span>。直白地说，它就是“向量长度的平方”，在训练里常作为最基础的“大小惩罚”。例如权重衰减（Weight Decay）把过大的参数拉回去，本质是在最小化 <span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_2^2\)</span> 这种二次型。</li>
<li>最小二乘与二次损失（Least Squares / MSE）：线性回归目标 <span displaypfx="inline-" class="mathjax-container">\(\|X\mathbf{w}-\mathbf{y}\|_2^2\)</span> 展开后是关于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 的二次型。通俗理解：模型每偏一点，代价按“平方”增长，所以大误差会被更重惩罚。它的闭式解来自正规方程（Normal Equations）<span displaypfx="inline-" class="mathjax-container">\(X^\top X\mathbf{w}=X^\top\mathbf{y}\)</span>。</li>
<li>PCA（Principal Component Analysis）：对中心化（Centering）数据，方向 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\)</span> 上的方差是 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}^\top\Sigma\mathbf{u}\)</span>。这句话的直觉是：“把数据投影到某个方向后，能展开多宽”。PCA 就是在所有单位方向里找让这个二次型最大的方向（主成分），因此主成分就是“信息最密集”的方向。</li>
<li>马氏距离（Mahalanobis Distance）与高斯负对数似然（Gaussian NLL）：核心项是 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{x}-\boldsymbol{\mu})^\top\Sigma^{-1}(\mathbf{x}-\boldsymbol{\mu})\)</span>。可以把它理解成“先按数据真实尺度做校正，再测距离”：方差大的方向偏离一点不算太异常，方差小的方向偏离同样大小则更异常。异常检测（Anomaly Detection）和高斯判别模型都依赖这个量。</li>
<li>二阶近似与优化曲率（Second-order Approximation / Curvature）：在参数点附近，损失变化可写成 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\Delta^\top H\Delta\)</span>。它告诉你“往哪个方向走会涨得快/慢”：特征值大表示该方向很陡，特征值小表示平坦，正负混合则是鞍点（Saddle）。这也是为什么牛顿法、预条件（Preconditioning）和学习率调度都在关心 Hessian 的谱结构。</li>
</ul>
<p>在机器学习的实现层面，二次型也常先通过可逆变量替换 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=T\mathbf{y}\)</span> 化到更易计算的表示： <span displaypfx="inline-" class="mathjax-container">\(q(\mathbf{x})=\mathbf{x}^\top A\mathbf{x}=\mathbf{y}^\top(T^\top A T)\mathbf{y}\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span>，原坐标有 <span displaypfx="inline-" class="mathjax-container">\(\nabla_{\mathbf{x}}q=2A\mathbf{x}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\nabla^2_{\mathbf{x}}q=2A\)</span>；在新坐标下 <span displaypfx="inline-" class="mathjax-container">\(\nabla_{\mathbf{y}}q=2A'\mathbf{y}\)</span>（<span displaypfx="inline-" class="mathjax-container">\(A'=T^\top A T\)</span>）。若进一步化到对角标准型 <span displaypfx="inline-" class="mathjax-container">\(A'=\Lambda\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial q}{\partial y_i}=2\lambda_i y_i\)</span>，逐坐标解耦，推导与实现都会更直接。</p>
<p>这里要区分“性质不变”和“数值不变”：在可逆变量替换下，正定/半正定/不定性质与正负零方向个数（惯性（Inertia））保持不变，因此局部最小/鞍点等优化结构不变；但一般合同变换 <span displaypfx="inline-" class="mathjax-container">\(T^\top A T\)</span> 不要求逐个保留特征值数值，只有正交相似变换 <span displaypfx="inline-" class="mathjax-container">\(Q^\top A Q\)</span> 才逐个保留特征值。</p>
<div class="blog_h2"><span class="graybg">对角矩阵（Diagonal Matrix）</span></div>
<p>对角矩阵（Diagonal Matrix）是只有对角线元素可能非零的方阵。写作 <span displaypfx="inline-" class="mathjax-container">\(D=\mathrm{diag}(d_1,\ldots,d_n)\)</span>，其非对角元素全为 0：</p>
<span displaypfx="" class="mathjax-container">\[D=\begin{bmatrix}d_1 &amp; 0 &amp; \cdots &amp; 0\\ 0 &amp; d_2 &amp; \cdots &amp; 0\\ \vdots &amp; \vdots &amp; \ddots &amp; \vdots\\ 0 &amp; 0 &amp; \cdots &amp; d_n\end{bmatrix}\]</span>
<p>对角矩阵乘以向量等价于“逐维缩放”：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x_1,\ldots,x_n)^\top\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(D\mathbf{x}=(d_1x_1,\ldots,d_nx_n)^\top\)</span>。例：令 <span displaypfx="inline-" class="mathjax-container">\(D=\mathrm{diag}(2,0.5)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(3,4)^\top\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(D\mathbf{x}=(6,2)^\top\)</span>。</p>
<p>在 AI 里，对角矩阵最常见的用途是把“逐元素缩放”写成线性算子：例如 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\odot \mathbf{s}=\mathrm{diag}(\mathbf{s})\mathbf{x}\)</span>。很多优化器的自适应学习率也可以视为对角预条件（Diagonal Preconditioner）：例如 Adam/Adagrad 里的 <span displaypfx="inline-" class="mathjax-container">\(1/\sqrt{v+\epsilon}\)</span> 本质上是按参数维度缩放梯度。</p>
<div class="blog_h2"><span class="graybg">单位矩阵（Identity Matrix）</span></div>
<p>单位矩阵（Identity Matrix）<span displaypfx="inline-" class="mathjax-container">\(I_n\)</span> 是对角线上全为 1、其他元素为 0 的方阵：</p>
<span displaypfx="" class="mathjax-container">\[I_n=\begin{bmatrix}1 &amp; 0 &amp; \cdots &amp; 0\\ 0 &amp; 1 &amp; \cdots &amp; 0\\ \vdots &amp; \vdots &amp; \ddots &amp; \vdots\\ 0 &amp; 0 &amp; \cdots &amp; 1\end{bmatrix}\]</span>
<p>它是矩阵乘法的单位元：对任意形状匹配的矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(AI=IA=A\)</span>；对任意向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(I\mathbf{x}=\mathbf{x}\)</span>。例：若 <span displaypfx="inline-" class="mathjax-container">\(I_2=\begin{bmatrix}1 &amp; 0\\ 0 &amp; 1\end{bmatrix}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(I_2(3,4)^\top=(3,4)^\top\)</span>。</p>
<p>在 AI/数值计算里，<span displaypfx="inline-" class="mathjax-container">\(A+\lambda I\)</span>（<span displaypfx="inline-" class="mathjax-container">\(\lambda&gt;0\)</span>）用于改善条件数、提高可逆性与数值稳定性：例如岭回归（Ridge Regression）把 <span displaypfx="inline-" class="mathjax-container">\(X^\top X\)</span> 替换为 <span displaypfx="inline-" class="mathjax-container">\(X^\top X+\lambda I\)</span>；在高斯模型与协方差估计里常见 <span displaypfx="inline-" class="mathjax-container">\(\Sigma+\epsilon I\)</span> 来保证 Cholesky 分解可用。</p>
<div class="blog_h2"><span class="graybg">对称矩阵（Symmetric Matrix）</span></div>
<p>对称矩阵（Symmetric Matrix）是满足 <span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span> 的实方阵，即 <span displaypfx="inline-" class="mathjax-container">\(A_{ij}=A_{ji}\)</span>。直观上，它的上三角与下三角互为镜像。</p>
<p>例： <span displaypfx="inline-" class="mathjax-container">\(\begin{bmatrix}2 &amp; 1\\ 1 &amp; 3\end{bmatrix}\)</span> 是对称矩阵；而 <span displaypfx="inline-" class="mathjax-container">\(\begin{bmatrix}2 &amp; 1\\ 0 &amp; 3\end{bmatrix}\)</span> 不是，因为非对角元素不成对相等。</p>
<p>对称矩阵拥有更“干净”的谱结构：所有特征值都是实数，并且可正交对角化（Spectral Theorem）：<span displaypfx="inline-" class="mathjax-container">\(A=Q\Lambda Q^\top\)</span>（<span displaypfx="inline-" class="mathjax-container">\(Q^\top Q=I\)</span>）。这使得很多推导都可以在旋转后的坐标系里逐维分析二次型、曲率与能量。</p>
<p>在 AI 里，对称矩阵高频出现于：</p>
<ul>
<li>协方差矩阵（Covariance Matrix）<span displaypfx="inline-" class="mathjax-container">\(\Sigma\)</span>：例如高斯模型与特征白化（Whitening）里，要求 <span displaypfx="inline-" class="mathjax-container">\(\Sigma\succeq 0\)</span>，并常用 <span displaypfx="inline-" class="mathjax-container">\(\Sigma+\epsilon I\)</span> 保证数值稳定。</li>
<li>Gram 矩阵（Gram Matrix）<span displaypfx="inline-" class="mathjax-container">\(X^\top X\)</span> 与核矩阵（Kernel Matrix）<span displaypfx="inline-" class="mathjax-container">\(K\)</span>：它们天然对称/半正定，是最小二乘、岭回归与核方法的核心对象。</li>
<li>海森矩阵（Hessian）：当目标函数二阶连续可导时，Hessian 对称；其特征值决定局部曲率，从而决定“极小/极大/鞍点”的类型与优化难度。</li>
</ul>
<div class="blog_h2"><span class="graybg">厄米矩阵（Hermitian Matrix）</span></div>
<p>厄米矩阵（Hermitian Matrix）是复数域上与对称矩阵对应的概念。若复矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{C}^{n\times n}\)</span> 满足</p>
<span displaypfx="" class="mathjax-container">\[A=A^\ast\]</span>
<p>则称 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 为厄米矩阵，其中 <span displaypfx="inline-" class="mathjax-container">\(A^\ast\)</span> 表示共轭转置（Conjugate Transpose）：先转置，再对每个元素取复共轭。因此，厄米矩阵满足按元素关系 <span displaypfx="inline-" class="mathjax-container">\(A_{ij}=\overline{A_{ji}}\)</span>。可以把它直接理解为<span style="background-color: #c0c0c0;">复数版本的对称矩阵</span>。</p>
<p>例： <span displaypfx="inline-" class="mathjax-container">\(\begin{bmatrix}1 &amp; 2+i\\ 2-i &amp; 3\end{bmatrix}\)</span> 是厄米矩阵，因为非对角元素互为复共轭，而对角线元素必须是实数。厄米矩阵保留了实对称矩阵最重要的好性质：特征值全为实数，并且可以被酉矩阵（Unitary Matrix）对角化。因此在复数信号处理、量子力学、复数优化与某些频域分析里，它扮演的角色与实对称矩阵在实数域中的角色完全对应。</p>
<div class="blog_h2"><span class="graybg">可逆矩阵与奇异矩阵（Invertible vs Singular）</span></div>
<p>可逆矩阵（Invertible Matrix）是存在逆矩阵的方阵：对 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{n\times n}\)</span>，若存在 <span displaypfx="inline-" class="mathjax-container">\(A^{-1}\)</span> 使得 <span displaypfx="inline-" class="mathjax-container">\(AA^{-1}=A^{-1}A=I\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 可逆；否则称 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 为奇异矩阵（Singular Matrix）。</p>
<p>等价判据（常用）：<span displaypfx="inline-" class="mathjax-container">\(A\)</span> 可逆 <span displaypfx="inline-" class="mathjax-container">\(\Leftrightarrow \det(A)\ne 0 \Leftrightarrow \mathrm{rank}(A)=n\)</span>（列向量线性无关）。</p>
<p>例（可逆）：令 <span displaypfx="inline-" class="mathjax-container">\(A=\begin{bmatrix}2 &amp; 1\\ 1 &amp; 1\end{bmatrix}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\det(A)=1\)</span>，并且 <span displaypfx="inline-" class="mathjax-container">\(A^{-1}=\begin{bmatrix}1 &amp; -1\\ -1 &amp; 2\end{bmatrix}\)</span>。</p>
<p>例（奇异）：令 <span displaypfx="inline-" class="mathjax-container">\(B=\begin{bmatrix}1 &amp; 2\\ 2 &amp; 4\end{bmatrix}\)</span>，第二行是第一行的 2 倍，因此秩为 1、行列式为 0，无法求逆。对应线性方程组 <span displaypfx="inline-" class="mathjax-container">\(B\mathbf{x}=\mathbf{b}\)</span> 可能无解（例如 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}=(3,5)^\top\)</span>），也可能有无穷多解（例如 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}=(3,6)^\top\)</span>）。</p>
<p>在 AI 里，奇异性最常出现在最小二乘与协方差：当特征共线、维度远大于样本数（<span displaypfx="inline-" class="mathjax-container">\(d\gg N\)</span>）时，<span displaypfx="inline-" class="mathjax-container">\(X^\top X\)</span> 往往奇异或病态（Ill-conditioned）。常见处理是正则化（<span displaypfx="inline-" class="mathjax-container">\(X^\top X+\lambda I\)</span>）或用 SVD/QR 求解并使用伪逆（Pseudoinverse）<span displaypfx="inline-" class="mathjax-container">\(A^+\)</span>。</p>
<p>实现上通常避免显式求 <span displaypfx="inline-" class="mathjax-container">\(A^{-1}\)</span>：更稳定的做法是直接求解 <span displaypfx="inline-" class="mathjax-container">\(A\mathbf{x}=\mathbf{b}\)</span>（Solve），或用分解（Cholesky / QR / SVD）替代。</p>
<div class="blog_h2"><span class="graybg">正交矩阵（Orthogonal Matrix）</span></div>
<p>正交矩阵（Orthogonal Matrix）是满足 <span displaypfx="inline-" class="mathjax-container">\(Q^\top Q=QQ^\top=I\)</span> 的实方阵。它的列向量（或行向量）构成一组正交标准基（Orthonormal Basis），因此保持长度与点积：对任意向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 有 <span displaypfx="inline-" class="mathjax-container">\(\|Q\mathbf{x}\|_2=\|\mathbf{x}\|_2\)</span>，对任意向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a},\mathbf{b}\)</span> 有 <span displaypfx="inline-" class="mathjax-container">\((Q\mathbf{a})^\top(Q\mathbf{b})=\mathbf{a}^\top\mathbf{b}\)</span>。</p>
<p>正交矩阵的列向量不需要“沿着坐标轴方向”。要求只有一个：列向量两两正交且都是单位向量，也就是构成一组正交标准基。标准基（<span displaypfx="inline-" class="mathjax-container">\(\mathbf{e}_1,\mathbf{e}_2,\ldots\)</span>）只是其中最常用的一组。</p>
<p>例（旋转 45°）：令</p>
<span displaypfx="" class="mathjax-container">\[Q=\frac{1}{\sqrt{2}}\begin{bmatrix}1 &amp; -1\\ 1 &amp; 1\end{bmatrix}\]</span>
<p>它的两列分别是 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{\sqrt{2}}(1,1)^\top\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{\sqrt{2}}(-1,1)^\top\)</span>，都不与坐标轴对齐，但它们正交且单位长度，因此</p>
<span displaypfx="" class="mathjax-container">\[Q^\top Q=\frac{1}{2}\begin{bmatrix}1 &amp; 1\\ -1 &amp; 1\end{bmatrix}\begin{bmatrix}1 &amp; -1\\ 1 &amp; 1\end{bmatrix}=\begin{bmatrix}1 &amp; 0\\ 0 &amp; 1\end{bmatrix}=I\]</span>
<p>例（二维旋转 90°）：令</p>
<span displaypfx="" class="mathjax-container">\[R=\begin{bmatrix}0 &amp; -1\\ 1 &amp; 0\end{bmatrix},\quad R^\top=\begin{bmatrix}0 &amp; 1\\ -1 &amp; 0\end{bmatrix}\]</span>
<p>则</p>
<span displaypfx="" class="mathjax-container">\[R^\top R=RR^\top=\begin{bmatrix}1 &amp; 0\\ 0 &amp; 1\end{bmatrix}=I\]</span>
<p>在 AI 里，正交矩阵常用于正交初始化（Orthogonal Initialization）、QR 分解与正交约束参数化；核心目的是把谱范数控制在 1 附近，改善深层网络与 RNN 的数值稳定性。</p>
<div class="blog_h2"><span class="graybg">酉矩阵（Unitary Matrix）</span></div>
<p>酉矩阵（Unitary Matrix）是复数域上的“长度保持”线性变换。对复矩阵 <span displaypfx="inline-" class="mathjax-container">\(U\in\mathbb{C}^{n\times n}\)</span>，若满足</p>
<span displaypfx="" class="mathjax-container">\[U^\ast U=UU^\ast=I\]</span>
<p>则称 <span displaypfx="inline-" class="mathjax-container">\(U\)</span> 为酉矩阵，其中 <span displaypfx="inline-" class="mathjax-container">\(U^\ast\)</span> 是共轭转置（Conjugate Transpose）。酉矩阵的列向量构成一组正交归一基（Orthonormal Basis），因此对任意向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 都有 <span displaypfx="inline-" class="mathjax-container">\(\|U\mathbf{x}\|_2=\|\mathbf{x}\|_2\)</span>。</p>
<p>实数域特例：当矩阵元素为实数时，酉矩阵退化为正交矩阵（Orthogonal Matrix），即满足 <span displaypfx="inline-" class="mathjax-container">\(Q^\top Q=QQ^\top=I\)</span>。</p>
<p>例（复数域）：令</p>
<span displaypfx="" class="mathjax-container">\[U=\begin{bmatrix}1 &amp; 0\\ 0 &amp; i\end{bmatrix},\quad U^\ast=\begin{bmatrix}1 &amp; 0\\ 0 &amp; -i\end{bmatrix}\]</span>
<p>则可直接计算：</p>
<span displaypfx="" class="mathjax-container">\[U^\ast U=\begin{bmatrix}1 &amp; 0\\ 0 &amp; -i\end{bmatrix}\begin{bmatrix}1 &amp; 0\\ 0 &amp; i\end{bmatrix}=\begin{bmatrix}1 &amp; 0\\ 0 &amp; 1\end{bmatrix}=I,\quad UU^\ast=\begin{bmatrix}1 &amp; 0\\ 0 &amp; i\end{bmatrix}\begin{bmatrix}1 &amp; 0\\ 0 &amp; -i\end{bmatrix}=I\]</span>
<p>在 AI 里，正交/酉矩阵常用于控制数值稳定性：例如正交初始化（Orthogonal Initialization）与正交/酉参数化可把谱范数压在 1 附近，缓解深层网络与 RNN 中的梯度爆炸/消失；一些长序列建模会使用 unitary/orthogonal RNN 来更好地传播长程信息。</p>
<div class="blog_h2"><span class="graybg">正定矩阵</span></div>
<p>正定矩阵（Positive Definite Matrix）把“二次型总是正”形式化。对对称矩阵（Symmetric Matrix）<span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span>，若对任意非零向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\ne\mathbf{0}\)</span> 都有</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x} &gt; 0\]</span>
<p>则称 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 正定，记作 <span displaypfx="inline-" class="mathjax-container">\(A\succ 0\)</span>。若是 <span displaypfx="inline-" class="mathjax-container">\(\ge 0\)</span> 则为半正定（Positive Semi-Definite, PSD），记作 <span displaypfx="inline-" class="mathjax-container">\(A\succeq 0\)</span>。</p>
<p>几何上，把 <span displaypfx="inline-" class="mathjax-container">\(q(x,y)=\mathbf{x}^\top A\mathbf{x}\)</span> 画成 <span displaypfx="inline-" class="mathjax-container">\(z=q(x,y)\)</span> 的三维曲面时，正定对应“向上开口的碗”（椭圆抛物面（Elliptic Paraboloid））：原点是唯一最低点，任意非零方向都往上抬升。若没有交叉项（<span displaypfx="inline-" class="mathjax-container">\(bxy\)</span> 项为 0），等值线与坐标轴对齐；若有交叉项（<span displaypfx="inline-" class="mathjax-container">\(b\ne 0\)</span>），碗的主轴会旋转，但“向上碗”的本质不变。半正定则可能出现平坦方向（Flat Direction），典型形状是槽（Trough）而非严格碗底。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/matrix-pd-psd.png"><img class="alignnone size-full wp-image-40695" src="https://blog.gmem.cc/wp-content/uploads/2026/03/matrix-pd-psd.png" alt="matrix-pd-psd" width="100%" /></a></p>
<p>从特征值（Eigenvalues）角度看，对称矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的二次型类型可直接由特征值符号判别（下述以二维 <span displaypfx="inline-" class="mathjax-container">\(\lambda_1,\lambda_2\)</span> 为例）：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda_1&gt;0,\lambda_2&gt;0\)</span>：正定（Positive Definite, PD），向上开口碗。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda_1&lt;0,\lambda_2&lt;0\)</span>：负定（Negative Definite, ND），向下开口碗。</li>
<li>一个为 0、另一个大于 0：半正定（Positive Semi-Definite, PSD），出现平坦方向，形状更像槽。</li>
<li>一个为 0、另一个小于 0：半负定（Negative Semi-Definite, NSD），对应“倒槽”。</li>
<li>一正一负：不定（Indefinite），对应鞍面（Saddle Surface）。</li>
</ul>
<p>因此图里“有交叉项”并不改变正负定类型；它主要改变主轴方向（旋转等值线），而“是不是碗/槽/鞍”由特征值符号决定。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/matrix-nd-nsd.png"><img class="alignnone size-full wp-image-40703" src="https://blog.gmem.cc/wp-content/uploads/2026/03/matrix-nd-nsd.png" alt="matrix-nd-nsd" width="100%" /></a></p>
<p>例：令</p>
<span displaypfx="" class="mathjax-container">\[A=\begin{bmatrix}2 &amp; 1\\ 1 &amp; 2\end{bmatrix}\]</span>
<p>对任意 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x_1,x_2)^\top\)</span>，有</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}^\top A\mathbf{x}=2x_1^2+2x_1x_2+2x_2^2=(x_1+x_2)^2+x_1^2+x_2^2&gt;0\quad (\mathbf{x}\ne\mathbf{0})\]</span>
<p>因此 <span displaypfx="inline-" class="mathjax-container">\(A\succ 0\)</span>。同时它的特征值为 3 与 1（均为正），并且存在 Cholesky 分解：</p>
<span displaypfx="" class="mathjax-container">\[A=LL^\top,\quad L=\begin{bmatrix}\sqrt{2} &amp; 0\\ \frac{1}{\sqrt{2}} &amp; \sqrt{\frac{3}{2}}\end{bmatrix}\]</span>
<p>等价刻画（常用）：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(A\succ 0\)</span> 当且仅当所有特征值 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i&gt;0\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(A\succ 0\)</span> 当且仅当存在 Cholesky 分解 <span displaypfx="inline-" class="mathjax-container">\(A=LL^\top\)</span>（<span displaypfx="inline-" class="mathjax-container">\(L\)</span> 下三角且对角为正）。</li>
</ul>
<p>它在优化里非常关键：若函数的海森矩阵（Hessian）在某点正定，则该点是严格局部极小；若 Hessian 半正定，则函数局部凸（Locally Convex）。</p>
<div class="blog_h2"><span class="graybg">特征值与特征向量</span></div>
<p>特征值（Eigenvalue）与特征向量（Eigenvector）描述线性变换的“固有方向”：若存在非零向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\ne\mathbf{0}\)</span> 与标量 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 使得</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{v}=\lambda \mathbf{v}\]</span>
<p>则 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\)</span> 是特征向量，<span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 是对应特征值。几何上，沿特征向量方向的向量经过变换后方向不变，只被缩放（若 <span displaypfx="inline-" class="mathjax-container">\(\lambda&lt;0\)</span> 还会翻转）。</p>
<p>当矩阵可对角化（Diagonalizable）时，可写为 <span displaypfx="inline-" class="mathjax-container">\(A=V\Lambda V^{-1}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(\Lambda\)</span> 是特征值对角矩阵，列向量 <span displaypfx="inline-" class="mathjax-container">\(V=[\mathbf{v}_1,\dots,\mathbf{v}_n]\)</span> 是特征向量。</p>
<p>对称矩阵（Symmetric Matrix）是最重要的特例：它的特征向量可取为一组正交归一基（Orthonormal Basis），因此</p>
<span displaypfx="" class="mathjax-container">\[A=Q\Lambda Q^\top,\quad Q^\top Q=I\]</span>
<p>这正是 PCA（Principal Component Analysis）等方法背后的谱分解（Spectral Decomposition）基础。</p>
<div class="blog_h2"><span class="graybg">谱、谱定理与谱范数</span></div>
<div class="blog_h3"><span class="graybg">谱（Spectrum）</span></div>
<p>矩阵的谱（Spectrum）指的是矩阵全部特征值构成的集合。对方阵 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{C}^{n\times n}\)</span>，常记为 <span displaypfx="inline-" class="mathjax-container">\(\sigma(A)\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\sigma(A)=\{\lambda\in\mathbb{C}\mid \det(A-\lambda I)=0\}\]</span>
<p>这里之所以会把行列式（Determinant）扯进来，是因为特征值定义本身就会自然导向这个条件。若 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 是特征值，按定义必须存在某个非零向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\ne \mathbf{0}\)</span> 使得</p>
<span displaypfx="" class="mathjax-container">\[A\mathbf{v}=\lambda \mathbf{v}\]</span>
<p>把右边移到左边，就是</p>
<span displaypfx="" class="mathjax-container">\[(A-\lambda I)\mathbf{v}=\mathbf{0}\]</span>
<p>这说明：矩阵 <span displaypfx="inline-" class="mathjax-container">\(A-\lambda I\)</span> 对应的齐次线性方程组必须有<span style="background-color: #c0c0c0;">非零解</span>。而线性代数里，一个方阵齐次方程组有非零解，当且仅当该矩阵不可逆；对方阵而言，不可逆又等价于行列式为 0。因此就得到</p>
<span displaypfx="" class="mathjax-container">\[\det(A-\lambda I)=0\]</span>
<p>也就是说，行列式在这里不是额外硬塞进来的工具，而是“特征向量存在非零解”这一要求的等价写法。解这个方程得到的多项式根，就是矩阵的全部特征值。</p>
<p>这个定义的关键不是“列出所有特征值”本身，而是把矩阵最核心的线性作用浓缩成一组标量。许多稳定性、可逆性、收敛性与几何性质，最终都可以回到谱上来判断。例：若 <span displaypfx="inline-" class="mathjax-container">\(0\in \sigma(A)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 不可逆；若所有特征值都为正，且 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 对称，则它是正定矩阵。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/spectrum.png"><img class="alignnone size-full wp-image-42063" src="https://blog.gmem.cc/wp-content/uploads/2026/03/spectrum.png" alt="spectrum" width="1024" height="1024" /></a></p>
<p>与谱紧密相关、但不能混为一谈的另一个概念是谱半径（Spectral Radius）：</p>
<span displaypfx="" class="mathjax-container">\[\rho(A)=\max_{\lambda\in\sigma(A)}|\lambda|\]</span>
<p>它只取特征值模长中的最大者。谱是整个特征值集合，谱半径只是其中的最大模长摘要。在线性迭代、RNN 稳定性与幂法（Power Method）里，谱半径尤其常见。</p>
<div class="blog_h3"><span class="graybg">谱定理（Spectral Theorem）</span></div>
<p>谱定理（Spectral Theorem）是线性代数里最重要的结构定理之一。对实对称矩阵 <span displaypfx="inline-" class="mathjax-container">\(A=A^\top\)</span>，存在正交矩阵 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 与实对角矩阵 <span displaypfx="inline-" class="mathjax-container">\(\Lambda\)</span>，使得</p>
<span displaypfx="" class="mathjax-container">\[A=Q\Lambda Q^\top\]</span>
<p>等价地说， <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 的列向量是一组两两正交的单位特征向量， <span displaypfx="inline-" class="mathjax-container">\(\Lambda\)</span> 的对角线上放着对应特征值。这条定理的含义非常深：对称矩阵不仅“有特征值”，而且它的全部作用都可以被解释为<span style="background-color: #c0c0c0;">先换到特征向量基，再按各坐标轴独立缩放</span>。这也是为什么二次型、协方差矩阵、Hessian、图 Laplacian 等对象一旦对称，分析就会变得异常清爽。</p>
<p>在复数域上，对应版本是厄米矩阵（Hermitian Matrix）<span displaypfx="inline-" class="mathjax-container">\(A=A^\ast\)</span> 可被酉矩阵（Unitary Matrix）对角化：</p>
<span displaypfx="" class="mathjax-container">\[A=U\Lambda U^\ast\]</span>
<p>这类“可由一组正交 / 酉基完全分解”的矩阵，在数值上更稳定、几何上更透明，也正因此成为机器学习里最重要的一类矩阵对象。</p>
<div class="blog_h3"><span class="graybg">谱范数（Spectral Norm）</span></div>
<p>谱范数（Spectral Norm）记作 <span displaypfx="inline-" class="mathjax-container">\(\|A\|_2\)</span>，定义为矩阵对单位向量能造成的最大放大倍数：</p>
<span displaypfx="" class="mathjax-container">\[\|A\|_2=\max_{\|\mathbf{x}\|_2=1}\|A\mathbf{x}\|_2\]</span>
<p>这一定义直接揭示了它的几何意义：找出所有长度为 1 的输入向量，看矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 最多能把哪一个方向拉得最长。对任意矩阵，都有</p>
<span displaypfx="" class="mathjax-container">\[\|A\|_2=\sigma_{\max}(A)=\sqrt{\lambda_{\max}(A^\top A)}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\sigma_{\max}(A)\)</span> 是最大奇异值。若 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 还是实对称矩阵，则奇异值等于特征值绝对值，于是谱范数进一步化为</p>
<span displaypfx="" class="mathjax-container">\[\|A\|_2=\max_i |\lambda_i(A)|\]</span>
<p>因此，对一般矩阵要通过奇异值来理解谱范数；对对称矩阵，则可以直接通过特征值来理解。要特别区分：谱范数与谱半径在对称 / 正规矩阵上常常一致，但对一般非正规矩阵并不总相同。</p>
<p>在 AI 中，谱范数最常用于表达“局部放大能力”与 Lipschitz 常数控制。若某层线性映射的谱范数过大，它会把某些方向的扰动显著放大，进而加重训练不稳定、梯度爆炸或对抗脆弱性；这也是谱归一化（Spectral Normalization）、正交初始化与某些鲁棒优化方法反复关注它的原因。</p>
<div class="blog_h2"><span class="graybg">奇异值分解（SVD）</span></div>
<p>奇异值分解（SVD, Singular Value Decomposition）对任意矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{m\times n}\)</span> 都成立：</p>
<span displaypfx="" class="mathjax-container">\[A=U\Sigma V^\top\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(U\in\mathbb{R}^{m\times m}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(V\in\mathbb{R}^{n\times n}\)</span> 是正交矩阵（Orthogonal Matrix），<span displaypfx="inline-" class="mathjax-container">\(\Sigma\in\mathbb{R}^{m\times n}\)</span> 是对角（更准确说是“对角形”）矩阵，其对角线元素 <span displaypfx="inline-" class="mathjax-container">\(\sigma_1\ge\sigma_2\ge\cdots\ge 0\)</span> 为奇异值（Singular Values）。</p>
<p>几何解释非常直接：<span style="background-color: #c0c0c0;">先用 <span displaypfx="inline-" class="mathjax-container">\(V^\top\)</span> 旋转/换基，再用 <span displaypfx="inline-" class="mathjax-container">\(\Sigma\)</span> 沿坐标轴缩放，最后用 <span displaypfx="inline-" class="mathjax-container">\(U\)</span> 再旋转</span>。因此 SVD 是“旋转-缩放-旋转”的标准分解。</p>
<p>SVD 与特征值的关系要分左右两侧一起看：</p>
<ul>
<li>右奇异向量（Right Singular Vectors）是 <span displaypfx="inline-" class="mathjax-container">\(A^\top A\)</span> 的特征向量，即 <span displaypfx="inline-" class="mathjax-container">\(A^\top A\mathbf{v}_i=\sigma_i^2\mathbf{v}_i\)</span>。</li>
<li>左奇异向量（Left Singular Vectors）是 <span displaypfx="inline-" class="mathjax-container">\(AA^\top\)</span> 的特征向量，即 <span displaypfx="inline-" class="mathjax-container">\(AA^\top\mathbf{u}_i=\sigma_i^2\mathbf{u}_i\)</span>。</li>
</ul>
<p>其中<span displaypfx="inline-" class="mathjax-container">\(\sigma_i^2\)</span> 是 <span displaypfx="inline-" class="mathjax-container">\(A^\top A\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(AA^\top\)</span> 的特征值；<span displaypfx="inline-" class="mathjax-container">\(\sigma_i\)</span> 为奇异值（即这些非零特征值的平方根）。</p>
<p>矩阵形式分别是 <span displaypfx="inline-" class="mathjax-container">\(A^\top A=V\Sigma^\top\Sigma V^\top\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(AA^\top=U\Sigma\Sigma^\top U^\top\)</span>，因此非零奇异值满足 <span displaypfx="inline-" class="mathjax-container">\(\sigma_i=\sqrt{\lambda_i(A^\top A)}=\sqrt{\lambda_i(AA^\top)}\)</span>。这也解释了为什么奇异值总是非负，而一般矩阵的特征值可以为负甚至为复数。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(\sigma_i&gt;0\)</span>，左右奇异向量还可互相对应： <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}_i=\frac{A\mathbf{v}_i}{\sigma_i}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}_i=\frac{A^\top\mathbf{u}_i}{\sigma_i}\)</span>。</p>
<p>特殊地，若 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 是对称矩阵，则它可正交对角化，此时奇异值等于特征值的绝对值：<span displaypfx="inline-" class="mathjax-container">\(\sigma_i=|\lambda_i(A)|\)</span>；若 <span displaypfx="inline-" class="mathjax-container">\(A\succeq 0\)</span>（半正定），则 <span displaypfx="inline-" class="mathjax-container">\(\sigma_i=\lambda_i(A)\)</span>。</p>
<p>为什么 SVD 可用于压缩与降维（Compression &amp; Dimensionality Reduction）？因为很多数据/权重矩阵在有效意义下是低秩（Low-rank）的：只有前几个 <span displaypfx="inline-" class="mathjax-container">\(\sigma_i\)</span> 很大，后面的奇异值接近 0。保留前 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 项得到秩 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 近似：</p>
<span displaypfx="" class="mathjax-container">\[A_k=U_{(:,1:k)}\Sigma_{(1:k,1:k)}V_{(:,1:k)}^\top\]</span>
<p>它在 Frobenius 范数（Frobenius Norm）与谱范数（Spectral Norm）意义下都是最优的秩 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 近似（Eckart–Young 定理）：用最少的信息保留最大的能量（由奇异值平方决定）。</p>
<p>在 PCA 中，对中心化数据矩阵 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 做 SVD： <span displaypfx="inline-" class="mathjax-container">\(X=U\Sigma V^\top\)</span>，右奇异向量 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 给出主方向（Principal Directions），奇异值刻画各方向的方差贡献（Variance Explained）。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/svd.png"><img class="alignnone size-full wp-image-42053" src="https://blog.gmem.cc/wp-content/uploads/2026/03/svd.png" alt="svd" width="1024" height="1024" /></a></p>
<div class="blog_h2"><span class="graybg">范数（L0 / L1 / L2 / L∞）</span></div>
<p>范数（Norm）刻画“向量大小”。在 AI 中，它最常出现在三类地方：距离度量（Distance Metric）、正则化（Regularization）与鲁棒性约束（Robustness Constraint）。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\in\mathbb{R}^d\)</span>，当 <span displaypfx="inline-" class="mathjax-container">\(p\ge 1\)</span> 时 <span displaypfx="inline-" class="mathjax-container">\(L_p\)</span> 范数定义为</p>
<span displaypfx="" class="mathjax-container">\[\|\mathbf{x}\|_p=\left(\sum_{i=1}^{d}|x_i|^p\right)^{1/p},\quad p\ge 1\]</span>
<p>并且 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_\infty=\max_i |x_i|=\lim_{p\to\infty}\|\mathbf{x}\|_p\)</span>。</p>
<div class="blog_h3"><span class="graybg">L0 “范数”（L0 “norm”）</span></div>
<p><span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_0\)</span> 定义为非零分量的个数：</p>
<span displaypfx="" class="mathjax-container">\[\|\mathbf{x}\|_0=\#\{i\mid x_i\ne 0\}\]</span>
<p>严格来说它不是范数：例如对任意非零标量 <span displaypfx="inline-" class="mathjax-container">\(\alpha\ne 0\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(\|\alpha\mathbf{x}\|_0=\|\mathbf{x}\|_0\)</span>，不满足齐次性（Homogeneity）<span displaypfx="inline-" class="mathjax-container">\(\|\alpha\mathbf{x}\|=|\alpha|\|\mathbf{x}\|\)</span>。</p>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(3,0,-1,0)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_0=2\)</span>。</p>
<p>在 AI 里，<span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|_0\)</span> 用来表达稀疏性（Sparsity）：特征选择（Feature Selection）、压缩感知（Compressed Sensing）与网络剪枝（Pruning）常以“非零个数最少”为目标。但直接优化 <span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|_0\)</span> 一般是组合优化，常用 <span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|_1\)</span> 或其他可优化的替代目标近似。</p>
<div class="blog_h3"><span class="graybg">L1 范数（L1 Norm）</span></div>
<span displaypfx="" class="mathjax-container">\[\|\mathbf{x}\|_1=\sum_{i=1}^{d}|x_i|\]</span>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(3,4)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_1=7\)</span>。几何上，二维 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 等值线是菱形；与 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 的圆相比，它更容易在坐标轴上产生“尖角”，对应优化时更容易把部分坐标推到 0。</p>
<p>在 AI 里，<span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 正则化是稀疏学习（Sparse Learning）的标准工具：在凸模型中会得到稀疏解（例如 Lasso）；在深度模型中也常用于诱导稀疏权重/稀疏特征、做轻量化。</p>
<div class="blog_h3"><span class="graybg">L2 范数（L2 Norm）</span></div>
<span displaypfx="" class="mathjax-container">\[\|\mathbf{x}\|_2=\sqrt{\sum_{i=1}^{d}x_i^2}\]</span>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(3,4)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_2=5\)</span>，对应欧氏距离（Euclidean Distance）。在连续优化中，<span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_2^2\)</span> 具有良好的光滑性（Smoothness），使得许多推导与数值计算更稳定。</p>
<p>在 AI 里，<span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 正则化（权重衰减（Weight Decay））会惩罚大权重、改善泛化并缓解病态问题；在二次目标里它也对应“加 <span displaypfx="inline-" class="mathjax-container">\(\lambda I\)</span>”的稳定化（例如岭回归）。</p>
<div class="blog_h3"><span class="graybg">L∞ 范数（L-infinity Norm）</span></div>
<span displaypfx="" class="mathjax-container">\[\|\mathbf{x}\|_\infty=\max_{i}|x_i|\]</span>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(3,0,-1,0)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{x}\|_\infty=3\)</span>。它度量的是“最大坐标幅度”。</p>
<p>在 AI 里，<span displaypfx="inline-" class="mathjax-container">\(L_\infty\)</span> 最常与鲁棒性相关：对抗样本（Adversarial Examples）中的 <span displaypfx="inline-" class="mathjax-container">\(L_\infty\)</span> 约束表示“每个像素的改动幅度不超过 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span>”；一些鲁棒优化与最坏情况界也会用 <span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|_\infty\)</span> 表达最大误差。</p>
<div class="blog_h3"><span class="graybg">距离与正则化</span></div>
<p>由范数诱导的距离（Norm-induced Distance）写作 <span displaypfx="inline-" class="mathjax-container">\(d(\mathbf{x},\mathbf{y})=\|\mathbf{x}-\mathbf{y}\|\)</span>。常见对应关系：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(L_2\)</span>：欧氏距离（Euclidean Distance），<span displaypfx="inline-" class="mathjax-container">\(d_2(\mathbf{x},\mathbf{y})=\|\mathbf{x}-\mathbf{y}\|_2=\sqrt{\sum_{i=1}^{d}(x_i-y_i)^2}\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(L_1\)</span>：曼哈顿距离（Manhattan Distance），<span displaypfx="inline-" class="mathjax-container">\(d_1(\mathbf{x},\mathbf{y})=\|\mathbf{x}-\mathbf{y}\|_1=\sum_{i=1}^{d}|x_i-y_i|\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(L_\infty\)</span>：切比雪夫距离（Chebyshev Distance），<span displaypfx="inline-" class="mathjax-container">\(d_\infty(\mathbf{x},\mathbf{y})=\|\mathbf{x}-\mathbf{y}\|_\infty=\max_{i}|x_i-y_i|\)</span>。它度量的是“最坏维度”的偏差：例如 <span displaypfx="inline-" class="mathjax-container">\(d_\infty(\mathbf{x},\mathbf{y})\le \epsilon \Leftrightarrow \forall i,\ |x_i-y_i|\le \epsilon\)</span>，即每一维的误差都被同一个上界约束。直觉上，如果一次操作允许同时修改所有坐标、且每步每个坐标最多改 1，那么从 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 变到 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 的最少步数就是 <span displaypfx="inline-" class="mathjax-container">\(\max_i|x_i-y_i|\)</span>（更一般地，每步上限为 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 时步数为 <span displaypfx="inline-" class="mathjax-container">\(d_\infty(\mathbf{x},\mathbf{y})/\epsilon\)</span> 的向上取整）。</li>
</ul>
<p>关于 <span displaypfx="inline-" class="mathjax-container">\(L_0\)</span>：常见写法是 <span displaypfx="inline-" class="mathjax-container">\(d_0(\mathbf{x},\mathbf{y})=\|\mathbf{x}-\mathbf{y}\|_0=\#\{i\mid x_i\ne y_i\}\)</span>，它统计两向量在多少个坐标上不相等。本质上这是逐坐标“相等/不相等”的计数度量；当取值来自离散集合时，它对应哈明距离（Hamming Distance）。但 <span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|_0\)</span> 严格来说不是范数，因此 <span displaypfx="inline-" class="mathjax-container">\(d_0\)</span> 不属于“由范数诱导”的距离家族。</p>
<p>在学习目标中，正则化通常写成：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\theta}\ \frac{1}{m}\sum_{i=1}^{m}\ell\!\left(f_{\theta}(x^{(i)}),y^{(i)}\right)+\lambda\Omega(\theta)\]</span>
<p>式中各成分含义如下：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>：模型参数（Parameters），优化的对象。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(m\)</span>：训练样本数（Number of Samples）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\((x^{(i)},y^{(i)})\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本与标签。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f_{\theta}(x^{(i)})\)</span>：模型对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本的预测。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\ell(\cdot,\cdot)\)</span>：单样本损失函数（Per-sample Loss），衡量预测与标签偏差。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{m}\sum_{i=1}^{m}\ell(\cdot)\)</span>：经验风险（Empirical Risk），即平均训练误差。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\Omega(\theta)\)</span>：正则项（Regularizer），约束参数复杂度。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span>：正则化系数（Regularization Strength），平衡“拟合训练数据”与“控制模型复杂度”。</li>
</ul>
<p>常见的 <span displaypfx="inline-" class="mathjax-container">\(\Omega(\theta)\)</span> 包括 <span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_1\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_2^2\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_0\)</span>（稀疏性目标）与 <span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_\infty\)</span>（最大幅度约束）。同一个思想也可写成“约束形式”（Constraint Form）：<span displaypfx="inline-" class="mathjax-container">\(\min_\theta \frac{1}{m}\sum_i \ell(\cdot)\ \text{s.t.}\ \Omega(\theta)\le c\)</span>。惩罚系数 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 与约束半径 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 在凸优化（Convex Optimization）里可通过对偶（Duality）联系起来。</p>
<div class="blog_h1"><span class="graybg">微积分</span></div>
<div class="blog_h2"><span class="graybg">极限与连续</span></div>
<div class="blog_h3"><span class="graybg">极限的定义</span></div>
<p>极限（Limit）回答的问题是：当输入“逼近”某个值时，函数输出“逼近”什么值。它关心的是趋势而不是是否刚好取到该点。</p>
<span displaypfx="" class="mathjax-container">\[\lim_{x\to a}f(x)=L\]</span>
<p>严格定义（<span displaypfx="inline-" class="mathjax-container">\(\varepsilon-\delta\)</span> 定义）可写为：</p>
<span displaypfx="" class="mathjax-container">\[\forall \varepsilon \gt 0,\ \exists \delta \gt 0,\ \text{s.t.}\ 0 \lt |x-a| \lt \delta \Rightarrow |f(x)-L| \lt \varepsilon\]</span>
<p>工程上可把它理解为：把输入控制得足够近，输出误差就能被压到任意小。</p>
<p>在 AI 里，极限直觉用于理解“收敛（Convergence）”：例如训练步长变小后，参数更新是否趋于稳定；以及损失函数在某点附近是否可被低阶展开近似。</p>
<div class="blog_h3"><span class="graybg">连续性</span></div>
<p>连续（Continuity）可理解为“函数图像没有跳断”。在点 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 处连续的三个条件是：</p>
<ol>
<li><span displaypfx="inline-" class="mathjax-container">\(f(a)\)</span> 有定义；</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\lim_{x\to a}f(x)\)</span> 存在；</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\lim_{x\to a}f(x)=f(a)\)</span>。</li>
</ol>
<p>连续是可导（Differentiable）的前提之一（但连续不必然可导）。例如 <span displaypfx="inline-" class="mathjax-container">\(|x|\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span> 连续但不可导。</p>
<p>在优化里，连续性保证“小步更新不会导致目标突变”，这也是学习率（Learning Rate）可调与训练可控的基础假设之一。</p>
<div class="blog_h3"><span class="graybg">无穷小与无穷大</span></div>
<p>无穷小（Infinitesimal）描述“趋近于 0 的量”，无穷大（Infinity）描述“无界增长”。在推导里常通过渐近记号（Asymptotic Notation）表达量级关系：</p>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(o\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(O\)</span> 不是变量，而是两种记号：小 <span displaypfx="inline-" class="mathjax-container">\(o\)</span>（little-o）表示“严格更小一个量级”，大 <span displaypfx="inline-" class="mathjax-container">\(O\)</span>（big-O）表示“至多同量级的上界”。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(f(x)=o(g(x))\)</span>：当 <span displaypfx="inline-" class="mathjax-container">\(x\to a\)</span> 时 <span displaypfx="inline-" class="mathjax-container">\(f/g\to 0\)</span>（高阶小量，增长/衰减速度严格慢于 <span displaypfx="inline-" class="mathjax-container">\(g\)</span>）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f(x)=O(g(x))\)</span>：存在常数 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 使 <span displaypfx="inline-" class="mathjax-container">\(|f(x)|\le C|g(x)|\)</span>（同阶或更小的上界）。</li>
</ul>
<p>例如一阶 Taylor 展开里的 <span displaypfx="inline-" class="mathjax-container">\(o(\Delta x)\)</span> 表示“比 <span displaypfx="inline-" class="mathjax-container">\(\Delta x\)</span> 更小得多”的误差项。算法分析中的时间复杂度 <span displaypfx="inline-" class="mathjax-container">\(O(Nd)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(O(N^2)\)</span> 也属于同一套量级语言。</p>
<div class="blog_h2"><span class="graybg">常见求导法则与公式</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">法则</td>
<td style="text-align: center;">公式</td>
<td style="text-align: center;">条件</td>
</tr>
</thead>
<tbody>
<tr>
<td>常数倍法则</td>
<td><span displaypfx="inline-" class="mathjax-container">\((Cu)' = C u'\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(C\)</span> 为常数</td>
</tr>
<tr>
<td>和差法则</td>
<td><span displaypfx="inline-" class="mathjax-container">\((u \pm v)' = u' \pm v'\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(u,v\)</span> 可导</td>
</tr>
<tr>
<td>乘法法则</td>
<td><span displaypfx="inline-" class="mathjax-container">\((uv)' = u'v + uv'\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(u,v\)</span> 可导</td>
</tr>
<tr>
<td>除法法则</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\left(\frac{u}{v}\right)'=\frac{u'v-uv'}{v^2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(u,v\)</span> 可导，且 <span displaypfx="inline-" class="mathjax-container">\(v \ne 0\)</span></td>
</tr>
<tr>
<td>链式法则</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{d}{dx}f(g(x)) = f'(g(x))g'(x)\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(f,g\)</span> 可导</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">常见函数导数</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">函数 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span></td>
<td style="text-align: center;">导数 <span displaypfx="inline-" class="mathjax-container">\(f'(x)\)</span></td>
<td style="text-align: center;">备注/条件</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(c\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(0\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(c\)</span> 为常数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x^n\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(n x^{n-1}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(n\)</span> 为常数；非整数 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 时需注意实数域定义域</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\sqrt{x}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2\sqrt{x}}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(e^x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^x\)</span></td>
<td>自然指数（Natural Exponential）</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(a^x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a^x\ln a\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(a&gt;0,a\ne 1\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{x}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\log_a x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{x\ln a}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x&gt;0,\ a&gt;0,\ a\ne 1\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\cos x\)</span></td>
<td> </td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\cos x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(-\sin x\)</span></td>
<td> </td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\tan x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sec^2 x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\cos x\ne 0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(|x|\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathrm{sign}(x)\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span> 时导数为 <span displaypfx="inline-" class="mathjax-container">\(1\)</span></p>
<p><span displaypfx="inline-" class="mathjax-container">\(x&lt;0\)</span> 时导数为 <span displaypfx="inline-" class="mathjax-container">\(-1\)</span></p>
<p>在 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span> 不可导（优化中常用次梯度 <span displaypfx="inline-" class="mathjax-container">\(g\in[-1,1]\)</span>）</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">导数（Derivative）</span></div>
<p>导数是点 <span displaypfx="inline-" class="mathjax-container">\(x_0\)</span> 处的瞬时变化率（Instantaneous Rate of Change）：</p>
<span displaypfx="" class="mathjax-container">\[f'(x_0)=\lim_{\Delta x\to 0}\frac{f(x_0+\Delta x)-f(x_0)}{\Delta x}\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(x_0\)</span> 不是“很小的数”，而是定义域中的固定位置；趋近于 0 的小量是 <span displaypfx="inline-" class="mathjax-container">\(\Delta x\)</span>（或记作 <span displaypfx="inline-" class="mathjax-container">\(dx\)</span>）。</p>
<p>一阶展开把导数解释为“线性项的系数”：当 <span displaypfx="inline-" class="mathjax-container">\(\Delta x\to 0\)</span> 时，</p>
<span displaypfx="" class="mathjax-container">\[f(x_0+\Delta x)=f(x_0)+f'(x_0)\Delta x+o(\Delta x)\]</span>
<p>可按“等于三项相加”理解：左边 <span displaypfx="inline-" class="mathjax-container">\(f(x_0+\Delta x)\)</span> 是扰动后的真实函数值；右边第一项 <span displaypfx="inline-" class="mathjax-container">\(f(x_0)\)</span> 是基点函数值，第二项 <span displaypfx="inline-" class="mathjax-container">\(f'(x_0)\Delta x\)</span> 是一阶线性近似，第三项 <span displaypfx="inline-" class="mathjax-container">\(o(\Delta x)\)</span> 是比 <span displaypfx="inline-" class="mathjax-container">\(\Delta x\)</span> 更小的高阶余项（High-order Remainder）。</p>
<div class="blog_h2"><span class="graybg">中值定理（Mean Value Theorems）</span></div>
<p>中值定理（Mean Value Theorems）是一组把“区间上的平均变化”与“某一点的瞬时变化率”连接起来的核心定理。它们在形式上不同，但共同结构是：在闭区间连续、在开区间可导，进而保证存在某个中间点 <span displaypfx="inline-" class="mathjax-container">\(c\in(a,b)\)</span> 使得斜率关系成立。</p>
<div class="blog_h3"><span class="graybg">罗尔中值定理</span></div>
<p>设 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\([a,b]\)</span> 上连续、在 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 上可导，且 <span displaypfx="inline-" class="mathjax-container">\(f(a)=f(b)\)</span>。则存在 <span displaypfx="inline-" class="mathjax-container">\(c\in(a,b)\)</span> 使</p>
<span displaypfx="" class="mathjax-container">\[f'(c)=0\]</span>
<p>几何直觉：若曲线两端高度相同，那么中间至少有一点的切线是水平的。</p>
<div class="blog_h3"><span class="graybg">拉格朗日中值定理</span></div>
<p>设 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\([a,b]\)</span> 上连续、在 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 上可导。则存在 <span displaypfx="inline-" class="mathjax-container">\(c\in(a,b)\)</span> 使</p>
<span displaypfx="" class="mathjax-container">\[f'(c)=\frac{f(b)-f(a)}{b-a}\]</span>
<p>几何直觉：区间割线斜率（平均变化率）等于某个点的切线斜率（瞬时变化率）。</p>
<div class="blog_h3"><span class="graybg">柯西中值定理</span></div>
<p>设 <span displaypfx="inline-" class="mathjax-container">\(f,g\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\([a,b]\)</span> 上连续、在 <span displaypfx="inline-" class="mathjax-container">\((a,b)\)</span> 上可导。则存在 <span displaypfx="inline-" class="mathjax-container">\(c\in(a,b)\)</span> 使</p>
<span displaypfx="" class="mathjax-container">\[(f(b)-f(a))g'(c)=(g(b)-g(a))f'(c)\]</span>
<p>当分母不为 0 时，可写成比值形式：</p>
<span displaypfx="" class="mathjax-container">\[\frac{f'(c)}{g'(c)}=\frac{f(b)-f(a)}{g(b)-g(a)}\]</span>
<p>它把“单函数斜率比较”推广为“两个函数变化率的比较”，是洛必达法则（L'Hospital's Rule）证明链中的关键步骤。</p>
<div class="blog_h3"><span class="graybg">三类中值定理的区别</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">定理</td>
<td style="text-align: center;">涉及函数</td>
<td style="text-align: center;">额外条件</td>
<td style="text-align: center; width: 40%;">结论形式</td>
<td style="text-align: center;">关系</td>
</tr>
</thead>
<tbody>
<tr>
<td>罗尔中值定理</td>
<td>1 个函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(f(a)=f(b)\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(f'(c)=0\)</span></td>
<td>拉格朗日定理的特殊情形</td>
</tr>
<tr>
<td>拉格朗日中值定理</td>
<td>1 个函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span></td>
<td>无额外端点相等条件</td>
<td><span displaypfx="inline-" class="mathjax-container">\(f'(c)=\frac{f(b)-f(a)}{b-a}\)</span></td>
<td>柯西定理取 <span displaypfx="inline-" class="mathjax-container">\(g(x)=x\)</span> 的特例</td>
</tr>
<tr>
<td>柯西中值定理</td>
<td>2 个函数 <span displaypfx="inline-" class="mathjax-container">\(f,g\)</span></td>
<td>两个函数都满足连续+可导</td>
<td><span displaypfx="inline-" class="mathjax-container">\((f(b)-f(a))g'(c)=(g(b)-g(a))f'(c)\)</span></td>
<td>三者中最一般形式</td>
</tr>
</tbody>
</table>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/mean-value-theorems.png"><img class="alignnone size-full wp-image-40785" src="https://blog.gmem.cc/wp-content/uploads/2026/03/mean-value-theorems.png" alt="mean-value-theorems" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">中值定理和AI</span></div>
<p>在 AI 系统中，中值定理主要作为理论工具出现：工程代码很少直接“调用定理”，但大量优化、稳定性与误差分析都在使用它的核心变形，即把“两个点的函数值差”转写为“某个中间点的导数（梯度）信息”。</p>
<ul>
<li>优化收敛分析（Optimization Convergence）：训练中常关心一步更新后损失变化 <span displaypfx="inline-" class="mathjax-container">\(L(\theta+\Delta)-L(\theta)\)</span>。中值定理把它连接到中间点的梯度，进而用于学习率条件与下降性证明。</li>
<li>Lipschitz 界与鲁棒性（Lipschitz Bound &amp; Robustness）：中值定理给出“输入小扰动导致输出变化”的上界形式。若梯度有界，则输出变化可控；这正是对抗鲁棒性分析和梯度惩罚（Gradient Penalty）类方法的数学基础之一。</li>
<li>有限差分与梯度估计（Finite Difference / Gradient Estimation）：割线斜率与切线斜率之间的关系来自拉格朗日中值定理，是数值优化里差分近似、线搜索（Line Search）和误差估计的核心依据。</li>
</ul>
<p>从“纯数学”到“AI 实践”的桥梁可以一句话概括：<span style="background-color: #c0c0c0;">中值定理把宏观变化量（函数值差）变成可优化的微观信息（导数/梯度）</span>。</p>
<div class="blog_h2"><span class="graybg">偏导数（Partial Derivative）</span></div>
<p>对多元函数 <span displaypfx="inline-" class="mathjax-container">\(f:\mathbb{R}^n\to\mathbb{R}\)</span>，偏导数把“只沿某一坐标轴方向的导数”形式化：固定其余变量，只让第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个变量变化。先写成最直接的极限定义：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial f}{\partial x_i}(x_0)=\lim_{h\to 0}\frac{f(x_{0,1},\dots,x_{0,i-1},x_{0,i}+h,x_{0,i+1},\dots,x_{0,n})-f(x_0)}{h}\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(h\)</span> 是“第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个变量的微小增量”（Increment）：只加在第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个坐标上，其他坐标保持不变。</p>
<p>在线性代数记号下，上式等价写成（更紧凑）：令 <span displaypfx="inline-" class="mathjax-container">\(e_i\)</span> 为第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个标准基向量（Standard Basis Vector），则</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial f}{\partial x_i}(x_0)=\lim_{h\to 0}\frac{f(x_0+h e_i)-f(x_0)}{h}\]</span>
<p>更常用的导数视角（计算视角）是：<span style="background-color: #c0c0c0;">把其余变量暂时当常数，把目标变量当自变量，直接套一元求导法则</span>。也就是说，极限定义负责“定义正确性”，日常计算通常用求导规则完成。</p>
<p>例如 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=x^2y\)</span>：对 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 求偏导时把 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 视作常数，得 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f}{\partial x}=2xy\)</span>；对 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 求偏导时把 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 视作常数，得 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f}{\partial y}=x^2\)</span>。</p>
<p>切换为极限视角，令 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=x^2y\)</span>，在点 <span displaypfx="inline-" class="mathjax-container">\((1,2)\)</span>。</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial f}{\partial x}(1,2)=\lim_{h\to 0}\frac{f(1+h,2)-f(1,2)}{h}\]</span>
<span displaypfx="" class="mathjax-container">\[=\lim_{h\to 0}\frac{2(1+h)^2-2}{h}\]</span>
<span displaypfx="" class="mathjax-container">\[=\lim_{h\to 0}(4+2h)=4\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial f}{\partial y}(1,2)=\lim_{h\to 0}\frac{f(1,2+h)-f(1,2)}{h}\]</span>
<span displaypfx="" class="mathjax-container">\[=\lim_{h\to 0}\frac{(2+h)-2}{h}=1\]</span>
<p>可以看到：求 <span displaypfx="inline-" class="mathjax-container">\(\partial f/\partial x\)</span> 时把 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 当常数；求 <span displaypfx="inline-" class="mathjax-container">\(\partial f/\partial y\)</span> 时把 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 当常数。这正是“其余变量固定”的具体计算含义。</p>
<p>梯度（Gradient）就是把所有偏导数按坐标收集成向量： <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x)=(\partial f/\partial x_1,\dots,\partial f/\partial x_n)^\top\)</span>。</p>
<div class="blog_h2"><span class="graybg">微分（Differential）</span></div>
<p>在一元情形中，微分是“把函数增量线性化”的记号：给定一个小增量 <span displaypfx="inline-" class="mathjax-container">\(dx\)</span>，定义</p>
<span displaypfx="" class="mathjax-container">\[df\big|_{x_0}=f'(x_0)\,dx\]</span>
<p>此时真实增量满足 <span displaypfx="inline-" class="mathjax-container">\(\Delta f = df + o(dx)\)</span>。给定 <span displaypfx="inline-" class="mathjax-container">\(dx\)</span> 后， <span displaypfx="inline-" class="mathjax-container">\(df\)</span> 才对应一个确定数值。</p>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(f(x)=x^2\)</span>，在 <span displaypfx="inline-" class="mathjax-container">\(x_0=3\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(dx=0.1\)</span> 时， <span displaypfx="inline-" class="mathjax-container">\(df=2x_0dx=0.6\)</span>，而 <span displaypfx="inline-" class="mathjax-container">\(\Delta f=f(3.1)-f(3)=0.61\)</span>。微分给出一阶近似，误差来自高阶项。</p>
<div class="blog_h2"><span class="graybg">全微分（Total Differential）</span></div>
<p>对多元函数 <span displaypfx="inline-" class="mathjax-container">\(f(x_1,\dots,x_n)\)</span>，全微分把“多方向小扰动下的一阶线性响应”写成：</p>
<span displaypfx="" class="mathjax-container">\[df=\sum_{i=1}^n \frac{\partial f}{\partial x_i}dx_i\]</span>
<p>令 <span displaypfx="inline-" class="mathjax-container">\(d\mathbf{x}=(dx_1,\dots,dx_n)^\top\)</span>，则向量形式是：</p>
<span displaypfx="" class="mathjax-container">\[df=(\nabla f(x))^\top d\mathbf{x}\]</span>
<p>若进一步把扰动分解为“方向 + 步长”： <span displaypfx="inline-" class="mathjax-container">\(d\mathbf{x}=\mathbf{u}\,ds\)</span>（<span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{u}\|_2=1\)</span>），则</p>
<span displaypfx="" class="mathjax-container">\[\frac{df}{ds}=\nabla f(x)\cdot \mathbf{u}\]</span>
<p>右侧就是方向导数（Directional Derivative）：全微分给出任意小位移的一阶近似，方向导数则把位移约束在单位方向并除去步长。</p>
<p>例：令 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=x^2y\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f}{\partial x}=2xy\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f}{\partial y}=x^2\)</span>。在点 <span displaypfx="inline-" class="mathjax-container">\((x_0,y_0)=(1,2)\)</span>，若 <span displaypfx="inline-" class="mathjax-container">\(dx=0.1\)</span>、<span displaypfx="inline-" class="mathjax-container">\(dy=-0.05\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(df=2xy\,dx+x^2dy=2\cdot1\cdot2\cdot0.1+1\cdot(-0.05)=0.35\)</span>，给出 <span displaypfx="inline-" class="mathjax-container">\(\Delta f\)</span> 的一阶近似。</p>
<div class="blog_h2"><span class="graybg">向量微积分</span></div>
<p>Nabla 算子（Nabla Operator）记作 <span displaypfx="inline-" class="mathjax-container">\(\nabla\)</span>，本质上是把偏导算子按坐标排列成的“向量形式”：</p>
<span displaypfx="" class="mathjax-container">\[\nabla=\left(\frac{\partial}{\partial x_1},\dots,\frac{\partial}{\partial x_n}\right)^\top\]</span>
<p>它本身不是一个数，而是一个算子（Operator）。作用在标量场上得到梯度（Gradient）；与向量场点乘得到散度（Divergence）；与向量场叉乘得到旋度（Curl）；对标量场再取散度得到拉普拉斯（Laplacian）。</p>
<p>符号 <span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|\)</span>（双竖线）表示范数（Norm）。因此 <span displaypfx="inline-" class="mathjax-container">\(\|\nabla f(x)\|\)</span> 是梯度向量的长度；而 <span displaypfx="inline-" class="mathjax-container">\(\|\nabla\|\)</span> 作为“算子范数”在工程推导中很少直接使用，通常需要明确它作用的函数空间与范数定义。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/nabla.jpg"><img class="alignnone size-full wp-image-40609" src="https://blog.gmem.cc/wp-content/uploads/2026/03/nabla.jpg" alt="nabla" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">梯度</span></div>
<p>梯度（Gradient）把标量函数 <span displaypfx="inline-" class="mathjax-container">\(f:\mathbb{R}^n\to\mathbb{R}\)</span> 的局部变化率组织成向量：</p>
<span displaypfx="" class="mathjax-container">\[\nabla f(x)=\left(\frac{\partial f}{\partial x_1},\dots,\frac{\partial f}{\partial x_n}\right)^\top\]</span>
<p>它指向函数增长最快的方向（Steepest Ascent Direction），模长刻画该方向上的最大斜率。在优化里，梯度下降（Gradient Descent）沿 <span displaypfx="inline-" class="mathjax-container">\(-\nabla f\)</span> 走，是因为这是一阶近似下最快下降方向。</p>
<p>例： <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=x^2+2y^2\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\nabla f=(2x,4y)\)</span>，在点 <span displaypfx="inline-" class="mathjax-container">\((1,1)\)</span> 处梯度为 <span displaypfx="inline-" class="mathjax-container">\((2,4)\)</span>，表示沿 y 方向的增长更陡。</p>
<p>方向导数（Directional Derivative）把“沿某个方向的变化率”写成点积：若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\)</span> 是单位方向向量，则</p>
<span displaypfx="" class="mathjax-container">\[D_{\mathbf{u}}f(x)=\nabla f(x)\cdot \mathbf{u}\]</span>
<p>符号先约定： <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是当前点， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{u}\)</span> 是单位方向（<span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{u}\|_2=1\)</span>）， <span displaypfx="inline-" class="mathjax-container">\(\varepsilon\)</span> 是沿该方向的步长（可正可负的小实数）。方向导数定义为</p>
<span displaypfx="" class="mathjax-container">\[D_{\mathbf{u}}f(x)=\lim_{\varepsilon\to 0}\frac{f(x+\varepsilon\mathbf{u})-f(x)}{\varepsilon}\]</span>
<p>对 <span displaypfx="inline-" class="mathjax-container">\(f(x+\varepsilon\mathbf{u})\)</span> 做一阶 Taylor 展开：</p>
<span displaypfx="" class="mathjax-container">\[f(x+\varepsilon\mathbf{u})=f(x)+\varepsilon\,\nabla f(x)\cdot\mathbf{u}+o(\varepsilon)\]</span>
<p>代回定义式：</p>
<span displaypfx="" class="mathjax-container">\[\frac{f(x+\varepsilon\mathbf{u})-f(x)}{\varepsilon}\]</span>
<span displaypfx="" class="mathjax-container">\[=\nabla f(x)\cdot\mathbf{u}+\frac{o(\varepsilon)}{\varepsilon}\]</span>
<p>令 <span displaypfx="inline-" class="mathjax-container">\(\varepsilon\to 0\)</span>，由于 <span displaypfx="inline-" class="mathjax-container">\(o(\varepsilon)/\varepsilon\to 0\)</span>，得到 <span displaypfx="inline-" class="mathjax-container">\(D_{\mathbf{u}}f(x)=\nabla f(x)\cdot\mathbf{u}\)</span>。</p>
<div class="blog_h3"><span class="graybg">散度</span></div>
<p>场（Field）是“把空间中每个点映射到一个量”的函数。标量场（Scalar Field）<span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 在每个点输出一个标量；向量场（Vector Field）<span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}(x)\)</span> 在每个点输出一个向量。梯度作用在标量场上，散度/旋度作用在向量场上。</p>
<p>散度（Divergence）作用在向量场 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}=(F_1,\dots,F_n)^\top:\mathbb{R}^n\to\mathbb{R}^n\)</span> 上。这里先把两个术语说清楚：</p>
<span displaypfx="" class="mathjax-container">\[J_{\mathbf{F}}(x)=\left[\frac{\partial F_i}{\partial x_j}\right]_{i,j=1}^n\]</span>
<p>上式是雅可比矩阵（Jacobian）：第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 列表示 <span displaypfx="inline-" class="mathjax-container">\(F_i\)</span> 对 <span displaypfx="inline-" class="mathjax-container">\(x_j\)</span> 的偏导。迹（Trace）是矩阵对角线元素之和，因此</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{tr}(J_{\mathbf{F}})=\sum_{i=1}^n \frac{\partial F_i}{\partial x_i}\]</span>
<p>而散度按定义正是这条和式，所以“散度 = Jacobian 的迹”：</p>
<span displaypfx="" class="mathjax-container">\[\nabla\cdot \mathbf{F}(x)=\mathrm{tr}(J_{\mathbf{F}}(x))=\sum_{i=1}^n \frac{\partial F_i}{\partial x_i}\]</span>
<p>几何/物理直觉：把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}\)</span> 想成“流体速度场（Velocity Field）”，散度衡量一个点附近是否像“源（Source）/汇（Sink）”——单位体积的净流出率。散度为正表示净流出，为负表示净流入。</p>
<p>例：二维场 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}(x,y)=(x,y)\)</span> 的 Jacobian 是 <span displaypfx="inline-" class="mathjax-container">\(\begin{bmatrix}1&amp;0\\0&amp;1\end{bmatrix}\)</span>，其迹为 <span displaypfx="inline-" class="mathjax-container">\(1+1=2\)</span>，所以散度也等于 2，在任意点都表现为均匀“发散”。</p>
<div class="blog_h3"><span class="graybg">旋度</span></div>
<p>旋度（Curl）衡量向量场的局部旋转（Local Rotation）。在三维中：</p>
<span displaypfx="" class="mathjax-container">\[\nabla\times \mathbf{F}=\left(\frac{\partial F_3}{\partial y}-\frac{\partial F_2}{\partial z},\ \frac{\partial F_1}{\partial z}-\frac{\partial F_3}{\partial x},\ \frac{\partial F_2}{\partial x}-\frac{\partial F_1}{\partial y}\right)\]</span>
<p>直觉上，可把它理解为“放一个很小的桨轮（Paddle Wheel）在该点附近，桨轮是否会被带着转”。保守场（Conservative Field）满足 <span displaypfx="inline-" class="mathjax-container">\(\nabla\times\mathbf{F}=\mathbf{0}\)</span>，这与“路径无关”/“存在势函数”是等价刻画。</p>
<p>例：二维旋转场 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}(x,y)=(-y,x,0)\)</span> 的旋度为 <span displaypfx="inline-" class="mathjax-container">\((0,0,2)\)</span>，表示绕 z 轴的均匀旋转趋势。</p>
<p>拉普拉斯算子（Laplacian）作用在标量场上，定义为梯度的散度：</p>
<span displaypfx="" class="mathjax-container">\[\nabla^2 f=\nabla\cdot(\nabla f)=\sum_{i=1}^n \frac{\partial^2 f}{\partial x_i^2}\]</span>
<p>它常出现在扩散（Diffusion）与平滑（Smoothing）问题中，也常被用作“曲率/粗糙度”的度量。例： <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=x^2+y^2\)</span> 则 <span displaypfx="inline-" class="mathjax-container">\(\nabla^2 f=2+2=4\)</span>。</p>
<div class="blog_h2"><span class="graybg">Jacobian 矩阵</span></div>
<p>当函数的输出不再是一个标量，而是一个向量时，梯度这个概念就不够用了。此时需要把“每个输出分量对每个输入变量的一阶变化率”统一收集起来，这个对象就是 Jacobian 矩阵（Jacobian Matrix）。它是一阶导数在向量值函数上的自然推广，也是 Hessian 的直接前置概念。</p>
<p>设向量值函数</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{F}:\mathbb{R}^n\to\mathbb{R}^m,\qquad \mathbf{F}(\mathbf{x})=\begin{bmatrix}F_1(\mathbf{x})\\ \vdots\\ F_m(\mathbf{x})\end{bmatrix}\]</span>
<p>其中输入 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x_1,\dots,x_n)^\top\)</span>，输出 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}(\mathbf{x})\)</span> 有 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 个分量。Jacobian 矩阵定义为：</p>
<span displaypfx="" class="mathjax-container">\[J_{\mathbf{F}}(\mathbf{x})=\left[\frac{\partial F_i}{\partial x_j}(\mathbf{x})\right]_{i=1,\dots,m;\,j=1,\dots,n}\]</span>
<p>按矩阵展开，就是一个 <span displaypfx="inline-" class="mathjax-container">\(m\times n\)</span> 矩阵：</p>
<span displaypfx="" class="mathjax-container">\[J_{\mathbf{F}}(\mathbf{x})= \begin{bmatrix} \frac{\partial F_1}{\partial x_1} &amp; \frac{\partial F_1}{\partial x_2} &amp; \cdots &amp; \frac{\partial F_1}{\partial x_n}\\ \frac{\partial F_2}{\partial x_1} &amp; \frac{\partial F_2}{\partial x_2} &amp; \cdots &amp; \frac{\partial F_2}{\partial x_n}\\ \vdots &amp; \vdots &amp; \ddots &amp; \vdots\\ \frac{\partial F_m}{\partial x_1} &amp; \frac{\partial F_m}{\partial x_2} &amp; \cdots &amp; \frac{\partial F_m}{\partial x_n} \end{bmatrix}\]</span>
<p>这里第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 列的元素 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial F_i}{\partial x_j}\)</span> 表示：第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个输入变量发生微小变化时，第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个输出分量会怎样一阶变化。按矩阵读法，<span style="background-color: #c0c0c0;">行对应输出分量，列对应输入变量</span>，因此 Jacobian 本质上是一张局部灵敏度表（Local Sensitivity Table）。</p>
<p>它描述的不是全局非线性结构，而是函数在某个点附近的局部线性映射：输入空间里的一个小扰动向量 <span displaypfx="inline-" class="mathjax-container">\(d\mathbf{x}\)</span> 经过 Jacobian 作用后，变成输出空间中的一阶近似扰动 <span displaypfx="inline-" class="mathjax-container">\(d\mathbf{F}\)</span>。因此，Jacobian 可以理解为“该点附近最能代表原函数的一阶线性算子”。</p>
<p>它与全微分的关系最直接。若在点 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 附近加入一个小扰动 <span displaypfx="inline-" class="mathjax-container">\(d\mathbf{x}\)</span>，则输出的一阶变化满足：</p>
<span displaypfx="" class="mathjax-container">\[d\mathbf{F}\approx J_{\mathbf{F}}(\mathbf{x})\,d\mathbf{x}\]</span>
<p>这条式子就是向量值函数的一阶线性化。也就是说，Jacobian 在局部扮演的角色，类似于一元函数里的导数：它给出“最好的线性近似”。若输出是一维，即 <span displaypfx="inline-" class="mathjax-container">\(m=1\)</span>，Jacobian 就退化成一个 <span displaypfx="inline-" class="mathjax-container">\(1\times n\)</span> 的行向量；它与梯度本质上包含同一组偏导数，只是排布约定可能不同。</p>
<p>一个二维到二维的例子最容易看清。设</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{F}(x,y)=\begin{bmatrix}x^2y\\ x+y^2\end{bmatrix}\]</span>
<p>则第一行来自分量函数 <span displaypfx="inline-" class="mathjax-container">\(F_1(x,y)=x^2y\)</span>，第二行来自 <span displaypfx="inline-" class="mathjax-container">\(F_2(x,y)=x+y^2\)</span>。逐项求偏导可得：</p>
<span displaypfx="" class="mathjax-container">\[J_{\mathbf{F}}(x,y)= \begin{bmatrix} 2xy &amp; x^2\\ 1 &amp; 2y \end{bmatrix}\]</span>
<p>例如在点 <span displaypfx="inline-" class="mathjax-container">\((1,2)\)</span>，Jacobian 为</p>
<span displaypfx="" class="mathjax-container">\[J_{\mathbf{F}}(1,2)= \begin{bmatrix} 4 &amp; 1\\ 1 &amp; 4 \end{bmatrix}\]</span>
<p>这表示：在该点附近，若输入发生微小变化 <span displaypfx="inline-" class="mathjax-container">\((dx,dy)^\top\)</span>，则输出变化近似为</p>
<span displaypfx="" class="mathjax-container">\[d\mathbf{F}\approx \begin{bmatrix} 4 &amp; 1\\ 1 &amp; 4 \end{bmatrix} \begin{bmatrix} dx\\ dy \end{bmatrix}\]</span>
<p>第一行说明输出第一分量大致按 <span displaypfx="inline-" class="mathjax-container">\(4dx+dy\)</span> 变化，第二行说明输出第二分量大致按 <span displaypfx="inline-" class="mathjax-container">\(dx+4dy\)</span> 变化。Jacobian 因而可以被理解为局部灵敏度矩阵（Sensitivity Matrix）。</p>
<p>对标量函数 <span displaypfx="inline-" class="mathjax-container">\(f:\mathbb{R}^n\to\mathbb{R}\)</span>，梯度 <span displaypfx="inline-" class="mathjax-container">\(\nabla f:\mathbb{R}^n\to\mathbb{R}^n\)</span> 本身就是一个向量值函数，因此 Hessian 可以直接看成梯度映射的 Jacobian：</p>
<span displaypfx="" class="mathjax-container">\[\nabla^2 f(\mathbf{x})=J_{\nabla f}(\mathbf{x})\]</span>
<p>这就是把 Jacobian 放在 Hessian 前面的原因：先理解“向量值函数的一阶导数如何组织成矩阵”，再看“标量函数的梯度再求一次导”，Hessian 的结构就不会显得突兀。</p>
<p>在机器学习中，Jacobian 最常出现在三类地方：</p>
<ol>
<li>反向传播（Backpropagation）：本质上是在链式法则（Chain Rule）下逐层组合这些局部 Jacobian。前一层输出的微小变化如何传到后一层，正是由对应层的 Jacobian 决定。</li>
<li>向量场分析：散度（Divergence）等于 Jacobian 的迹（Trace）。</li>
<li>生成模型、正则化与鲁棒性分析：常关心 Jacobian 的谱范数（Spectral Norm）或 Frobenius 范数，以度量局部放大效应。</li>
</ol>
<p>若输出维度和输入维度都很大，显式构造完整 Jacobian 也会很贵，因此工程上常直接计算 Jacobian-Vector Product 或 Vector-Jacobian Product，而不是把整块矩阵完全展开。</p>
<p>在神经网络语境里，Jacobian 到底“针对激活值还是针对权重”，取决于谁被当作自变量。若研究层与层之间的信号传播，常写成 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial h^{(l+1)}}{\partial h^{(l)}}\)</span>，这时它针对的是激活值（Activation）或层表示；若研究模型输出对输入的敏感度，可写成 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial y}{\partial x}\)</span>；若研究输出对参数的敏感度，也可以写成 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial y}{\partial \theta}\)</span>。因此，Jacobian 的本质是“一个向量怎样对另一个向量发生一阶变化”，并不预先限定变量一定是激活值还是权重。</p>
<div class="blog_h2"><span class="graybg">Hessian 矩阵</span></div>
<p>梯度（Gradient）回答的是“函数沿各坐标方向的一阶变化率”；Hessian 矩阵（Hessian Matrix）进一步回答的是“这些一阶变化率本身又在怎样变化”。这一点可以直接从“梯度是一个向量值函数”展开出来。对标量函数 <span displaypfx="inline-" class="mathjax-container">\(f:\mathbb{R}^n\to\mathbb{R}\)</span>，梯度写成</p>
<span displaypfx="" class="mathjax-container">\[\nabla f(\mathbf{x})=\begin{bmatrix}\frac{\partial f}{\partial x_1}\\ \frac{\partial f}{\partial x_2}\\ \vdots\\ \frac{\partial f}{\partial x_n}\end{bmatrix}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\nabla f\)</span> 已经不是标量，而是把输入向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 映射为另一个向量的函数。因此可以像对任何向量值函数那样，对它再求一次 Jacobian：</p>
<span displaypfx="" class="mathjax-container">\[J_{\nabla f}(\mathbf{x})=\frac{\partial (\nabla f)}{\partial \mathbf{x}}\]</span>
<p>把梯度各分量逐项代进去，Jacobian 的第 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 个元素就是“梯度第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量对输入第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个变量的偏导数”：</p>
<span displaypfx="" class="mathjax-container">\[\big[J_{\nabla f}(\mathbf{x})\big]_{ij}=\frac{\partial}{\partial x_j}\left(\frac{\partial f}{\partial x_i}\right)=\frac{\partial^2 f}{\partial x_j\partial x_i}\]</span>
<p>于是整块矩阵就是</p>
<span displaypfx="" class="mathjax-container">\[J_{\nabla f}(\mathbf{x})=\begin{bmatrix}\frac{\partial^2 f}{\partial x_1\partial x_1} &amp; \frac{\partial^2 f}{\partial x_2\partial x_1} &amp; \cdots &amp; \frac{\partial^2 f}{\partial x_n\partial x_1}\\ \frac{\partial^2 f}{\partial x_1\partial x_2} &amp; \frac{\partial^2 f}{\partial x_2\partial x_2} &amp; \cdots &amp; \frac{\partial^2 f}{\partial x_n\partial x_2}\\ \vdots &amp; \vdots &amp; \ddots &amp; \vdots\\ \frac{\partial^2 f}{\partial x_1\partial x_n} &amp; \frac{\partial^2 f}{\partial x_2\partial x_n} &amp; \cdots &amp; \frac{\partial^2 f}{\partial x_n\partial x_n}\end{bmatrix}\]</span>
<p>这正是 Hessian：</p>
<span displaypfx="" class="mathjax-container">\[\nabla^2 f(\mathbf{x})=J_{\nabla f}(\mathbf{x})\]</span>
<p>这个等式的含义很直接：Hessian 不是凭空引入的另一种矩阵，而就是“梯度这个向量场对输入的 Jacobian”。</p>
<p>直接从二阶导角度看，Hessian 是多元函数的二阶导数对象，用来描述局部曲率（Curvature）、凹凸性以及临界点附近的二阶形状。</p>
<p>设标量函数 <span displaypfx="inline-" class="mathjax-container">\(f:\mathbb{R}^n\to\mathbb{R}\)</span>，输入向量为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x_1,\dots,x_n)^\top\)</span>。Hessian 矩阵记作 <span displaypfx="inline-" class="mathjax-container">\(\nabla^2 f(\mathbf{x})\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(H_f(\mathbf{x})\)</span>，定义为：</p>
<span displaypfx="" class="mathjax-container">\[\nabla^2 f(\mathbf{x})=\left[\frac{\partial^2 f}{\partial x_i\partial x_j}(\mathbf{x})\right]_{i,j=1}^{n}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 都从 <span displaypfx="inline-" class="mathjax-container">\(1\)</span> 遍历到 <span displaypfx="inline-" class="mathjax-container">\(n\)</span>，因此 Hessian 会收集所有变量对 <span displaypfx="inline-" class="mathjax-container">\((x_i,x_j)\)</span> 的二阶偏导。之所以自然形成一个 <span displaypfx="inline-" class="mathjax-container">\(n\times n\)</span> 的“全组合”矩阵，是因为一阶导数 <span displaypfx="inline-" class="mathjax-container">\(\nabla f\)</span> 本身已经有 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个分量；再对这 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个分量分别对 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个输入变量各求一次导，就得到 <span displaypfx="inline-" class="mathjax-container">\(n\times n\)</span> 个二阶偏导项。前面展开的 <span displaypfx="inline-" class="mathjax-container">\(J_{\nabla f}(\mathbf{x})\)</span> 就是这块矩阵，因此这里不再重复写一遍。</p>
<p>把梯度记为 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(\mathbf{x})=(g_1,\dots,g_n)^\top\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(g_i=\frac{\partial f}{\partial x_i}\)</span>，则 Hessian 的第 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 个元素可以直接写成 <span displaypfx="inline-" class="mathjax-container">\(H_{ij}=\frac{\partial g_i}{\partial x_j}\)</span>。这个记号把它的意义说得很清楚：它衡量“第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个方向上的斜率”会不会随着第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个变量的变化而改变。</p>
<p>对角线元素 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial^2 f}{\partial x_i^2}\)</span> 描述沿单一坐标方向的纯二阶曲率，也就是该方向本身的弯曲强弱；非对角元素 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial^2 f}{\partial x_i\partial x_j}\)</span> 描述不同方向之间的局部耦合（Local Coupling）。当 <span displaypfx="inline-" class="mathjax-container">\(H_{ij}=0\)</span> 时，意味着在当前点附近，变量 <span displaypfx="inline-" class="mathjax-container">\(x_j\)</span> 的微小变化不会改变第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个方向上的一阶斜率；当 <span displaypfx="inline-" class="mathjax-container">\(H_{ij}\neq 0\)</span> 时，两个方向在局部二阶结构上发生耦合。</p>
<p>这种耦合会直接反映在等高线（Contour）形状上：若非对角项接近零，局部二次近似更接近与坐标轴对齐的“正椭圆碗”；若非对角项明显非零，等高线会发生倾斜，说明改变一个变量会连带改变另一个方向上的坡度。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 足够光滑，满足二阶混合偏导连续，则由 Clairaut / Schwarz 定理可得</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial^2 f}{\partial x_i\partial x_j}=\frac{\partial^2 f}{\partial x_j\partial x_i}\]</span>
<p>因此 Hessian 矩阵是对称矩阵（Symmetric Matrix）。这点非常重要，因为一旦 Hessian 对称，就可以用特征值（Eigenvalue）来判断局部曲率：正特征值对应向上弯，负特征值对应向下弯，正负并存则意味着不同方向上的弯曲趋势相反。</p>
<p>Hessian 的几何意义可以通过二阶 Taylor 展开看得最清楚。对点 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 附近的微小扰动 <span displaypfx="inline-" class="mathjax-container">\(\Delta \mathbf{x}\)</span>，有：</p>
<span displaypfx="" class="mathjax-container">\[f(\mathbf{x}+\Delta\mathbf{x})\approx f(\mathbf{x})+\nabla f(\mathbf{x})^\top \Delta\mathbf{x}+\frac{1}{2}\Delta\mathbf{x}^\top \nabla^2 f(\mathbf{x})\Delta\mathbf{x}\]</span>
<p>这里第一项 <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})\)</span> 是当前函数值，第二项 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(\mathbf{x})^\top \Delta\mathbf{x}\)</span> 是一阶线性变化，第三项 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\Delta\mathbf{x}^\top \nabla^2 f(\mathbf{x})\Delta\mathbf{x}\)</span> 就是二阶曲率修正项。也就是说，梯度告诉你“朝哪边走函数会立刻增减”，Hessian 则告诉你“地形本身是碗状、山丘状，还是马鞍状”。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/hessian-locality-preview.png"><img class="alignnone size-full" src="https://blog.gmem.cc/wp-content/uploads/2026/03/hessian-locality.png" alt="hessian-locality" width="2307" height="1169" /></a></p>
<p>二维情形最直观。设</p>
<span displaypfx="" class="mathjax-container">\[f(x_1,x_2)=x_1^2+3x_1x_2+2x_2^2\]</span>
<p>先求梯度：</p>
<span displaypfx="" class="mathjax-container">\[\nabla f(x_1,x_2)=\begin{bmatrix}2x_1+3x_2\\3x_1+4x_2\end{bmatrix}\]</span>
<p>再求二阶偏导，可得：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial^2 f}{\partial x_1^2}=2,\qquad \frac{\partial^2 f}{\partial x_1\partial x_2}=3,\qquad \frac{\partial^2 f}{\partial x_2^2}=4\]</span>
<p>因此 Hessian 为：</p>
<span displaypfx="" class="mathjax-container">\[\nabla^2 f(x_1,x_2)=\begin{bmatrix}2 &amp; 3\\3 &amp; 4\end{bmatrix}\]</span>
<p>这个例子还有一个关键特征：Hessian 与 <span displaypfx="inline-" class="mathjax-container">\((x_1,x_2)\)</span> 无关，因此它说明该函数在整个空间里都是同一种二次曲面（Quadratic Surface）。对二次函数而言，Hessian 足以完整决定其曲率结构。</p>
<p>在极值判别里，Hessian 主要出现在临界点（Critical Point）附近。若某点 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}^*\)</span> 满足 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(\mathbf{x}^*)=\mathbf{0}\)</span>，则：</p>
<ul>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\nabla^2 f(\mathbf{x}^*)\)</span> 正定（Positive Definite），即对任意非零向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\)</span> 都有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}^\top \nabla^2 f(\mathbf{x}^*)\mathbf{v}&gt;0\)</span>，则该点是局部极小值（Local Minimum）。</li>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\nabla^2 f(\mathbf{x}^*)\)</span> 负定（Negative Definite），即对任意非零向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}\)</span> 都有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}^\top \nabla^2 f(\mathbf{x}^*)\mathbf{v}&lt;0\)</span>，则该点是局部极大值（Local Maximum）。</li>
<li>若 Hessian 不定（Indefinite），即存在方向使二次型为正，也存在方向使其为负，则该点是鞍点（Saddle Point）。</li>
<li>若 Hessian 只有半正定、半负定，或出现零特征值，则二阶信息不足以单独判定，还需要更高阶分析或结合函数结构进一步判断。</li>
</ul>
<p>这套判据和一维情形完全一致：一维里 <span displaypfx="inline-" class="mathjax-container">\(f''(x^*)&gt;0\)</span> 表示“碗底”， <span displaypfx="inline-" class="mathjax-container">\(f''(x^*)&lt;0\)</span> 表示“山顶”；Hessian 只是把这个二阶导概念推广到了多维空间。</p>
<p>在优化中，Hessian 的意义更直接。牛顿法（Newton's Method）用它近似局部曲面，并据此选择更合理的更新方向：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}_{t+1}=\mathbf{x}_t-\big(\nabla^2 f(\mathbf{x}_t)\big)^{-1}\nabla f(\mathbf{x}_t)\]</span>
<p>与只看一阶斜率的梯度下降不同，牛顿法还利用了局部曲率信息：若某个方向很陡但也弯得很厉害，步长就应更谨慎；若某个方向很平缓，更新可以相对更大。这也是为什么 Hessian 会出现在牛顿法、拟牛顿法（BFGS / L-BFGS）以及很多二阶优化分析中。</p>
<p>在机器学习里，Hessian 还常用来讨论损失面的尖锐度（Sharpness）、参数可辨识性以及训练稳定性。但深度学习模型维度极高，完整 Hessian 的存储与求逆代价非常大：若参数维度为 <span displaypfx="inline-" class="mathjax-container">\(d\)</span>，Hessian 大小就是 <span displaypfx="inline-" class="mathjax-container">\(d\times d\)</span>。因此工程上更常用 Hessian 向量积（Hessian-Vector Product）、对角近似、低秩近似，或拟牛顿方法来间接利用二阶信息，而不是显式构造整块矩阵。</p>
<p>在神经网络里，单独说 Hessian 时，默认通常指损失函数对参数向量 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的 Hessian，即 <span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta^2 L(\theta)\)</span>。原因很直接：优化真正要更新的是权重，因此人们最关心的是“损失面对参数空间的局部曲率”。当然，Hessian 也可以对输入或中间激活值来求，例如 <span displaypfx="inline-" class="mathjax-container">\(\nabla_x^2 f(x)\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\nabla_h^2 f(h)\)</span>，它们分别描述输入空间或表示空间中的局部弯曲；只是从工程语境看，这类用法通常不是默认含义。</p>
<div class="blog_h2"><span class="graybg">积分</span></div>
<div class="blog_h3"><span class="graybg">不定积分</span></div>
<p>不定积分（Indefinite Integral）也叫反导数、原函数。是“由变化率反推原函数（Antiderivative）”：已知 <span displaypfx="inline-" class="mathjax-container">\(f\)</span>，寻找所有满足 <span displaypfx="inline-" class="mathjax-container">\(F'(x)=f(x)\)</span> 的函数 <span displaypfx="inline-" class="mathjax-container">\(F\)</span>。</p>
<p><span style="background-color: #c0c0c0;">一句话强调：不定积分就是“已知导函数（导数），求原函数”的过程。</span></p>
<span displaypfx="" class="mathjax-container">\[\int f(x)\,dx=F(x)+C\]</span>
<div class="blog_h4"><span class="graybg">从变化率反推状态</span></div>
<p>把导数看作“瞬时变化率”最容易理解积分的方向。若 <span displaypfx="inline-" class="mathjax-container">\(s(t)\)</span> 是位置、<span displaypfx="inline-" class="mathjax-container">\(v(t)\)</span> 是速度，则 <span displaypfx="inline-" class="mathjax-container">\(s'(t)=v(t)\)</span>。当只知道 <span displaypfx="inline-" class="mathjax-container">\(v(t)\)</span> 时，要恢复 <span displaypfx="inline-" class="mathjax-container">\(s(t)\)</span> 就是在做积分。</p>
<span displaypfx="" class="mathjax-container">\[s'(t)=v(t)\ \Longrightarrow\ s(t)=\int v(t)\,dt\]</span>
<div class="blog_h4"><span class="graybg">为什么必须有 +C</span></div>
<p>因为微分会“抹掉常数项”。不同函数只要相差一个常数，导数就完全一样：</p>
<span displaypfx="" class="mathjax-container">\[\frac{d}{dx}(x^2)=\frac{d}{dx}(x^2+5)=\frac{d}{dx}(x^2-100)=2x\]</span>
<p>所以当你从 <span displaypfx="inline-" class="mathjax-container">\(f(x)=2x\)</span> 反推原函数时，只能确定主体是 <span displaypfx="inline-" class="mathjax-container">\(x^2\)</span>，无法确定常数偏移。这就是积分常数（Constant of Integration）<span displaypfx="inline-" class="mathjax-container">\(C\)</span> 的来源。若再给一个初值条件（Initial Condition），例如 <span displaypfx="inline-" class="mathjax-container">\(F(x_0)=y_0\)</span>，<span displaypfx="inline-" class="mathjax-container">\(C\)</span> 才会被唯一确定。</p>
<div class="blog_h4"><span class="graybg">几何图像：不是一条曲线，而是一族</span></div>
<p><span displaypfx="inline-" class="mathjax-container">\(\int f(x)\,dx\)</span> 的结果对应一族函数 <span displaypfx="inline-" class="mathjax-container">\(\{F(x)+C\mid C\in\mathbb{R}\}\)</span>。它们形状完全相同，只是沿 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 轴上下平移；在同一个 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 处，这族曲线的切线斜率都等于 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span>。</p>
<div class="blog_h4"><span class="graybg">一个最小可算例</span></div>
<span displaypfx="" class="mathjax-container">\[\int 2x\,dx=x^2+C\]</span>
<p>验算： <span displaypfx="inline-" class="mathjax-container">\(\frac{d}{dx}(x^2+C)=2x\)</span>。这一步强调的是“求导与积分互为逆过程”，但逆过程会保留一个常数不确定性。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/indefinite-integral1.png"><img class="alignnone size-full wp-image-40773" src="https://blog.gmem.cc/wp-content/uploads/2026/03/indefinite-integral1.png" alt="indefinite-integral" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">微积分基本定理（Fundamental Theorem of Calculus）</span></div>
<p>不定积分与定积分的连接点是微积分基本定理（Fundamental Theorem of Calculus）。它包含两条互补结论：</p>
<ol>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(F'(x)=f(x)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\int_a^b f(x)\,dx=F(b)-F(a)\)</span>。这告诉我们：定积分可以通过任意一个原函数在端点处相减来计算。</li>
<li>定义 <span displaypfx="inline-" class="mathjax-container">\(G(x)=\int_a^x f(t)\,dt\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(G'(x)=f(x)\)</span>。这告诉我们：把函数从 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 到 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 累积起来，得到的累积函数本身就是一个原函数。</li>
</ol>
<p><span style="background-color: #c0c0c0;">一句话强调：定积分就是原函数（不定积分）在区间两端的函数值“收尾相减”</span>，即 <span displaypfx="inline-" class="mathjax-container">\(\int_a^b f(x)\,dx=F(b)-F(a)\)</span>。</p>
<p>例： <span displaypfx="inline-" class="mathjax-container">\(f(x)=2x\)</span>，取原函数 <span displaypfx="inline-" class="mathjax-container">\(F(x)=x^2\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\int_1^3 2x\,dx=F(3)-F(1)=9-1=8\]</span>
<div class="blog_h3"><span class="graybg">定积分</span></div>
<p>定积分（Definite Integral）可看成“带符号面积（Signed Area）”或“连续求和”。Riemann 和定义为：</p>
<span displaypfx="" class="mathjax-container">\[\int_a^b f(x)\,dx=\lim_{n\to\infty}\sum_{k=1}^{n}f(\xi_k)\Delta x_k\]</span>
<p>若上下界中出现 <span displaypfx="inline-" class="mathjax-container">\(\infty\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(-\infty\)</span>，它仍然属于定积分范畴，只是更准确地叫广义积分（Improper Integral）或广义定积分：本质上仍是在一个区间上求累积量，只是需要先把无穷区间截断，再用极限恢复。例如</p>
<span displaypfx="" class="mathjax-container">\[\int_a^{\infty} f(x)\,dx:=\lim_{R\to\infty}\int_a^R f(x)\,dx\]</span>
<p>因此“上下界无穷”不是不定积分，而是<span style="background-color: #c0c0c0;">定积分的特殊形式</span>。</p>
<p>在 AI 里，连续概率密度函数（Probability Density Function, PDF）的概率计算 <span displaypfx="inline-" class="mathjax-container">\(P(a\le X\le b)=\int_a^b p(x)\,dx\)</span> 本质上就是定积分。</p>
<div class="blog_h3"><span class="graybg">常用积分公式</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">被积函数</td>
<td style="text-align: center;">积分结果</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(x^n\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{x^{n+1}}{n+1}+C\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(n\ne -1\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{x}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\ln|x|+C\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(x\ne 0\)</span></td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(e^x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(e^x+C\)</span></td>
<td>指数函数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(-\cos x+C\)</span></td>
<td>三角函数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\cos x\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\sin x+C\)</span></td>
<td>三角函数</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{1}{1+x^2}\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\arctan x+C\)</span></td>
<td>反三角函数</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">多重积分</span></div>
<p>多重积分（Multiple Integrals）最容易卡住人的地方，不是计算，而是符号一上来就把几何直觉遮住了。理解它的关键是把“积分”继续看成连续求和，只不过求和对象不再只是线段上的小长度，而是区域上的小面积、空间中的小体积，或者曲面上的小面片。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/multiple-integral.jpg"><img class="alignnone size-full wp-image-40789" src="https://blog.gmem.cc/wp-content/uploads/2026/03/multiple-integral.jpg" alt="multiple-integral" width="100%" /></a></p>
<div class="blog_h4"><span class="graybg">从一维面积到三维体积</span></div>
<p>一重积分 <span displaypfx="inline-" class="mathjax-container">\(\int f(x)\,dx\)</span> 常被理解为曲线下的面积：在 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 轴上切出很多极窄的小条，每条小条的面积近似是“高度 <span displaypfx="inline-" class="mathjax-container">\(\times\)</span> 宽度”，也就是 <span displaypfx="inline-" class="mathjax-container">\(f(x)\,dx\)</span>，再把它们全部加起来。</p>
<p>双重积分把这个想法从“一条线”推广到“一块区域”。设 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 是平面上的一块区域，曲面高度由 <span displaypfx="inline-" class="mathjax-container">\(z=f(x,y)\)</span> 给出，那么</p>
<span displaypfx="" class="mathjax-container">\[\iint_D f(x,y)\,dx\,dy\]</span>
<p>就可以理解为：在区域 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 的每个小块 <span displaypfx="inline-" class="mathjax-container">\(dx\,dy\)</span> 上立起一根很细的小柱子，柱高是 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)\)</span>。这些小柱子拼起来，形成曲面下方的一整块立体体积。于是双重积分最直观的几何意义就是：<span style="background-color: #c0c0c0;">把区域上方由高度函数堆出来的整块三维体积连续累加起来</span>。</p>
<p>再往上推广，三重积分</p>
<span displaypfx="" class="mathjax-container">\[\iiint_\Omega f(x,y,z)\,dV\]</span>
<p>表示在空间区域 <span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span> 内，对每个微小体积元 <span displaypfx="inline-" class="mathjax-container">\(dV\)</span> 的函数值做累加。此时它不一定是在“求一个更高维体积”，更准确地说，是在整个空间体内部对某个量做总汇总，例如总质量、总能量、总代价或总概率权重。可以把它和一重积分放在同一条理解链上来看：速度函数在时间上的累积给出位移，密度函数在空间上的累积给出质量，能量密度（Energy Density）在区域或体积上的累积给出总能量。若 <span displaypfx="inline-" class="mathjax-container">\(\rho(x,y)\)</span> 是面密度，则 <span displaypfx="inline-" class="mathjax-container">\(\iint_D \rho(x,y)\,dA\)</span> 表示区域 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 上的总质量；若 <span displaypfx="inline-" class="mathjax-container">\(\rho(x,y,z)\)</span> 是体密度，则 <span displaypfx="inline-" class="mathjax-container">\(\iiint_\Omega \rho(x,y,z)\,dV\)</span> 表示空间区域 <span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span> 内的总质量。积分符号的重数，本质上对应的是累积区域的维度。</p>
<div class="blog_h4"><span class="graybg">高度函数到底表示什么</span></div>
<p>双重积分里最需要读懂的是 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)\)</span>。它并不总是“真实高度”，而是一个随位置变化的量。你可以把它看成“每个点上放了多少东西”。积分的过程，就是把整块区域上的这些局部贡献全部收集起来。</p>
<ul>
<li>当 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=1\)</span> 时，每个位置的高度都恒为 1，所以积分值在数值上就等于区域 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 的面积。此时双重积分退化为“面积公式”。</li>
<li>当 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)\)</span> 是密度函数（Density Function）时，积分得到的是总质量、总电荷或总概率。也就是说，函数值越大，说明该位置“堆得越多”，对总量贡献越大。</li>
<li>当 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)\)</span> 表示代价、风险、热量、响应强度等场量时，积分得到的是这类量在整个区域上的累积结果。</li>
</ul>
<p>因此，多重积分不是在机械地操纵符号，而是在做一件很具体的事：把无穷多个微小局部量累加成一个整体量。</p>
<div class="blog_h4"><span class="graybg">带圈的积分号是什么意思</span></div>
<p>有时会看到带圈的双积分号 ∯。这个圈表示积分域是闭合的（Closed）：不是一块敞开的平面区域，而是一个完整包起来的曲面，例如球壳表面、气泡表面或任意封闭外壳。为了把这个“带圈”符号直接看出来，下面就直接写成在闭合曲面 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 上的积分。</p>
<p style="text-align: center; font-size: 1.15em;"><span style="text-align: center; font-size: 2em;">∯</span><sub>S</sub> <span displaypfx="inline-" class="mathjax-container">\(\mathbf{F}\cdot d\mathbf{S}\)</span></p>
<p>这里的含义通常不是“求面积”，而是计算向量场（Vector Field）穿过闭合曲面的总通量（Flux）。直观地说，就是看有多少“流”从这个封闭外壳内部穿出，或者从外部流入。它和高斯散度定理（Gauss's Divergence Theorem）直接相关，因此在偏微分方程（PDE）、连续介质建模、计算流体以及物理信息神经网络（Physics-Informed Neural Networks, PINNs）中十分重要。</p>
<div class="blog_h4"><span class="graybg">多重积分和AI</span></div>
<p>在 AI 中，多重积分最常见的身份是<span style="background-color: #c0c0c0;">概率分布上的总量计算器</span>。如果 <span displaypfx="inline-" class="mathjax-container">\(p(x,y)\)</span> 是二维随机变量的概率密度，那么</p>
<span displaypfx="" class="mathjax-container">\[\iint p(x,y)\,dx\,dy=1\]</span>
<p>表示整张概率曲面下方的总体积必须等于 1。这句话的本质是：所有可能情况加起来，概率总和必须是 100%。</p>
<p>进一步，期望（Expectation）就是在这个概率地形上做加权平均。以一维为例，</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[X]=\int x\,p(x)\,dx\]</span>
<p>表示每个位置 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 都按其概率权重 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span> 参与平均；多维情形下则自然推广为多重积分。用几何语言说，期望就是这堆“概率质量”在各坐标方向上的重心位置。</p>
<p>这也是为什么高斯分布归一化、边缘分布（Marginal Distribution）、条件分布、变分推断（Variational Inference）、连续潜变量模型以及能量模型（Energy-Based Models）都离不开多重积分。很多时候模型真正要做的事，并不是求一个公式值，而是在高维概率空间里累计、归一化、消去变量或计算期望。</p>
<div class="blog_h2"><span class="graybg">卷积（Convolution）</span></div>
<p>卷积（Convolution）把两个函数/序列组合成第三个函数/序列，最常见的语义是：<span style="background-color: #c0c0c0;">“一个信号在另一个信号的权重/响应下做累积叠加”</span>。它同时是信号处理（Signal Processing）、系统理论（Systems Theory）与 CNN 的基础运算。</p>
<div class="blog_h3"><span class="graybg">连续卷积（Continuous Convolution）</span></div>
<p>对连续函数 <span displaypfx="inline-" class="mathjax-container">\(f(t)\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(g(t)\)</span>，卷积定义为：</p>
<span displaypfx="" class="mathjax-container">\[(f*g)(t)=\int_{-\infty}^{+\infty} f(\tau)\,g(t-\tau)\,d\tau\]</span>
<p>经典记忆法：<span style="background-color: #c0c0c0;">翻转（Flip）→ 平移（Shift）→ 相乘（Multiply）→ 积分（Integrate）</span>。其中 <span displaypfx="inline-" class="mathjax-container">\(g(t-\tau)\)</span> 这一项等价于把 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 先时间反转再按 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 平移。</p>
<div class="blog_h3"><span class="graybg">离散卷积（Discrete Convolution）</span></div>
<p>对离散序列 <span displaypfx="inline-" class="mathjax-container">\(x[n]\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(h[n]\)</span>，卷积定义为：</p>
<span displaypfx="" class="mathjax-container">\[(x*h)[n]=\sum_{k=-\infty}^{+\infty} x[k]\,h[n-k]\]</span>
<p>当序列有限长度时，上式求和区间会自然收缩为有限项。二维卷积（2D Convolution）/图像滤波可以视为把求和从一维扩展到二维索引。</p>
<div class="blog_h3"><span class="graybg">因果性与单侧卷积（Causality）</span></div>
<p>工程系统常满足因果性（Causality）：系统在时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的输出只依赖过去与当前输入，不依赖未来输入。若输入 <span displaypfx="inline-" class="mathjax-container">\(f(t)\)</span> 与系统的冲激响应（Impulse Response）<span displaypfx="inline-" class="mathjax-container">\(g(t)\)</span> 都是因果的（<span displaypfx="inline-" class="mathjax-container">\(t&lt;0\)</span> 时为 0），则卷积积分可写成单侧形式：</p>
<span displaypfx="" class="mathjax-container">\[(f*g)(t)=\int_{0}^{t} f(\tau)\,g(t-\tau)\,d\tau\]</span>
<p>这个形式更符合直觉：时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的结果由 <span displaypfx="inline-" class="mathjax-container">\([0,t]\)</span> 区间内“所有过去输入”的贡献叠加而来。</p>
<div class="blog_h3"><span class="graybg">直觉例子：打点滴的累积药效</span></div>
<p>把卷积理解成“累积药效”通常更直观。设 <span displaypfx="inline-" class="mathjax-container">\(r(t)\)</span> 是单位时间的滴注速率（Infusion Rate），<span displaypfx="inline-" class="mathjax-container">\(h(t)\)</span> 是“单位剂量在体内的药效/浓度随时间衰减曲线”（可以把它理解为冲激响应）。在时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的总药效/浓度 <span displaypfx="inline-" class="mathjax-container">\(c(t)\)</span> 可以写成：</p>
<span displaypfx="" class="mathjax-container">\[c(t)=(r*h)(t)=\int_{0}^{t} r(\tau)\,h(t-\tau)\,d\tau\]</span>
<p>解释：在过去每个时刻 <span displaypfx="inline-" class="mathjax-container">\(\tau\)</span> 滴入的一小部分药量 <span displaypfx="inline-" class="mathjax-container">\(r(\tau)\,d\tau\)</span>，到现在时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 已经“经过了” <span displaypfx="inline-" class="mathjax-container">\(t-\tau\)</span> 的代谢时间，因此它的残留贡献按 <span displaypfx="inline-" class="mathjax-container">\(h(t-\tau)\)</span> 衰减；把所有过去贡献叠加，就得到当前总效果。这正是卷积“用一个响应核去累积历史”的核心语义。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/convolution.jpg"><img class="alignnone size-full wp-image-40805" src="https://blog.gmem.cc/wp-content/uploads/2026/03/convolution.jpg" alt="convolution" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">卷积与互相关（Cross-Correlation）</span></div>
<p>互相关（Cross-Correlation）与卷积的形式非常接近，但没有“核翻转”（Kernel Flip）：一维离散互相关常写成 <span displaypfx="inline-" class="mathjax-container">\(\sum_k x[k]\,w[n+k]\)</span>（不同资料索引略有差异）。深度学习框架里多数“卷积层”实现的是互相关，而不是严格数学卷积；由于卷积核参数是可学习的，翻不翻转并不会改变可表达的函数族，但在信号处理里二者语义不同，需注意约定。</p>
<div class="blog_h2"><span class="graybg">链式法则</span></div>
<p>链式法则（Chain Rule）是反向传播（Backpropagation）的核心：当计算图（Computation Graph）由一系列函数复合组成时，总导数等于沿路径的局部导数相乘。</p>
<p>在微分语言下更紧凑：若 <span displaypfx="inline-" class="mathjax-container">\(y=g(x)\)</span>、<span displaypfx="inline-" class="mathjax-container">\(z=f(y)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(dz=f'(y)\,dy\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(dy=g'(x)\,dx\)</span>，合并得 <span displaypfx="inline-" class="mathjax-container">\(dz=f'(g(x))g'(x)\,dx\)</span>。反向传播做的就是把这些局部“系数”从输出一路乘回输入。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/rule-of-chains.jpg"><img class="alignnone size-full wp-image-40809" src="https://blog.gmem.cc/wp-content/uploads/2026/03/rule-of-chains.jpg" alt="rule-of-chains" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">最小二乘法</span></div>
<p>最小二乘法（Least Squares）解决“拟合误差最小”的问题。给定样本矩阵 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 与目标 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span>，线性模型 <span displaypfx="inline-" class="mathjax-container">\(\hat{\mathbf{y}}=X\mathbf{w}\)</span> 的目标是：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w}}\ \|X\mathbf{w}-\mathbf{y}\|_2^2\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(X^\top X\)</span> 可逆时，闭式解满足正规方程（Normal Equations）：</p>
<span displaypfx="" class="mathjax-container">\[X^\top X\mathbf{w}=X^\top\mathbf{y},\quad \mathbf{w}^*=(X^\top X)^{-1}X^\top\mathbf{y}\]</span>
<p>几何直觉： <span displaypfx="inline-" class="mathjax-container">\(X\mathbf{w}\)</span> 只能落在 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 的列空间（Column Space）中，最小二乘解就是把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 正交投影（Orthogonal Projection）到这个空间上，剩余误差向量与列空间正交。</p>
<p>在 AI/统计实践中，若 <span displaypfx="inline-" class="mathjax-container">\(X^\top X\)</span> 病态或奇异，常用岭回归（Ridge Regression）：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w}}\ \|X\mathbf{w}-\mathbf{y}\|_2^2+\lambda\|\mathbf{w}\|_2^2,\quad (X^\top X+\lambda I)\mathbf{w}=X^\top\mathbf{y}\]</span>
<p>它本质上是把二次目标做稳定化，降低方差并提升泛化。</p>
<div class="blog_h2"><span class="graybg">凸函数与凸优化</span></div>
<p>凸函数（Convex Function）是“碗状”的函数：任意两点连线上的函数值不超过端点函数值的线性插值。形式化定义：</p>
<span displaypfx="" class="mathjax-container">\[f(\lambda x_1+(1-\lambda)x_2)\le \lambda f(x_1)+(1-\lambda)f(x_2),\quad \lambda\in[0,1]\]</span>
<p>凸优化（Convex Optimization）之所以重要，是因为凸目标在凸可行域上没有“坏局部最小值”：任一局部最小值都是全局最小值。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 二阶可导，则 Hessian 提供了最直接的局部判据：在一维中 <span displaypfx="inline-" class="mathjax-container">\(f''(x)\ge 0\)</span> 对应凸；在多维中，若对所有 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 都有 <span displaypfx="inline-" class="mathjax-container">\(\nabla^2 f(x)\succeq 0\)</span>（半正定，Positive Semidefinite），则 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 是凸函数。若处处 <span displaypfx="inline-" class="mathjax-container">\(\nabla^2 f(x)\succ 0\)</span>（正定，Positive Definite），则函数通常具有更强的严格凸性（Strict Convexity）。</p>
<p>损失函数的形状可以非常多样。需要区分两层含义：</p>
<ul>
<li>损失形式本身：例如 BCE/CE（对概率或对线性模型的 logits）是凸的，MSE 也是凸的。</li>
<li>对参数的整体目标：当把损失与深度网络的非线性参数化组合后，目标函数通常变成非凸（Non-convex），这不是“交叉熵不凸”，而是“网络映射让问题不凸”。</li>
</ul>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/loss-fn-convex.jpg"><img class="alignnone size-full wp-image-40813" src="https://blog.gmem.cc/wp-content/uploads/2026/03/loss-fn-convex.jpg" alt="loss-fn-convex" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">拉格朗日乘数法</span></div>
<p>拉格朗日乘数法（Lagrange Multiplier Method）处理约束优化（Constrained Optimization）：在满足约束的前提下最小化/最大化目标函数。常用写法是把约束写成函数形式：等式约束（Equality Constraints）<span displaypfx="inline-" class="mathjax-container">\(g_i(x)=0\)</span>，不等式约束（Inequality Constraints）<span displaypfx="inline-" class="mathjax-container">\(g_i(x)\le 0\)</span>。</p>
<p>对等式约束，构造拉格朗日函数（Lagrangian）：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(x,\lambda)=f(x)+\sum_{i=1}^{m}\lambda_i g_i(x)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 是目标函数（Objective），<span displaypfx="inline-" class="mathjax-container">\(g_i(x)\)</span> 是约束函数（Constraint Function），<span displaypfx="inline-" class="mathjax-container">\(\lambda_i\)</span> 是拉格朗日乘子（Lagrange Multiplier）。新增部分 <span displaypfx="inline-" class="mathjax-container">\(\sum_i \lambda_i g_i(x)\)</span> 称为拉格朗日项（Lagrange Term），单个约束对应的项是 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i g_i(x)\)</span>。</p>
<p>在不同应用里乘子的记号可能不同：在优化理论里常写 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i\)</span>，在支持向量机（SVM）里常写 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span>（每个样本约束对应一个乘子）。</p>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}(x,\lambda)\)</span> 看作关于原变量 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 与乘子 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 的二元函数后，鞍点（Saddle Point）结构是约束被编码进目标函数后的直接结果。 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的角色是压低总代价， <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 的角色是放大任何尚未满足的约束，因此两者天然形成极小极大（Min-Max）结构。</p>
<p>先看等式约束 <span displaypfx="inline-" class="mathjax-container">\(g(x)=0\)</span>。这里必须先把“为什么内层是最大化”说清楚：这不是记号习惯，而是为了把约束编码成一个<span style="background-color: #c0c0c0;">可行点保留原目标、不可行点直接罚到 <span displaypfx="inline-" class="mathjax-container">\(+\infty\)</span></span> 的机制。因此，固定某个 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 后，内层写成 <span displaypfx="inline-" class="mathjax-container">\(\max_\lambda \, (f(x)+\lambda g(x))\)</span>。如果 <span displaypfx="inline-" class="mathjax-container">\(g(x)\neq 0\)</span>，由于 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 不受符号限制，最大化者总能把 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 取到与 <span displaypfx="inline-" class="mathjax-container">\(g(x)\)</span> 同号且绝对值任意大，使 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}(x,\lambda)\to\infty\)</span>；只有当 <span displaypfx="inline-" class="mathjax-container">\(g(x)=0\)</span> 时，拉格朗日项才消失，内层最大值才退化为 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span>。因此 <span displaypfx="inline-" class="mathjax-container">\(\max_\lambda \mathcal{L}(x,\lambda)\)</span> 的作用，是把所有不满足等式约束的点直接排除掉。</p>
<p>不等式约束更容易看出这种“过滤”机制。若约束是 <span displaypfx="inline-" class="mathjax-container">\(g(x)\le 0\)</span> 且乘子满足 <span displaypfx="inline-" class="mathjax-container">\(\lambda\ge 0\)</span>，那么当 <span displaypfx="inline-" class="mathjax-container">\(g(x)&gt;0\)</span> 时，最大化者会把 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 推大，使 <span displaypfx="inline-" class="mathjax-container">\(\lambda g(x)\to\infty\)</span>；当 <span displaypfx="inline-" class="mathjax-container">\(g(x)\le 0\)</span> 时，继续增大 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 只会让值变小，因此最优选择是 <span displaypfx="inline-" class="mathjax-container">\(\lambda=0\)</span>。于是 <span displaypfx="inline-" class="mathjax-container">\(\max_{\lambda\ge 0}\mathcal{L}(x,\lambda)\)</span> 对可行点返回原始代价 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span>，对不可行点返回“无限罚款”。外层再对 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 做最小化，就等价于只在可行域内最小化 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span>。</p>
<span displaypfx="" class="mathjax-container">\[\min_x\ \max_\lambda\ \mathcal{L}(x,\lambda)\]</span>
<p>几何上，这正对应马鞍形曲面：沿 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 方向切开， <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span> 像一个向上开的“碗”，因为这里在做最小化；沿 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 方向切开， <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span> 像一个向下开的“拱”，因为这里在做最大化。鞍点 <span displaypfx="inline-" class="mathjax-container">\((x^*,\lambda^*)\)</span> 的含义是：固定 <span displaypfx="inline-" class="mathjax-container">\(\lambda=\lambda^*\)</span> 时， <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 已经不能再把值压低；固定 <span displaypfx="inline-" class="mathjax-container">\(x=x^*\)</span> 时， <span displaypfx="inline-" class="mathjax-container">\(\lambda^*\)</span> 也已经不能再把值抬高。原问题的最优解与约束的恰当作用强度，就在这个交点同时确定。</p>
<p>记号约定：上标星号（Asterisk）<span displaypfx="inline-" class="mathjax-container">\(^*\)</span> 通常表示“最优/最优点处的取值”，例如 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 是最优解， <span displaypfx="inline-" class="mathjax-container">\(\lambda^*\)</span> 是与之对应的最优乘子。</p>
<p>在鞍点 <span displaypfx="inline-" class="mathjax-container">\((x^*,\lambda^*)\)</span>，固定 <span displaypfx="inline-" class="mathjax-container">\(\lambda=\lambda^*\)</span> 时 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 使 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span> 最小；固定 <span displaypfx="inline-" class="mathjax-container">\(x=x^*\)</span> 时 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*\)</span> 使 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span> 最大，可用不等式写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(x^*,\lambda)\le \mathcal{L}(x^*,\lambda^*)\le \mathcal{L}(x,\lambda^*)\]</span>
<p>上面的鞍点不等式给出的是几何刻画；真正求解时，还需要把“这个点已经不能再降、也不能再升”的直观条件改写成可计算的方程。做法是检查拉格朗日函数（Lagrangian）<span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span> 对各个变量的一阶变化：若在候选点附近，沿某个允许方向还能继续把值压低（对 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>）或继续把值抬高（对 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span>），那么该点就不可能是鞍点。</p>
<p>这就是一阶必要条件（First-Order Necessary Condition）的来源：若 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 是最优解，则一阶（线性）变化项必须已经消失，否则还存在继续改进的方向。这里“必要”不等于“充分”：满足一阶条件只说明它有可能是最优点，不说明它一定最优；要得到充分结论，还需要二阶条件、凸性（Convexity）或其他结构。</p>
<p>无约束情形最直接。在一维中，若 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 是可微且不在边界上的局部最小点，则向左或向右移动都不能让函数更小，因此斜率必须满足 <span displaypfx="inline-" class="mathjax-container">\(f'(x^*)=0\)</span>；多维里把“斜率”推广为梯度（Gradient），于是条件变为 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x^*)=0\)</span>。</p>
<p>把同样的思路移到等式约束上，求解目标就从“找原函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 的极小点”变成“找拉格朗日函数 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}(x,\lambda)\)</span> 的鞍点”。对可导问题，一阶条件写成：</p>
<span displaypfx="" class="mathjax-container">\[\nabla_x \mathcal{L}(x^*,\lambda^*)=0,\quad g_i(x^*)=0\]</span>
<p>第一式表示：固定 <span displaypfx="inline-" class="mathjax-container">\(\lambda=\lambda^*\)</span> 后，已经找不到能继续降低 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 方向；第二式就是可行性（Feasibility）本身。又因为 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial \lambda_i}=g_i(x)\)</span>，所以“对乘子求偏导为 0”本质上只是把约束 <span displaypfx="inline-" class="mathjax-container">\(g_i(x)=0\)</span> 原样写回。</p>
<p>不等式约束时，乘子必须满足 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i\ge 0\)</span>，因此鞍点结构相应写成：</p>
<span displaypfx="" class="mathjax-container">\[\min_x\ \max_{\lambda\ge 0}\ \mathcal{L}(x,\lambda)\]</span>
<p>此时除了驻点条件，还必须同时满足 KKT 条件（Karush–Kuhn–Tucker Conditions）。其中最关键的是互补松弛（Complementary Slackness）<span displaypfx="inline-" class="mathjax-container">\(\lambda_i g_i(x^*)=0\)</span>：每个约束在最优点只有两种状态——要么它是紧的（Active），即 <span displaypfx="inline-" class="mathjax-container">\(g_i(x^*)=0\)</span>；要么它不紧，此时对应乘子必须为 0，表示该约束在最优点处没有实际作用。</p>
<p>例（等式约束）：最小化 <span displaypfx="inline-" class="mathjax-container">\(f(x,y)=x^2+y^2\)</span>，约束 <span displaypfx="inline-" class="mathjax-container">\(x+y=1\)</span>。拉格朗日函数：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(x,y,\lambda)=x^2+y^2+\lambda(x+y-1)\]</span>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-lagrange.png"><img class="alignnone size-full wp-image-40821" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-lagrange.png" alt="plot-lagrange" width="100%" /></a></p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\lambda(x+y-1)\)</span> 是拉格朗日项（Lagrange Term）：它把等式约束 <span displaypfx="inline-" class="mathjax-container">\(x+y-1=0\)</span> 以乘子加权的形式并入目标函数。</p>
<p>令偏导为零： <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial x}=2x+\lambda=0\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial y}=2y+\lambda=0\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial \lambda}=x+y-1=0\)</span>。注意 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial \lambda}=0\)</span> 就是把约束 <span displaypfx="inline-" class="mathjax-container">\(x+y=1\)</span> 写回来；联立可得 <span displaypfx="inline-" class="mathjax-container">\(x=y=\frac{1}{2}\)</span>。几何上，这意味着最优点处目标函数等高线与约束曲线相切。</p>
<p>例（不等式约束）：最小化 <span displaypfx="inline-" class="mathjax-container">\(f(x)=x^2\)</span>，约束 <span displaypfx="inline-" class="mathjax-container">\(x\ge 1\)</span>。直觉上，无约束最小值在 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span>，但它不满足约束，所以最优点只能落在边界 <span displaypfx="inline-" class="mathjax-container">\(x=1\)</span>。</p>
<ol>
<li>把约束改写成标准形式：令 <span displaypfx="inline-" class="mathjax-container">\(g(x)=1-x\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(x\ge 1\Leftrightarrow g(x)\le 0\)</span>。</li>
<li>构造拉格朗日函数： <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}(x,\lambda)=f(x)+\lambda g(x)=x^2+\lambda(1-x)\)</span>。其中 <span displaypfx="inline-" class="mathjax-container">\(\lambda(1-x)\)</span> 是拉格朗日项（Lagrange Term），不等式情形要求 <span displaypfx="inline-" class="mathjax-container">\(\lambda\ge 0\)</span>。</li>
<li>写出 KKT 的核心条件：可行性（Feasibility）<span displaypfx="inline-" class="mathjax-container">\(1-x\le 0\)</span>；乘子非负 <span displaypfx="inline-" class="mathjax-container">\(\lambda\ge 0\)</span>；驻点（Stationarity）<span displaypfx="inline-" class="mathjax-container">\(\frac{d\mathcal{L}}{dx}=2x-\lambda=0\)</span>；互补松弛（Complementary Slackness）<span displaypfx="inline-" class="mathjax-container">\(\lambda(1-x)=0\)</span>。</li>
<li>解：由 <span displaypfx="inline-" class="mathjax-container">\(2x-\lambda=0\)</span> 得 <span displaypfx="inline-" class="mathjax-container">\(\lambda=2x\)</span>。互补松弛要求 <span displaypfx="inline-" class="mathjax-container">\(\lambda=0\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(x=1\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(\lambda=0\)</span> 则 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span>，但违反 <span displaypfx="inline-" class="mathjax-container">\(x\ge 1\)</span>；因此 <span displaypfx="inline-" class="mathjax-container">\(x^*=1\)</span>，进而 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*=2\)</span>。</li>
<li>解释：在最优点处 <span displaypfx="inline-" class="mathjax-container">\(1-x^*=0\)</span>，该约束是“紧的”（Active），互补松弛允许乘子 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*&gt;0\)</span>，它刻画了该约束在最优点处对解的影响强度。如果最优点落在可行域（Feasible Set）内部（约束不紧， <span displaypfx="inline-" class="mathjax-container">\(1-x^*&lt;0\)</span>），互补松弛会强制 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*=0\)</span>。</li>
</ol>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-lagrange-2.png"><img class="alignnone size-full wp-image-40825" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-lagrange-2.png" alt="plot-lagrange-2" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">对偶问题</span></div>
<p>对偶问题（Dual Problem）从原始问题（Primal Problem）导出下界函数，用于分析可行性、间隙与最优性。标准形式：</p>
<span displaypfx="" class="mathjax-container">\[\min_x\ f(x)\ \text{s.t.}\ g_i(x)\le 0,\ h_j(x)=0\]</span>
<span displaypfx="" class="mathjax-container">\[g(\lambda,\nu)=\inf_x\Big(f(x)+\sum_i \lambda_i g_i(x)+\sum_j \nu_j h_j(x)\Big),\ \lambda_i\ge 0\]</span>
<span displaypfx="" class="mathjax-container">\[\max_{\lambda,\nu}\ g(\lambda,\nu)\ \text{s.t.}\ \lambda\ge 0\]</span>
<p>把括号中的表达式记为拉格朗日函数（Lagrangian）<span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}(x,\lambda,\nu)=f(x)+\sum_i \lambda_i g_i(x)+\sum_j \nu_j h_j(x)\)</span>，则对偶函数（Dual Function）就是 <span displaypfx="inline-" class="mathjax-container">\(g(\lambda,\nu)=\inf_x \mathcal{L}(x,\lambda,\nu)\)</span>：固定乘子后先对原变量求下确界，把它“消去”，再对乘子最大化这个下界。</p>
<div class="blog_h4"><span class="graybg">例：SVM 对偶函数是怎么把变量“消掉”的</span></div>
<p>“对偶（Dual）”不是凭空再造一个新问题，而是从同一个原始问题（Primal Problem）的拉格朗日函数（Lagrangian）出发，先对原变量取下确界（Infimum），再只对乘子变量做优化后得到的伴随问题。在线性支持向量机（Support Vector Machine, SVM）里，这个过程最清楚，因为 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\alpha}\)</span> 的出现既解释了“为什么叫对偶”，也把“把原变量消掉”变成了可逐步计算的代换。</p>
<p>考虑硬间隔（Hard-margin）线性 SVM。原始问题是：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ \frac{1}{2}\|\mathbf{w}\|_2^2\quad \text{s.t.}\quad y_i(\mathbf{w}^\top \mathbf{x}_i+b)-1\ge 0,\ i=1,\dots,n\]</span>
<p>把约束改写成标准不等式形式：</p>
<span displaypfx="" class="mathjax-container">\[g_i(\mathbf{w},b)=1-y_i(\mathbf{w}^\top \mathbf{x}_i+b)\le 0\]</span>
<p>于是拉格朗日函数为：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(\mathbf{w},b,\boldsymbol{\alpha})=\frac{1}{2}\|\mathbf{w}\|_2^2+\sum_{i=1}^{n}\alpha_i\big(1-y_i(\mathbf{w}^\top \mathbf{x}_i+b)\big),\quad \alpha_i\ge 0\]</span>
<p>对偶函数（Dual Function）的定义是：固定乘子 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\alpha}\)</span>，对原变量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 取下确界：</p>
<span displaypfx="" class="mathjax-container">\[g(\boldsymbol{\alpha})=\inf_{\mathbf{w},b}\mathcal{L}(\mathbf{w},b,\boldsymbol{\alpha})\]</span>
<p>这里的关键结构已经出现了：原始问题直接优化 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span>，而对偶函数先把 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\alpha}\)</span> 固定住，把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 当成待消去变量。随后得到的最大化问题，只剩乘子变量 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\alpha}\)</span>。这就是“对偶”的来源：<span style="background-color: #c0c0c0;">同一个最优化问题，被改写成了另一组变量上的伴随优化问题</span>。</p>
<p>现在把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 完整消掉。先求驻点条件（Stationarity Conditions）。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 求偏导：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial \mathbf{w}}=\mathbf{w}-\sum_{i=1}^{n}\alpha_i y_i \mathbf{x}_i=\mathbf{0}\]</span>
<p>因此</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{w}=\sum_{i=1}^{n}\alpha_i y_i \mathbf{x}_i\]</span>
<p>对 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 求偏导：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial b}=-\sum_{i=1}^{n}\alpha_i y_i=0\]</span>
<p>因此</p>
<span displaypfx="" class="mathjax-container">\[\sum_{i=1}^{n}\alpha_i y_i=0\]</span>
<p>把这些结果代回拉格朗日函数之前，先把它展开成便于逐项代换的形式：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(\mathbf{w},b,\boldsymbol{\alpha})=\frac{1}{2}\mathbf{w}^\top\mathbf{w}+\sum_{i=1}^{n}\alpha_i-\sum_{i=1}^{n}\alpha_i y_i\,\mathbf{w}^\top\mathbf{x}_i-b\sum_{i=1}^{n}\alpha_i y_i\]</span>
<p>第一项代入 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}=\sum_{i=1}^{n}\alpha_i y_i \mathbf{x}_i\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\frac{1}{2}\mathbf{w}^\top\mathbf{w}=\frac{1}{2}\Big(\sum_{i=1}^{n}\alpha_i y_i \mathbf{x}_i\Big)^\top\Big(\sum_{j=1}^{n}\alpha_j y_j \mathbf{x}_j\Big)\]</span>
<span displaypfx="" class="mathjax-container">\[=\frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_i^\top\mathbf{x}_j\]</span>
<p>第二项保持不变：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{i=1}^{n}\alpha_i\]</span>
<p>第三项代入同一个 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 表达式：</p>
<span displaypfx="" class="mathjax-container">\[-\sum_{i=1}^{n}\alpha_i y_i\,\mathbf{w}^\top\mathbf{x}_i=-\sum_{i=1}^{n}\alpha_i y_i\Big(\sum_{j=1}^{n}\alpha_j y_j \mathbf{x}_j\Big)^\top\mathbf{x}_i\]</span>
<span displaypfx="" class="mathjax-container">\[=-\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_j^\top\mathbf{x}_i\]</span>
<span displaypfx="" class="mathjax-container">\[=-\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_i^\top\mathbf{x}_j\]</span>
<p>最后一项利用 <span displaypfx="inline-" class="mathjax-container">\(\sum_{i=1}^{n}\alpha_i y_i=0\)</span> 直接消失：</p>
<span displaypfx="" class="mathjax-container">\[-b\sum_{i=1}^{n}\alpha_i y_i=0\]</span>
<p>因此</p>
<span displaypfx="" class="mathjax-container">\[g(\boldsymbol{\alpha})=\frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_i^\top\mathbf{x}_j+\sum_{i=1}^{n}\alpha_i-\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_i^\top\mathbf{x}_j\]</span>
<span displaypfx="" class="mathjax-container">\[=\sum_{i=1}^{n}\alpha_i-\frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_i^\top\mathbf{x}_j\]</span>
<p>于是对偶问题（Dual Problem）就是：</p>
<span displaypfx="" class="mathjax-container">\[\max_{\boldsymbol{\alpha}}\ g(\boldsymbol{\alpha})\quad \text{s.t.}\quad \alpha_i\ge 0,\ \sum_{i=1}^{n}\alpha_i y_i=0\]</span>
<p>此时“对偶”二字的含义就精确了：原始问题在参数空间里直接求 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span>，对偶问题则在乘子空间里求 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\alpha}\)</span>；两者来自同一个拉格朗日结构，描述的是同一个最优解的两种表示。对 SVM 而言，更重要的结构变化是：数据 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i\)</span> 不再单独出现，而只通过内积（Inner Product）<span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i^\top\mathbf{x}_j\)</span> 进入目标函数，这正是核技巧（Kernel Trick）的入口。</p>
<p>若问题是凸的且满足 Slater 条件，则强对偶（Strong Duality）成立，可以用鞍点形式表达“先最小化再最大化”与“先最大化再最小化”等价：</p>
<span displaypfx="" class="mathjax-container">\[\min_x\max_{\lambda\ge 0,\nu}\mathcal{L}(x,\lambda,\nu)=\max_{\lambda\ge 0,\nu}\min_x\mathcal{L}(x,\lambda,\nu)\]</span>
<p>对偶函数之所以是“下界”，来自一个简单但关键的不等式：对任意可行解 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>（满足所有约束）与任意 <span displaypfx="inline-" class="mathjax-container">\(\lambda\ge 0\)</span>，都有</p>
<span displaypfx="" class="mathjax-container">\[f(x)\ge f(x)+\sum_i \lambda_i g_i(x)+\sum_j \nu_j h_j(x)\ge \inf_{x'}\Big(f(x')+\sum_i \lambda_i g_i(x')+\sum_j \nu_j h_j(x')\Big)=g(\lambda,\nu)\]</span>
<p>因此对任何 <span displaypfx="inline-" class="mathjax-container">\((\lambda,\nu)\)</span>，都有 <span displaypfx="inline-" class="mathjax-container">\(g(\lambda,\nu)\le p^*\)</span>（原始最优值），这就是弱对偶（Weak Duality）。</p>
<p>弱对偶（Weak Duality）总成立；也就是说，对偶问题给出的值永远不会高于原始问题的最优值。但弱对偶只保证“对偶值是下界”，并不保证这个下界恰好贴住原始最优值。若两者之间还差一截，这个差距就叫对偶间隙（Duality Gap）。</p>
<p>强对偶（Strong Duality）则更进一步：它要求原始最优值与对偶最优值完全相等，也就是对偶问题不只是给出一个保守下界，而是精确刻画了原问题的最优值。对很多凸优化问题而言，Slater 条件正是让这种“下界刚好贴住最优值”的关键保证。</p>
<p>从直观上看，Slater 条件要求可行域内部真的存在一个严格可行点。这意味着约束系统不是被边界死死卡住的退化结构，而是有真实的内部空间。可行域一旦有内部，拉格朗日乘子就更容易稳定地描述“目标函数下降趋势”和“约束反作用力”之间的平衡，因此原始问题与对偶问题之间更不容易出现缝隙。在凸优化里，这就是为什么 Slater 条件常被看作强对偶成立的重要通行证。</p>
<p>直观上，对偶变量（Dual Variables）可以看作约束的“影子价格（Shadow Price）”：如果把约束放宽一点点，最优目标值会如何变化。在很多问题中（例如支持向量机（SVM）），对偶化会把优化变量从“模型参数”转成“约束乘子”，并把数据依赖压缩为内积，从而自然导出核技巧（Kernel Trick）。</p>
<div class="blog_h4"><span class="graybg">影子价格（Shadow Price）</span></div>
<p>对偶变量（Dual Variable）常被称为影子价格（Shadow Price），因为它衡量的是：<span style="background-color: #c0c0c0;">如果把某条约束稍微放宽一点，最优目标值会改善多少</span>。这里的“价格”不是市场价格，而是“约束资源有多值钱”的边际刻度。</p>
<p>可以把约束想成一种稀缺资源。例如训练时有显存限制、预算限制、风险限制或几何边界限制；如果某条约束非常紧，那么它就像一个卡脖子的瓶颈。此时只要把这条约束稍微放宽一点，最优目标值就可能明显改善，于是它对应的对偶变量就会比较大。反过来，如果某条约束本来就很松，放宽它也几乎没有收益，那么它对应的对偶变量通常就是 0 或接近 0。</p>
<p>在数学上，这个直觉可以写成一种局部敏感度关系。若把约束写成</p>
<span displaypfx="" class="mathjax-container">\[g_i(x)\le b_i\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(b_i\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 条约束允许的“资源上限”或“边界位置”，那么对应的最优值函数可以记为 <span displaypfx="inline-" class="mathjax-container">\(p^*(b)\)</span>。在适当条件下，对偶变量 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*\)</span> 可以理解为最优值对 <span displaypfx="inline-" class="mathjax-container">\(b_i\)</span> 的边际变化率，也就是“把第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 条约束放宽一点，最优值会朝什么方向变化、变化多快”。</p>
<p>在最小化问题中，若某条约束对应的 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*\)</span> 很大，通常表示这条约束非常关键：它一旦被放宽，最优目标值会明显下降；若 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*=0\)</span>，则说明该约束在当前最优点并没有真正起作用，放宽它也不会立刻带来收益。这与前面的互补松弛完全一致：<span style="background-color: #c0c0c0;">只有真正卡住最优解的约束，才会拥有非零影子价格</span>。</p>
<p>因此，影子价格提供了一个非常有用的解释视角：原始变量告诉我们“最优解长什么样”，而对偶变量告诉我们“哪些约束最贵、最紧、最值得被放宽”。这也是为什么在优化理论、经济学和机器学习里，对偶变量不仅是求解工具，也是理解模型结构的重要语言。</p>
<div class="blog_h4"><span class="graybg">Slater 条件</span></div>
<p>Slater 条件（Slater's Condition）是凸优化（Convex Optimization）里最常见的正则性条件（Regularity Condition）之一。它的作用不是改变优化问题本身，而是保证对偶理论能够“工作得很干净”：在满足它时，很多凸问题会满足强对偶（Strong Duality），也就是原始问题最优值与对偶问题最优值相等；同时，KKT 条件也更容易从“必要条件”提升为判定最优性的核心条件。</p>
<p>对标准凸优化问题</p>
<span displaypfx="" class="mathjax-container">\[ \min_x f(x)\quad \text{s.t.}\quad g_i(x)\le 0,\ h_j(x)=0 \]</span>
<p>如果 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 和每个 <span displaypfx="inline-" class="mathjax-container">\(g_i(x)\)</span> 都是凸函数（Convex Function），每个 <span displaypfx="inline-" class="mathjax-container">\(h_j(x)\)</span> 都是仿射函数（Affine Function），那么 Slater 条件要求：<span style="background-color: #c0c0c0;">至少存在一个严格可行点（Strictly Feasible Point）</span> <span displaypfx="inline-" class="mathjax-container">\(\tilde{x}\)</span>，使得所有不等式约束都被“严格满足”，也就是</p>
<span displaypfx="" class="mathjax-container">\[ g_i(\tilde{x})&lt;0,\ \forall i,\qquad h_j(\tilde{x})=0,\ \forall j \]</span>
<p>这里“严格满足”四个字最关键。普通可行点只要求 <span displaypfx="inline-" class="mathjax-container">\(g_i(x)\le 0\)</span>，也就是允许刚好压在边界上；而 Slater 条件要求存在某个点，能让所有不等式约束都留出一点余量，也就是完全处在可行域内部，而不是贴着边界。</p>
<p>这个条件可以用一个非常直观的图像来理解。把不等式约束围成的可行域想成一个房间：</p>
<ul>
<li>如果房间内部真的有空间，存在一个点站在房间里，不碰任何墙，这就是满足 Slater 条件。</li>
<li>如果所谓“可行域”其实只是几面墙交出来的一条线、一个角、甚至一个点，根本没有真正的内部空间，那么 Slater 条件通常就不满足。</li>
</ul>
<p>因此，Slater 条件本质上是在说：<span style="background-color: #c0c0c0;">这个凸约束系统不能只是勉强拼出一个边界碎片，而要有真正的内部</span>。一旦内部存在，原始问题和对偶问题之间的间隙通常就会消失，拉格朗日乘子和 KKT 条件也会变得更稳定、更有解释力。</p>
<p>一个简单例子可以把它说清楚。考虑约束</p>
<span displaypfx="" class="mathjax-container">\[x\ge 0\]</span>
<p>写成标准形式是 <span displaypfx="inline-" class="mathjax-container">\(g(x)=-x\le 0\)</span>。这个约束满足 Slater 条件，因为取 <span displaypfx="inline-" class="mathjax-container">\(\tilde{x}=1\)</span> 时，有 <span displaypfx="inline-" class="mathjax-container">\(g(1)=-1&lt;0\)</span>。这说明可行域 <span displaypfx="inline-" class="mathjax-container">\([0,+\infty)\)</span> 不只是边界点 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span>，而是真正包含内部区域。</p>
<p>再看一个不满足 Slater 条件的例子：</p>
<span displaypfx="" class="mathjax-container">\[x^2\le 0\]</span>
<p>由于 <span displaypfx="inline-" class="mathjax-container">\(x^2\)</span> 永远不小于 0，这个约束唯一允许的点只有 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span>。可行域虽然非空，但没有任何点能让 <span displaypfx="inline-" class="mathjax-container">\(x^2&lt;0\)</span> 成立，所以不存在严格可行点，Slater 条件不成立。这样的约束系统只有边界、没有内部。</p>
<p>在机器学习常见的凸问题里，Slater 条件之所以频繁出现，是因为它几乎就是“强对偶成立的通行证”。例如在线性规划、逻辑回归的某些约束变体、支持向量机（SVM）的凸二次规划中，只要能找到一个严格可行点，就通常可以放心地从原始问题走到对偶问题，再用 KKT 条件解释最优解结构。反过来，如果没有 Slater 条件，就可能出现对偶间隙（Duality Gap），也就是对偶最优值严格小于原始最优值，此时只看对偶或只看 KKT 就不一定足够。</p>
<div class="blog_h4"><span class="graybg">概念速查</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">概念</td>
<td style="text-align: center;">它回答的问题</td>
<td style="text-align: center;">最核心的一句话</td>
<td style="text-align: center;">在这一章里的作用</td>
</tr>
</thead>
<tbody>
<tr>
<td>弱对偶（Weak Duality）</td>
<td>对偶问题和原始问题之间至少有什么关系</td>
<td>对偶最优值永远不会高于原始最优值；它天然是一个下界</td>
<td>先建立“对偶为什么有意义”的最低保证</td>
</tr>
<tr>
<td>对偶间隙（Duality Gap）</td>
<td>为什么有时对偶值还不等于原始最优值</td>
<td>如果对偶下界还没贴住原始最优值，两者之间的差就是对偶间隙</td>
<td>解释为什么“有对偶”不等于“对偶已经足够”</td>
</tr>
<tr>
<td>强对偶（Strong Duality）</td>
<td>什么时候对偶问题就足以精确刻画原问题</td>
<td>原始最优值与对偶最优值完全相等，对偶不再只是保守下界</td>
<td>为从原始问题转到对偶问题提供理论正当性</td>
</tr>
<tr>
<td>Slater 条件（Slater's Condition）</td>
<td>凸优化里什么条件有助于强对偶成立</td>
<td>只要可行域内部存在严格可行点，很多凸问题就能消除对偶间隙</td>
<td>说明为什么强对偶和 KKT 在凸问题里经常可用</td>
</tr>
<tr>
<td>影子价格（Shadow Price）</td>
<td>对偶变量到底在解释什么</td>
<td>它衡量“把某条约束放宽一点，最优值会改善多少”</td>
<td>赋予拉格朗日乘子清晰的经济 / 几何解释</td>
</tr>
<tr>
<td>KKT 条件</td>
<td>最优解在约束下必须满足哪些平衡关系</td>
<td>目标函数的下降趋势与约束施加的反作用力在最优点平衡</td>
<td>把可行性、乘子、驻点与互补松弛统一成最优性条件</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">KKT 条件</span></div>
<div class="blog_h4"><span class="graybg">背景和定义</span></div>
<p>KKT 条件（Karush–Kuhn–Tucker Conditions）讨论的是<span style="background-color: #c0c0c0;">带约束优化问题在最优点必须满足什么条件</span>。无约束优化里，常见做法是令梯度（Gradient）为 0；但一旦问题带有不等式约束或等式约束，只看 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x)=0\)</span> 就不够了，因为最优点很可能被约束“顶”在边界上，而不是落在自由空间里的普通驻点。</p>
<p>标准形式写作：</p>
<span displaypfx="" class="mathjax-container">\[\min_x f(x)\quad \text{s.t.}\quad g_i(x)\le 0,\ h_j(x)=0\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 是目标函数（Objective Function），表示希望最小化的量；<span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是优化变量（Optimization Variable）；<span displaypfx="inline-" class="mathjax-container">\(g_i(x)\le 0\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个不等式约束（Inequality Constraint）；<span displaypfx="inline-" class="mathjax-container">\(h_j(x)=0\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个等式约束（Equality Constraint）。KKT 条件给出的就是：当 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 真的是一个最优解时，它与对应的拉格朗日乘子（Lagrange Multipliers）之间必须满足一组相互配合的条件。</p>
<p>在凸优化（Convex Optimization）里，如果问题满足适当的正则性条件，例如 Slater 条件（Slater's Condition），KKT 条件往往不只是必要条件，还可以成为判定最优性的核心工具。在线性规划、二次规划、支持向量机（Support Vector Machine, SVM）和许多机器学习训练问题中，KKT 条件都直接参与求解过程。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/kkt-paraboloid-plane.png"><img class="alignnone size-full wp-image-41393" src="https://blog.gmem.cc/wp-content/uploads/2026/03/kkt-paraboloid-plane.png" alt="kkt-paraboloid-plane" width="2107" height="1134" /></a></p>
<div class="blog_h4"><span class="graybg">图解：四组条件如何逐条理解</span></div>
<p>上图使用的是活跃边界 <span displaypfx="inline-" class="mathjax-container">\(g(x)=1-x_1-x_2=0\)</span>。边界 <span displaypfx="inline-" class="mathjax-container">\(x_1+x_2=1\)</span> 的切向方向可取 <span displaypfx="inline-" class="mathjax-container">\((1,-1)\)</span>；与它垂直的向量都是法向量（Normal Vector），例如 <span displaypfx="inline-" class="mathjax-container">\((1,1)\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\((-1,-1)\)</span>。如果把约束写成函数 <span displaypfx="inline-" class="mathjax-container">\(g(x)=1-x_1-x_2\)</span>，那么梯度 <span displaypfx="inline-" class="mathjax-container">\(\nabla g(x)=(-1,-1)\)</span> 就是一条法向量；它指向 <span displaypfx="inline-" class="mathjax-container">\(g(x)\)</span> 增大的方向，也就是不可行侧。</p>
<p>图中的橙色箭头对应驻点条件（Stationarity）里真正参与平衡的两个向量：目标梯度 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x^*)\)</span> 与乘子加权后的约束法向量 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*\nabla g(x^*)\)</span>。它们在最优点首尾相接后得到 0，于是 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x^*)+\lambda^*\nabla g(x^*)=0\)</span> 不是抽象记号，而是边界最优点上的几何平衡。</p>
<ul>
<li><span style="background-color: #c0c0c0;">原始可行性（Primal Feasibility）</span>：解必须落在可行域内。对这张图而言，可行域是半空间 <span displaypfx="inline-" class="mathjax-container">\(x_1+x_2\ge 1\)</span>，最优点 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 落在它的活跃边界 <span displaypfx="inline-" class="mathjax-container">\(x_1+x_2=1\)</span> 上。</li>
<li><span style="background-color: #c0c0c0;">对偶可行性（Dual Feasibility）</span>：不等式约束的乘子必须非负，即 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*\ge 0\)</span>。图中的计算给出 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*=0.65\)</span>，表示这条边界在最优点处确实对目标下降施加了正的“反作用”。</li>
<li><span style="background-color: #c0c0c0;">驻点条件（Stationarity）</span>：沿边界的切向方向已经不能继续下降，因此目标梯度不再含有切向分量，只能落在法向空间里。单个活跃约束时，这就变成 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x^*)=-\lambda^*\nabla g(x^*)\)</span>。图中的两根橙色箭头正是这两个大小相等、方向相反的向量。</li>
<li><span style="background-color: #c0c0c0;">互补松弛（Complementary Slackness）</span>：约束若不接触最优点，就不会产生乘子；约束一旦卡在最优点上，对应乘子才会变成正值。当前图像展示的是活跃边界情形，所以 <span displaypfx="inline-" class="mathjax-container">\(g(x^*)=0\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*&gt;0\)</span> 同时成立。</li>
</ul>
<p>法向量之所以关键，是因为它刻画了“离开边界最快”的方向；切向方向则刻画了“沿边界滑动”的方向。边界最优点的本质结论是：<span style="background-color: #c0c0c0;">目标函数想继续下降的那一部分趋势，已经完全被约束边界的法向作用抵消；沿边界本身则不存在进一步下降方向</span>。</p>
<div class="blog_h4"><span class="graybg">具像化描述</span></div>
<p>可以把 KKT 条件想成一个“受围栏限制的小球找最低点”的问题。目标函数 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 决定地形：哪里高、哪里低；约束 <span displaypfx="inline-" class="mathjax-container">\(g_i(x)\le 0\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(h_j(x)=0\)</span> 决定活动范围：哪些区域允许进入，哪些区域不允许进入。</p>
<p>如果没有围栏，小球会沿着坡度一路滚到某个最低点，此时常见条件是梯度为 0，也就是周围已经没有继续下降的方向。但有了围栏以后，最低点可能不在地形内部，而在边界上。此时小球还想往更低的地方滚，却被围栏挡住了。KKT 条件刻画的正是这种“<span style="background-color: #c0c0c0;">地形的下降趋势与边界施加的反作用力在最优点达到平衡</span>”的状态。</p>
<p>其中最形象的一条是互补松弛（Complementary Slackness）。它表达的是：每一个不等式约束在最优点都只有两种状态。</p>
<ul>
<li>如果某个约束正好卡在边界上，即 <span displaypfx="inline-" class="mathjax-container">\(g_i(x^*)=0\)</span>，说明这道围栏真的“顶住了”最优解，这时它对应的乘子 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*\)</span> 可以大于 0。</li>
<li>如果某个约束离边界还有余量，即 <span displaypfx="inline-" class="mathjax-container">\(g_i(x^*)&lt;0\)</span>，说明这道围栏根本没有碰到最优解，那么它对应的乘子必须是 0，也就是 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*=0\)</span>。</li>
</ul>
<p>因此，乘子 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*\)</span> 可以直观理解为“约束对最优解施加的压力”。压力不为 0，说明这条约束正在真正影响解；压力为 0，说明这条约束虽然存在，但在最优点处并没有发挥作用。</p>
<div class="blog_h4"><span class="graybg">公式逐元素解释</span></div>
<p>把原问题写成统一形式后，先定义拉格朗日函数（Lagrangian）：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(x,\lambda,\nu)=f(x)+\sum_i \lambda_i g_i(x)+\sum_j \nu_j h_j(x)\]</span>
<p>这个式子里的每个元素都有明确含义：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span>：拉格朗日函数，把目标函数和约束统一写进一个式子里。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(x\)</span>：原始变量（Primal Variable），也就是模型真正要优化的参数。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span>：目标函数，表示希望最小化的代价、损失或能量。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(g_i(x)\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个不等式约束函数；要求它满足 <span displaypfx="inline-" class="mathjax-container">\(g_i(x)\le 0\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(h_j(x)\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个等式约束函数；要求它满足 <span displaypfx="inline-" class="mathjax-container">\(h_j(x)=0\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda_i\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个不等式约束对应的拉格朗日乘子；它衡量该约束的“压力”大小。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\nu_j\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个等式约束对应的拉格朗日乘子。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sum_i\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\sum_j\)</span>：分别表示把所有不等式约束和所有等式约束的影响累加起来。</li>
</ul>
<p>在此基础上，KKT 条件通常写成四组：</p>
<span displaypfx="" class="mathjax-container">\[\text{Primal feasibility: } g_i(x^*)\le 0,\ h_j(x^*)=0\]</span>
<span displaypfx="" class="mathjax-container">\[\text{Dual feasibility: } \lambda_i^*\ge 0\]</span>
<span displaypfx="" class="mathjax-container">\[\text{Stationarity: } \nabla f(x^*)+\sum_i \lambda_i^*\nabla g_i(x^*)+\sum_j \nu_j^*\nabla h_j(x^*)=0\]</span>
<span displaypfx="" class="mathjax-container">\[\text{Complementary slackness: } \lambda_i^* g_i(x^*)=0\]</span>
<p>这四组条件可以逐条理解：</p>
<ul>
<li><span style="background-color: #c0c0c0;">原始可行性（Primal Feasibility）</span>：最优解 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 首先必须是合法的，不能跑到可行域之外。也就是说，所有不等式约束都要满足 <span displaypfx="inline-" class="mathjax-container">\(g_i(x^*)\le 0\)</span>，所有等式约束都要精确满足 <span displaypfx="inline-" class="mathjax-container">\(h_j(x^*)=0\)</span>。</li>
<li><span style="background-color: #c0c0c0;">对偶可行性（Dual Feasibility）</span>：不等式约束的乘子必须非负，即 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*\ge 0\)</span>。这保证了它们表达的是“阻止变量越界的压力”，而不是把解往不可行方向拉过去的反向力量。</li>
<li><span style="background-color: #c0c0c0;">驻点条件（Stationarity）</span>：在最优点处，目标函数的梯度 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x^*)\)</span> 与所有约束梯度加权后的合力必须平衡为 0。这里 <span displaypfx="inline-" class="mathjax-container">\(\nabla f(x^*)\)</span> 表示目标函数在 <span displaypfx="inline-" class="mathjax-container">\(x^*\)</span> 处最陡上升方向；<span displaypfx="inline-" class="mathjax-container">\(\nabla g_i(x^*)\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\nabla h_j(x^*)\)</span> 分别表示约束边界的法向方向；乘子 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\nu_j^*\)</span> 则控制这些方向各自施加多少“反作用力”。这条式子本质上是在说：最优点处已经不存在任何仍然可行且还能继续下降的方向。</li>
<li><span style="background-color: #c0c0c0;">互补松弛（Complementary Slackness）</span>：每个不等式约束都满足 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^* g_i(x^*)=0\)</span>。因为这是两个量的乘积等于 0，所以只能出现两种情况：要么 <span displaypfx="inline-" class="mathjax-container">\(g_i(x^*)=0\)</span>，说明约束正好顶在边界上；要么 <span displaypfx="inline-" class="mathjax-container">\(\lambda_i^*=0\)</span>，说明这条约束在最优点没有施加压力。它精确区分了“活跃约束（Active Constraint）”和“不活跃约束（Inactive Constraint）”。</li>
</ul>
<p>一个一维例子可以把这些符号落到实处。考虑：</p>
<span displaypfx="" class="mathjax-container">\[\min_x\ (x+1)^2\quad \text{s.t.}\quad x\ge 0\]</span>
<p>把约束写成标准形式 <span displaypfx="inline-" class="mathjax-container">\(g(x)=-x\le 0\)</span>，于是拉格朗日函数是：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(x,\lambda)=(x+1)^2+\lambda(-x)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\((x+1)^2\)</span> 是目标函数；<span displaypfx="inline-" class="mathjax-container">\(-x\le 0\)</span> 是不等式约束；<span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 是这条约束对应的乘子。KKT 条件变成：</p>
<span displaypfx="" class="mathjax-container">\[x\ge 0,\quad \lambda\ge 0,\quad 2(x+1)-\lambda=0,\quad \lambda x=0\]</span>
<p>这四项分别表示：解必须在 <span displaypfx="inline-" class="mathjax-container">\(x\ge 0\)</span> 的合法区域内；乘子必须非负；目标函数与约束施加的作用力在最优点平衡；约束要么卡住解、要么不施加压力。无约束时， <span displaypfx="inline-" class="mathjax-container">\((x+1)^2\)</span> 的最低点在 <span displaypfx="inline-" class="mathjax-container">\(x=-1\)</span>，但这不满足 <span displaypfx="inline-" class="mathjax-container">\(x\ge 0\)</span>，所以真正最优点被“推”到边界 <span displaypfx="inline-" class="mathjax-container">\(x^*=0\)</span>；再代入驻点条件 <span displaypfx="inline-" class="mathjax-container">\(2(x+1)-\lambda=0\)</span>，得到 <span displaypfx="inline-" class="mathjax-container">\(\lambda^*=2\)</span>。这正对应“边界在最优点处确实对解施加了压力”。</p>
<p>如果把目标函数换成 <span displaypfx="inline-" class="mathjax-container">\(\min_x (x-2)^2\ \text{s.t.}\ x\ge 0\)</span>，无约束最优点就是 <span displaypfx="inline-" class="mathjax-container">\(x=2\)</span>，它本来就在可行域内部，因此约束没有真正碰到最优点。此时 KKT 会给出 <span displaypfx="inline-" class="mathjax-container">\(x^*=2\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\lambda^*=0\)</span>。这就是互补松弛最直观的含义：<span style="background-color: #c0c0c0;">边界没有碰到解，压力就自动消失；边界一旦碰到解，乘子就会变成非零</span>。</p>
<div class="blog_h1"><span class="graybg">概率论与统计</span></div>
<p>概率论与统计处理两类问题：第一类是<span style="background-color: #c0c0c0;">在不确定性下如何描述事件、变量与数据生成过程</span>；第二类是<span style="background-color: #c0c0c0;">在只观察到有限样本时，如何反推总体规律与模型参数</span>。在 AI 中，分类概率、回归噪声、采样、似然训练、置信评估与后验推断都建立在这套语言之上。</p>
<div class="blog_h2"><span class="graybg">基础概念</span></div>
<p>在进入公式前，先区分几个最常混淆的基础对象。概率论不是一开始就讨论“均值”和“方差”，而是先规定<span style="background-color: #c0c0c0;">随机试验的结果空间、结果上的事件，以及把结果映射成数的随机变量</span>，之后才谈分布、期望和统计推断。</p>
<ul>
<li>样本空间（Sample Space）<span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span>：一次随机试验所有可能结果的集合，其中希腊字母 <span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span> 只是“全部可能结果”的记号。掷骰子时 <span displaypfx="inline-" class="mathjax-container">\(\Omega=\{1,2,3,4,5,6\}\)</span>，表示所有可能点数组成的集合。</li>
<li>事件（Event）：样本空间的子集，表示“哪些结果算作某件事发生”。例如“点数为偶数”对应集合 <span displaypfx="inline-" class="mathjax-container">\(\{2,4,6\}\)</span>；这句话的意思是，只要试验结果落在这个集合里，就说该事件发生。</li>
<li>随机变量（Random Variable）<span displaypfx="inline-" class="mathjax-container">\(X\)</span>：把随机结果映射为数的函数，可写作 <span displaypfx="inline-" class="mathjax-container">\(X:\Omega\to\mathbb{R}\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 是这个函数的名字， <span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span> 是输入端的样本空间，箭头 <span displaypfx="inline-" class="mathjax-container">\(\to\)</span> 表示“映射到”， <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}\)</span> 表示实数集合。也就是说，每个随机结果都会被 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 变成一个实数。掷骰子时最自然的随机变量就是“点数本身”；在机器学习里，输入 <span displaypfx="inline-" class="mathjax-container">\(X\)</span>、标签 <span displaypfx="inline-" class="mathjax-container">\(Y\)</span>、噪声 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 都是随机变量。</li>
<li>概率分布（Probability Distribution）：描述随机变量取不同值的概率规律。离散情形常写作 <span displaypfx="inline-" class="mathjax-container">\(P(X=x)\)</span>，意思是“随机变量 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 恰好取值 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的概率”；连续情形常写作密度 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span>，它不是某一点的概率本身，而是概率密度函数（Probability Density Function, PDF），需要在区间上积分才得到概率。</li>
<li>期望（Expectation）<span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]\)</span>：按概率加权的平均，回答“长期来看这个随机变量的典型水平在哪里”。这里 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}\)</span> 是 expectation 的标准记号，方括号中的 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 表示“对哪个随机变量取期望”。</li>
<li>方差（Variance）<span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(X)\)</span>：围绕期望的波动强度，回答“它通常偏离平均水平多大”。其中 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}\)</span> 是 variance 的记号，括号里的 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 表示“考察哪个随机变量的波动”。</li>
<li>参数（Parameter）与统计量（Statistic）：参数描述总体，例如高斯分布中的 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 表示均值（Mean）， <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 表示方差（Variance）；统计量则由样本计算出来，例如 <span displaypfx="inline-" class="mathjax-container">\(\bar{x}\)</span> 表示样本均值（Sample Mean），上面的横线读作“x bar”，意思是“样本中所有 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的平均值”。统计学习的核心任务之一，就是用统计量去估计未知参数。</li>
</ul>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/probability.jpg"><img class="alignnone size-full wp-image-40841" src="https://blog.gmem.cc/wp-content/uploads/2026/03/probability.jpg" alt="probability" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">似然和概率</span></div>
<p><span style="background-color: #c0c0c0;">似然（Likelihood）和概率（Probability）最容易混淆的原因，在于公式相同却回答的是相反方向的问题。</span> 条件分布 <span displaypfx="inline-" class="mathjax-container">\(p(x|\theta)\)</span> 在统计里经常同时出现在两种语境中：当参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 固定、数据 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 变化时，它表示“在这个模型下观察到不同数据的概率有多大”；当数据 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 固定、参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 变化时，它表示“哪些参数更能解释这份已经观察到的数据”。</p>
<p>因此，概率的读法是<span style="background-color: #c0c0c0;">参数已知，看数据</span>。例如抛硬币模型里，若已知正面概率 <span displaypfx="inline-" class="mathjax-container">\(\theta=0.7\)</span>，那么 10 次里出现 8 次正面的概率是</p>
<span displaypfx="" class="mathjax-container">\[P(X=8|\theta=0.7)={10 \choose 8}0.7^8 0.3^2\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\({10 \choose 8}\)</span> 表示组合数（Binomial Coefficient）：从 10 次试验里选出 8 次作为正面的不同选法一共有多少种。它负责计数“8 次正面可以出现在哪 8 个位置上”，不是额外的概率项。</p>
<p>而 <span displaypfx="inline-" class="mathjax-container">\(0.7^8 0.3^2\)</span> 来自独立重复试验的乘法法则：如果每次抛掷相互独立，且正面概率是 <span displaypfx="inline-" class="mathjax-container">\(0.7\)</span>、反面概率是 <span displaypfx="inline-" class="mathjax-container">\(0.3\)</span>，那么任意一个“8 次正面、2 次反面”的具体序列，其概率都是 8 个 <span displaypfx="inline-" class="mathjax-container">\(0.7\)</span> 与 2 个 <span displaypfx="inline-" class="mathjax-container">\(0.3\)</span> 的乘积，也就是 <span displaypfx="inline-" class="mathjax-container">\(0.7^8 0.3^2\)</span>。</p>
<p>这里被当作变量的是结果 <span displaypfx="inline-" class="mathjax-container">\(X\)</span>；问题是“给定参数，这样的数据是否常见”。这就是通常意义上的概率或概率密度。</p>
<p>似然的读法正好反过来：<span style="background-color: #c0c0c0;">数据已知，看参数</span>。假设现在已经观察到 10 次抛硬币里有 8 次正面，这组数据不再变化；真正变化的是候选参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>。于是同一个模型被改写为似然函数（Likelihood Function）：</p>
<span displaypfx="" class="mathjax-container">\[L(\theta|X=8)=P(X=8|\theta)={10 \choose 8}\theta^8(1-\theta)^2\]</span>
<p>此时 <span displaypfx="inline-" class="mathjax-container">\(L(\theta|x)\)</span> 不是“参数取某个值的概率”，而是一个关于 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的评分函数：哪个 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 让已观察到的数据 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 更容易出现，哪个 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的似然就更大。最大似然估计（Maximum Likelihood Estimation, MLE）做的正是这件事：寻找使 <span displaypfx="inline-" class="mathjax-container">\(L(\theta|x)\)</span> 最大的参数。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/likelihood.png"><img class="alignnone size-full wp-image-40879" src="https://blog.gmem.cc/wp-content/uploads/2026/03/likelihood.png" alt="likelihood" width="100%" /></a></p>
<p>这里有一个必须严格区分的点：<span style="background-color: #c0c0c0;">似然不是参数的概率分布</span>。对固定数据来说， <span displaypfx="inline-" class="mathjax-container">\(L(\theta|x)\)</span> 不要求对 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 积分为 1，也不能直接解释为 <span displaypfx="inline-" class="mathjax-container">\(P(\theta|x)\)</span>。只有在贝叶斯框架里，把似然与先验（Prior） <span displaypfx="inline-" class="mathjax-container">\(p(\theta)\)</span> 相乘并做归一化之后，才得到参数的后验概率（Posterior）：</p>
<span displaypfx="" class="mathjax-container">\[p(\theta|x)=\frac{p(x|\theta)p(\theta)}{p(x)}\]</span>
<p>所以可以把三者关系记成一条清晰的链： <span displaypfx="inline-" class="mathjax-container">\(p(x|\theta)\)</span> 作为“关于 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的函数”时是概率模型；作为“关于 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的函数”时是似然函数；再结合先验并归一化后，才变成参数的后验概率。很多机器学习教材里说“最小化交叉熵等价于最大化对数似然”，本质上就是固定数据后，在参数空间里寻找最能解释样本的模型。</p>
<div class="blog_h3"><span class="graybg">机器学习视角：概率、似然与损失</span></div>
<p>在机器学习模型里，这种“同一个式子，换个视角名字就变”的现象非常常见。设模型写成 <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y|x)\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是输入或上下文， <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 是真实标签或真实 token， <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 是模型参数。</p>
<p>当参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 固定、把输出 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 当作随机变量时， <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y|x)\)</span> 是概率模型：它回答“在这个模型已经定好的前提下，不同输出有多可能”。例如语言模型会对整个词表输出一个条件概率分布，其中 <span displaypfx="inline-" class="mathjax-container">\(c_t\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置之前的上下文（context）， <span displaypfx="inline-" class="mathjax-container">\(y_t\)</span> 表示该位置真实出现的 token，因此 <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y_t|c_t)\)</span> 就是“在上下文 <span displaypfx="inline-" class="mathjax-container">\(c_t\)</span> 下，模型给真实 token <span displaypfx="inline-" class="mathjax-container">\(y_t\)</span> 分配的概率”。</p>
<p>当观测到的 <span displaypfx="inline-" class="mathjax-container">\((x,y)\)</span> 固定、把参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 当作变量时，同一个式子 <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y|x)\)</span> 就变成似然：它回答“哪些参数更能让这条已经看到的数据显得合理”。于是训练时最大化似然，就等价于最小化负对数似然：</p>
<span displaypfx="" class="mathjax-container">\[-\log p_\theta(y|x)\]</span>
<p>因此可以把这个困惑直接拆开：<span style="background-color: #c0c0c0;">固定参数看输出，它是概率；固定输出看参数，它是似然；对它取负对数后，它就是训练里使用的损失项</span>。在语言建模里，对一个具体 token 而言，给定当前模型参数后， <span displaypfx="inline-" class="mathjax-container">\(-\log p_\theta(y_t|c_t)\)</span> 既可以看成该 token 的负对数概率，也可以在训练语境下看成该 token 对参数的负对数似然；两者是同一个数值对象，只是观察角度不同。</p>
<div class="blog_h2"><span class="graybg">基本概率</span></div>
<p>概率（Probability）描述不确定事件发生的可能性。设样本空间（Sample Space）为 <span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span>，事件（Event）为 <span displaypfx="inline-" class="mathjax-container">\(A\subseteq\Omega\)</span>，则概率公理要求 <span displaypfx="inline-" class="mathjax-container">\(P(A)\in[0,1]\)</span>、<span displaypfx="inline-" class="mathjax-container">\(P(\Omega)=1\)</span>，以及互斥事件可加。这里“互斥事件可加”的意思是：如果两个事件不能同时发生，即 <span displaypfx="inline-" class="mathjax-container">\(A\cap B=\{\}\)</span>，那么“<span displaypfx="inline-" class="mathjax-container">\(A\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 发生”的概率就是两者概率直接相加：</p>
<span displaypfx="" class="mathjax-container">\[P(A\cup B)=P(A)+P(B),\quad A\cap B=\{\}\]</span>
<p>之所以能直接相加，是因为两者没有重叠部分，不会重复计数。若有重叠，则不能直接相加，而要减去交集 <span displaypfx="inline-" class="mathjax-container">\(P(A\cap B)\)</span>。</p>
<p>最小例子：掷一枚公平六面骰。样本空间是 <span displaypfx="inline-" class="mathjax-container">\(\Omega=\{1,2,3,4,5,6\}\)</span>。若事件 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 表示“点数为 1”，事件 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 表示“点数为 2”，则它们互斥，因此 <span displaypfx="inline-" class="mathjax-container">\(P(A\cup B)=1/6+1/6=1/3\)</span>。再看一个非互斥例子：若事件 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 表示“点数为偶数”，则 <span displaypfx="inline-" class="mathjax-container">\(C=\{2,4,6\}\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(P(C)=3/6=1/2\)</span>。</p>
<div class="blog_h3"><span class="graybg">联合概率</span></div>
<p>联合概率（Joint Probability）表示多个事件同时发生的概率，即 <span displaypfx="inline-" class="mathjax-container">\(P(A,B)=P(A\cap B)\)</span>。它回答的是“这些条件一起成立的可能性有多大”。</p>
<span displaypfx="" class="mathjax-container">\[P(A,B)=P(A\cap B)\]</span>
<p>仍用骰子例子：令 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 表示“点数为偶数”，<span displaypfx="inline-" class="mathjax-container">\(B\)</span> 表示“点数至少为 4”，则 <span displaypfx="inline-" class="mathjax-container">\(A\cap B=\{4,6\}\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(P(A,B)=2/6=1/3\)</span>。在机器学习里，联合分布 <span displaypfx="inline-" class="mathjax-container">\(p(x,y)\)</span> 表示“输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 与标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 一起出现”的总体规律。</p>
<div class="blog_h3"><span class="graybg">独立性与乘法法则</span></div>
<p>独立（Independence）表示一个事件是否发生，不会改变另一个事件发生的概率。若事件 <span displaypfx="inline-" class="mathjax-container">\(A,B\)</span> 相互独立，则它们同时发生的概率可直接写成概率乘积：</p>
<span displaypfx="" class="mathjax-container">\[P(A\cap B)=P(A)P(B)\]</span>
<p>更一般地，若 <span displaypfx="inline-" class="mathjax-container">\(A_1,\dots,A_n\)</span> 相互独立，则</p>
<span displaypfx="" class="mathjax-container">\[P\!\left(\bigcap_{i=1}^{n}A_i\right)=\prod_{i=1}^{n}P(A_i)\]</span>
<p>例：掷一枚公平硬币并同时掷一枚公平骰子。令 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 表示“硬币为正面”，<span displaypfx="inline-" class="mathjax-container">\(B\)</span> 表示“骰子为偶数”，则 <span displaypfx="inline-" class="mathjax-container">\(P(A)=1/2\)</span>、<span displaypfx="inline-" class="mathjax-container">\(P(B)=1/2\)</span>，且二者独立，因此 <span displaypfx="inline-" class="mathjax-container">\(P(A\cap B)=1/2\times 1/2=1/4\)</span>。</p>
<p>独立时还可等价写成 <span displaypfx="inline-" class="mathjax-container">\(P(A|B)=P(A)\)</span>：知道 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 发生，并不会改变对 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的判断。</p>
<p>要注意：互斥（Mutually Exclusive）和独立（Independent）不是一回事。互斥表示不能同时发生；独立表示是否发生彼此无关。对非零概率事件而言，互斥通常意味着<span style="background-color: #c0c0c0;">不独立</span>，因为一旦知道 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 发生，就立刻知道 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 不可能发生。</p>
<div class="blog_h3"><span class="graybg">条件概率</span></div>
<p>条件概率（Conditional Probability）表示“已知 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 发生时，<span displaypfx="inline-" class="mathjax-container">\(A\)</span> 发生的概率”：</p>
<span displaypfx="" class="mathjax-container">\[P(A|B)=\frac{P(A,B)}{P(B)},\quad P(B)&gt;0\]</span>
<p>关键点不是“再算一次概率”，而是<span style="background-color: #c0c0c0;">样本空间被缩小了</span>。在上面的骰子例子中，已知 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>（点数至少为 4）后，只剩 <span displaypfx="inline-" class="mathjax-container">\(\{4,5,6\}\)</span> 三种可能，其中偶数是 <span displaypfx="inline-" class="mathjax-container">\(\{4,6\}\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(P(A|B)=2/3\)</span>，明显不同于原来的 <span displaypfx="inline-" class="mathjax-container">\(P(A)=1/2\)</span>。</p>
<p>在建模中，几乎所有监督学习目标都可写成条件概率最大化，例如分类模型学习的是 <span displaypfx="inline-" class="mathjax-container">\(P(y|x)\)</span>：给定特征 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 的概率有多大。</p>
<div class="blog_h3"><span class="graybg">边缘概率</span></div>
<p>边缘概率（Marginal Probability）是把不关心的变量“求和/积分掉”后得到的概率：</p>
<span displaypfx="" class="mathjax-container">\[P(A)=\sum_b P(A,b),\quad p(x)=\int p(x,z)\,dz\]</span>
<p>这两个公式的意思完全一致，只是分别对应离散情形与连续情形。 <span displaypfx="inline-" class="mathjax-container">\(P(A,b)\)</span> 表示“<span displaypfx="inline-" class="mathjax-container">\(A\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 同时发生”的联合概率；若现在只关心 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，就必须把所有可能的 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 都加起来。连续情形下同理： <span displaypfx="inline-" class="mathjax-container">\(p(x,z)\)</span> 是关于 <span displaypfx="inline-" class="mathjax-container">\((x,z)\)</span> 的联合密度，若只关心 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，就把所有 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 的可能性沿该维度积分掉，得到 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span>。</p>
<p>几何上，可以把边缘化（Marginalization）理解为<span style="background-color: #c0c0c0;">把高维分布沿某个方向压扁后得到的投影</span>。设二维联合分布的两个维度分别是身高与体重，平面上的每个位置都对应一个联合概率密度 <span displaypfx="inline-" class="mathjax-container">\(p(\text{height},\text{weight})\)</span>。如果现在根本不关心体重，只想知道身高的总体分布，那么就把每个固定身高处、沿着体重方向的所有概率质量全部累加起来；累加后的结果就是该身高对应的边缘密度 <span displaypfx="inline-" class="mathjax-container">\(p(\text{height})\)</span>。</p>
<p>因此，求和符号 <span displaypfx="inline-" class="mathjax-container">\(\sum_b P(A,b)\)</span> 或积分符号 <span displaypfx="inline-" class="mathjax-container">\(\int p(x,z)\,dz\)</span> 的几何含义，不是“神秘地消掉一个变量”，而是<span style="background-color: #c0c0c0;">把那个维度上的所有可能性叠加到剩余维度上</span>。它像从侧面看一个三维物体的投影：原来的结构仍然在，但你只保留了当前关心的坐标轴信息。</p>
<p>在 AI 中，边缘概率几乎总伴随着隐藏变量（Latent Variable）。例如主题模型里 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 可以表示隐藏主题（Hidden Topic），<span displaypfx="inline-" class="mathjax-container">\(x\)</span> 表示某个词；若只关心词出现的总体概率，就要把主题变量消去，得到 <span displaypfx="inline-" class="mathjax-container">\(p(x)=\sum_z p(x,z)\)</span>。很多推断算法的难点，本质上就是这个“沿隐藏维度求和/积分”的步骤代价很高。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/marginzation.jpg"><img class="alignnone size-full wp-image-40833" src="https://blog.gmem.cc/wp-content/uploads/2026/03/marginzation.jpg" alt="marginzation" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">补事件</span></div>
<p>补事件（Complementary Event）满足</p>
<span displaypfx="" class="mathjax-container">\[P(A^c)=1-P(A)\]</span>
<p>它表示“<span displaypfx="inline-" class="mathjax-container">\(A\)</span> 不发生”的概率等于总概率 1 减去 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 发生的概率。</p>
<div class="blog_h3"><span class="graybg">全概率公式</span></div>
<p>第二，若 <span displaypfx="inline-" class="mathjax-container">\(A_1,\dots,A_n\)</span> 把样本空间划分为互斥且完备的几部分（即两两互斥，且并集为 <span displaypfx="inline-" class="mathjax-container">\(\Omega\)</span>），则全概率公式（Law of Total Probability）为</p>
<span displaypfx="" class="mathjax-container">\[P(B)=\sum_{i=1}^{n}P(B|A_i)P(A_i)\]</span>
<p>它的含义是：事件 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 的总概率，可以拆成“先落入哪一种原因 <span displaypfx="inline-" class="mathjax-container">\(A_i\)</span>，再在该原因下发生 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>”的加权和。贝叶斯公式中的分母 <span displaypfx="inline-" class="mathjax-container">\(P(B)\)</span>，很多时候就是用全概率公式展开出来的。</p>
<div class="blog_h2"><span class="graybg">贝叶斯定理</span></div>
<p>贝叶斯定理（Bayes' Theorem）把“从原因到结果”的概率反转为“从结果反推原因”：</p>
<span displaypfx="" class="mathjax-container">\[P(A|B)=\frac{P(B|A)\,P(A)}{P(B)}\]</span>
<p>把式子写出来并不难，真正容易混淆的是每一项到底在说什么。下面直接用一个具体场景来解释：设 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 表示“患者真的患病”，<span displaypfx="inline-" class="mathjax-container">\(B\)</span> 表示“检测结果为阳性”。已知患病率为 1%，检测的灵敏度（Sensitivity）为 99%，假阳性率（False Positive Rate）为 5%。则：</p>
<ul>
<li>先验（Prior） <span displaypfx="inline-" class="mathjax-container">\(P(A)\)</span>：在还没看到这次检测结果前，对“此人患病”的原始判断。在这个例子里，先验就是总体患病率 <span displaypfx="inline-" class="mathjax-container">\(P(A)=0.01\)</span>。</li>
<li>似然（Likelihood） <span displaypfx="inline-" class="mathjax-container">\(P(B|A)\)</span>：如果一个人确实患病，那么检测为阳性的概率有多大。在这个例子里，检测灵敏度是 99%，因此 <span displaypfx="inline-" class="mathjax-container">\(P(B|A)=0.99\)</span>。</li>
<li>证据（Evidence） <span displaypfx="inline-" class="mathjax-container">\(P(B)\)</span>：不管一个人是否患病，检测结果为阳性在总体上出现的概率。它等于“真阳性 + 假阳性”的总和： <span displaypfx="inline-" class="mathjax-container">\(P(B)=0.99\times 0.01 + 0.05\times 0.99 = 0.0594\)</span>。</li>
<li>后验（Posterior） <span displaypfx="inline-" class="mathjax-container">\(P(A|B)\)</span>：在已经看到“检测阳性”这个证据之后，此人真正患病的更新后概率。代入上面的数值，有</li>
</ul>
<span displaypfx="" class="mathjax-container">\[P(\text{disease}|+) = \frac{0.99\times 0.01}{0.99\times 0.01 + 0.05\times 0.99} = \frac{1}{6} \approx 16.7\%\]</span>
<p>因此，贝叶斯定理说的不是“把公式套进去算一下”，而是一个非常具体的更新过程：<span style="background-color: #c0c0c0;">先从原始信念出发，用证据对它重新加权，再归一化，得到更新后的信念</span>。这也是为什么常把它概括成：<span style="background-color: #c0c0c0;">后验 = 似然 × 先验 ÷ 证据</span>。</p>
<p>这个例子最重要的结论是：检测阳性后，真实患病概率并不是 99%，而只有约 16.7%。原因不是检测太差，而是先验患病率本来就很低；假阳性虽然比例不高，但基数更大。换言之，<span style="background-color: #c0c0c0;">证据不会凭空决定结论，证据必须结合基线发生率一起解释</span>。在 AI 里，这就是“看到证据后更新信念”的统一公式：朴素贝叶斯分类、贝叶斯滤波、概率图模型都在做这件事。</p>
<div class="blog_h2"><span class="graybg">概率分布</span></div>
<p>这里的“分布（Distribution）”指随机变量取值的不确定性如何在取值空间上分配。严格地说，概率分布（Probability Distribution）是一种概率测度（Probability Measure），它为每个事件 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 赋予概率 <span displaypfx="inline-" class="mathjax-container">\(P(A)\)</span>。</p>
<p>对一维实值随机变量 <span displaypfx="inline-" class="mathjax-container">\(X\)</span>，分布最常用的统一表述是累积分布函数（Cumulative Distribution Function, CDF）<span displaypfx="inline-" class="mathjax-container">\(F(x)=P(X\le x)\)</span>。离散与连续的差别，主要体现在如何从 <span displaypfx="inline-" class="mathjax-container">\(F\)</span> 得到“点上/区间上”的概率。</p>
<p>不同任务对应不同的数据分布假设（Distribution Assumption）。分布选得对，建模与推断会更稳定；分布假设错得太远，参数估计、置信区间乃至损失函数解释都会失真。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot_six_basic_dist.png"><img class="alignnone size-full wp-image-40921" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot_six_basic_dist.png" alt="plot_six_basic_dist" width="1848" height="1473" /></a></p>
<div class="blog_h3"><span class="graybg">概率密度/概率质量函数</span></div>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 是离散型随机变量，其概率质量函数（Probability Mass Function, PMF）为 <span displaypfx="inline-" class="mathjax-container">\(p(x)=P(X=x)\)</span>，并满足 <span displaypfx="inline-" class="mathjax-container">\(\sum_x p(x)=1\)</span>。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 是连续型随机变量，其概率密度函数（Probability Density Function, PDF）为 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span>，满足 <span displaypfx="inline-" class="mathjax-container">\(p(x)\ge 0\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\int_{-\infty}^{+\infty} p(x)\,dx=1\)</span>；区间概率由积分给出：<span displaypfx="inline-" class="mathjax-container">\(P(a\le X\le b)=\int_a^b p(x)\,dx\)</span>。因此 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span> 本身不是“点上概率”。</p>
<p>两者都可写成同一 CDF 关系：连续情形 <span displaypfx="inline-" class="mathjax-container">\(F(x)=\int_{-\infty}^{x} p(t)\,dt\)</span>，离散情形 <span displaypfx="inline-" class="mathjax-container">\(F(x)=\sum_{t\le x} p(t)\)</span>。</p>
<div class="blog_h3"><span class="graybg">高斯分布（正态分布）</span></div>
<p>高斯分布（Gaussian / Normal Distribution）由均值 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 与方差 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 决定：</p>
<span displaypfx="" class="mathjax-container">\[p(x)=\frac{1}{\sqrt{2\pi}\sigma}\exp\!\left(-\frac{(x-\mu)^2}{2\sigma^2}\right)\]</span>
<p>它的图像是熟悉的“钟形曲线（Bell Curve）”：离 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 越近，概率密度越高；离得越远，概率密度衰减越快。大量独立小扰动叠加后常近似高斯（中心极限定理的结果），所以测量误差、回归残差、传感器噪声、嵌入向量某些方向上的统计近似都常采用高斯模型。</p>
<p>例：若成年男性身高近似服从 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{N}(170,6^2)\)</span>，则 170 cm 附近最常见，而 190 cm 属于离均值超过 3 个标准差的少见样本。回归里假设残差服从高斯，本质上是在说“误差多数小、极大误差少”。</p>
<div class="blog_h3"><span class="graybg">拉普拉斯分布</span></div>
<p>拉普拉斯分布（Laplace Distribution）也是定义在实数轴上的连续分布，常用位置参数（Location） <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 与尺度参数（Scale） <span displaypfx="inline-" class="mathjax-container">\(b&gt;0\)</span> 描述：</p>
<span displaypfx="" class="mathjax-container">\[p(x)=\frac{1}{2b}\exp\left(-\frac{|x-\mu|}{b}\right)\]</span>
<p>它的图像在中心点 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 处更尖、尾部比高斯分布更厚（Heavier Tails）。这表示模型更偏好“大多数误差很小，但偶尔出现相对较大偏差”这一类数据形状，因此它对离群点（Outliers）的容忍度通常高于高斯模型。</p>
<p>拉普拉斯分布和绝对误差（Absolute Error）关系非常紧密：若回归残差假设服从拉普拉斯分布，那么最大似然估计会导向 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 损失；若把参数先验设为拉普拉斯分布，最大后验估计则会导向 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 正则。这也是它在稀疏建模（Sparse Modeling）和鲁棒回归（Robust Regression）里很常见的原因。</p>
<p>例：若某传感器大多数时刻误差接近 0，但偶尔会因为抖动或遮挡产生较大的偏差，那么用拉普拉斯分布描述误差，往往比高斯分布更贴近这种“中心尖、尾部较厚”的统计形状。</p>
<div class="blog_h3"><span class="graybg">伯努利分布</span></div>
<p>伯努利分布（Bernoulli Distribution）描述一次二元结果试验（0/1）：</p>
<span displaypfx="" class="mathjax-container">\[P(X=1)=p,\quad P(X=0)=1-p\]</span>
<p>它是二分类标签建模的最小单元，也是逻辑回归与二分类交叉熵的概率基础。若 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 表示“用户是否点击广告”，则 <span displaypfx="inline-" class="mathjax-container">\(X=1\)</span> 代表点击、<span displaypfx="inline-" class="mathjax-container">\(X=0\)</span> 代表未点击，模型输出的 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 就是点击概率。</p>
<p>伯努利变量的期望是 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]=p\)</span>，方差是 <span displaypfx="inline-" class="mathjax-container">\(p(1-p)\)</span>。因此当 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 接近 0 或 1 时，不确定性反而更小；当 <span displaypfx="inline-" class="mathjax-container">\(p=0.5\)</span> 时，不确定性最大。</p>
<p>如果把同一个伯努利试验独立重复 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 次，并把成功次数加总，那么得到的就不是伯努利分布，而是二项分布（Binomial Distribution）。换句话说，伯努利分布描述“单次是否成功”，二项分布描述“总共成功了多少次”。</p>
<div class="blog_h3"><span class="graybg">二项分布</span></div>
<p>二项分布（Binomial Distribution）描述 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 次独立伯努利试验中的成功次数。若每次成功概率都是 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>，随机变量 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 表示总成功次数，则</p>
<span displaypfx="" class="mathjax-container">\[P(X=k)={n \choose k}p^k(1-p)^{n-k},\quad k=0,1,\dots,n\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\({n \choose k}\)</span> 表示“从 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 次试验里选出 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 次成功”的组合数；后面的 <span displaypfx="inline-" class="mathjax-container">\(p^k(1-p)^{n-k}\)</span> 表示某一种具体排列出现的概率。两者相乘，就得到“恰好成功 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 次”的总概率。</p>
<p>如果 <span displaypfx="inline-" class="mathjax-container">\(X_1,\dots,X_n\)</span> 独立同分布且 <span displaypfx="inline-" class="mathjax-container">\(X_i\sim\mathrm{Bernoulli}(p)\)</span>，那么它们的和</p>
<span displaypfx="" class="mathjax-container">\[S_n=\sum_{i=1}^{n}X_i\sim\mathrm{Binomial}(n,p)\]</span>
<p>因此，二项分布本质上就是“多个伯努利随机变量求和后的分布”。它的期望是 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]=np\)</span>，方差是 <span displaypfx="inline-" class="mathjax-container">\(np(1-p)\)</span>。</p>
<p>例：若一枚硬币正面概率为 <span displaypfx="inline-" class="mathjax-container">\(p=0.7\)</span>，连续抛 10 次，则“正面出现几次”服从二项分布。此时恰好出现 7 次正面的概率是</p>
<span displaypfx="" class="mathjax-container">\[P(X=7)={10 \choose 7}0.7^7 0.3^3\]</span>
<div class="blog_h3"><span class="graybg">多项式分布</span></div>
<p>多项式分布（Multinomial Distribution）是“多类别计数版”的伯努利：进行 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 次独立试验，类别概率为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{p}\)</span>，计数向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{k}\)</span> 的概率为</p>
<span displaypfx="" class="mathjax-container">\[P(\mathbf{k})=\frac{n!}{\prod_i k_i!}\prod_i p_i^{k_i},\quad \sum_i k_i=n\]</span>
<p>若只有一次抽样，通常写作分类分布（Categorical Distribution）：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/distribution-categorical.png"><img class="alignnone size-full wp-image-40867" src="https://blog.gmem.cc/wp-content/uploads/2026/03/distribution-categorical.png" alt="distribution-categorical" width="100%" /></a></p>
<p>若把一次抽样重复 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 次并统计每类出现次数，就得到多项式分布，语言模型里的词频计数、主题模型中的词袋（Bag of Words）都与这种计数视角一致。概率分布一节的第三个图就是多项式分布：总计抽样6次，横轴表示分类1的次数，纵轴表示分类2的次数（因为只有3分类，因此不需要绘制三维），颜色表示抽取到不同分类的次数组合的概率。</p>
<div class="blog_h3"><span class="graybg">泊松分布</span></div>
<p>泊松分布（Poisson Distribution）描述单位时间或单位空间内稀有事件的发生次数：</p>
<span displaypfx="" class="mathjax-container">\[P(X=k)=e^{-\lambda}\frac{\lambda^k}{k!},\quad k=0,1,2,\ldots\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 既是平均发生次数，也是方差。它适用于“事件相互独立、平均速率稳定、短时间内多次同时发生概率很小”的场景，例如单位分钟请求数、单位小时故障数、单位页面点击数。</p>
<p>例：若一个接口平均每分钟收到 3 次请求，则 <span displaypfx="inline-" class="mathjax-container">\(\lambda=3\)</span>。此时一分钟内 0 次请求的概率是 <span displaypfx="inline-" class="mathjax-container">\(e^{-3}\approx 0.05\)</span>；5 次请求的概率是 <span displaypfx="inline-" class="mathjax-container">\(e^{-3}3^5/5!\approx 0.10\)</span>。泊松分布常被用来做“到达数 / 故障数 / 事件数”建模。</p>
<div class="blog_h2"><span class="graybg">期望</span></div>
<p>期望（Expectation）是随机变量的“按概率加权的平均”，回答“长期来看这个量的典型水平在哪里”。离散情形下，它把每个可能取值按对应概率加权；连续情形下，则对概率密度做积分：</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[X]=\sum_x x\,p(x)\ \ (\text{或 } \int x\,p(x)\,dx)\]</span>
<p><span style="background-color: #c0c0c0;">期望和均值很像，但不是同一个概念。</span> 期望（Expectation）是总体分布层面的量，由概率模型 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span> 决定，描述“如果无限次重复抽样，平均会稳定到哪里”；均值（Mean）通常有两种语境：一是把总体的中心位置也叫“总体均值”，这时它与期望是同一个量；二是指有限样本算出来的样本均值（Sample Mean） <span displaypfx="inline-" class="mathjax-container">\(\bar{x}=\frac{1}{n}\sum_{i=1}^{n}x_i\)</span>，这是用样本近似期望的统计量。</p>
<p>因此可以把三者关系记成：<span style="background-color: #c0c0c0;">总体均值 = 期望；样本均值 = 对期望的估计</span>。例如公平骰子点数的期望是 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]=3.5\)</span>；如果你真的掷 6 次，样本均值可能是 3、4 或 4.5，不必恰好等于 3.5，但随着试验次数增多，它会越来越接近期望。</p>
<p>期望最重要的性质之一是线性性（Linearity）：<span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[aX+b]=a\mathbb{E}[X]+b\)</span>，不要求独立。例：公平骰子点数 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 的期望是 <span displaypfx="inline-" class="mathjax-container">\((1+2+3+4+5+6)/6=3.5\)</span>；若收入模型写成 <span displaypfx="inline-" class="mathjax-container">\(Y=2X+1\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[Y]=2\times 3.5+1=8\)</span>。</p>
<div class="blog_h2"><span class="graybg">矩（Moments）</span></div>
<p>矩（Moment）是概率统计里用来概括分布形状的一组数。理论上，“第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 阶”指对 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 次幂取期望：矩天然与期望/均值绑定。</p>
<p>常用两类定义是：</p>
<ul>
<li>原点矩（Raw Moment / Non-central Moment）：<span displaypfx="inline-" class="mathjax-container">\(m_k=\mathbb{E}[X^k]\)</span>（以 0 为参照）。</li>
<li>中心矩（Central Moment）：<span displaypfx="inline-" class="mathjax-container">\(\mu_k=\mathbb{E}\big[(X-\mathbb{E}[X])^k\big]\)</span>（以均值为参照）。</li>
</ul>
<p>直觉上可以用“跷跷板”来理解：把概率质量看作分布在数轴上的“重量”，期望/均值决定“支点放哪里”才平衡；更高阶矩描述“重量围绕支点如何分布”。</p>
<p>一阶矩（First Moment）回答“中心在哪里”：均值/期望 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]\)</span> 给出平衡点。但<span style="background-color: #c0c0c0;">一阶矩无法刻画离散程度</span>：例如 <span displaypfx="inline-" class="mathjax-container">\(X\in\{-1,+1\}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(X\in\{-10,+10\}\)</span> 都满足 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]=0\)</span>，但后者离中心更远。</p>
<p>二阶量用平方偏差的期望刻画离散程度：平方保证非负、避免正负偏差相互抵消，并对远离均值的样本赋予更大权重。在统计里，这对应二阶中心矩——方差 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(X)=\mathbb{E}[(X-\mathbb{E}[X])^2]\)</span>；在力学类比里，这与转动惯量（Moment of Inertia）中按 <span displaypfx="inline-" class="mathjax-container">\(r^2\)</span> 加权的直觉一致：远处的“重量”对系统的“难摆动/难转动”贡献更大。</p>
<p>二阶原点矩与方差之间满足恒等式：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Var}(X)=\mathbb{E}\big[(X-\mathbb{E}[X])^2\big]=\mathbb{E}[X^2]-\mathbb{E}[X]^2\]</span>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[X^2]=\mathrm{Var}(X)+\mathbb{E}[X]^2\]</span>
<p>它说明二阶原点矩 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X^2]\)</span> 以 0 为参照，会把两类效应叠加在一起：一是分布整体离 0 的偏移（由均值项 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]^2\)</span> 表示），二是围绕自身均值的离散/抖动（由方差项 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(X)\)</span> 表示）。因此 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X^2]\)</span> 变大并不等价于“波动更大”；把所有取值整体平移一个常数，方差不变，但二阶原点矩会随偏移显著改变。</p>
<p>一个最小例子可以把差异看得非常清楚。令 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 等概率取值 <span displaypfx="inline-" class="mathjax-container">\(\{99,100,101\}\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[X]=\frac{99+100+101}{3}=100,\quad \mathbb{E}[X]^2=100^2=10000\]</span>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[X^2]=\frac{99^2+100^2+101^2}{3}=\frac{30002}{3}\approx 10000.67\]</span>
<span displaypfx="" class="mathjax-container">\[\mathrm{Var}(X)=\mathbb{E}[X^2]-\mathbb{E}[X]^2=\frac{30002}{3}-10000=\frac{2}{3}\]</span>
<p>可以直接读成一句话：这组数“离 0 很远”（所以 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X^2]\)</span> 很大），但“围绕自身均值很稳定”（所以方差很小）。</p>
<p>这也解释了 Adam 里的常见误解。Adam 说的“一阶矩/二阶矩”是把 mini-batch 梯度 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 当作随机变量，分别估计 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g]\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g^2]\)</span>（逐参数维度）。它用的是二阶原点矩 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g^2]\)</span>，不是方差 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(g)\)</span>。原因很直接：如果梯度在一段时间内几乎恒定（例如每步都是 <span displaypfx="inline-" class="mathjax-container">\(g=10\)</span>），那么方差为 0，拿它做除法会造成数值灾难；但 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g^2]=100\)</span> 给出了稳定的“绝对尺度”，使更新项 <span displaypfx="inline-" class="mathjax-container">\(\frac{\mathbb{E}[g]}{\sqrt{\mathbb{E}[g^2]}}\)</span> 仍然是有界的（再加上 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 做数值稳定）。</p>
<div class="blog_h2"><span class="graybg">方差</span></div>
<p>方差（Variance）衡量随机变量围绕均值的波动大小。它不是“偏离均值后再平均”，而是“偏离均值的平方再平均”；平方的作用是避免正负抵消，并放大大偏差：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Var}(X)=\mathbb{E}[(X-\mu)^2]\]</span>
<p>因此，方差小表示样本更集中在均值附近；方差大表示波动更强。它刻画的是“不确定性的尺度”，而不是取值本身的大小。例如两个模型的平均误差相同，方差更大的那个模型，输出往往更不稳定。</p>
<div class="blog_h2"><span class="graybg">协方差</span></div>
<p>协方差（Covariance）描述两个变量是否倾向同向变化：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Cov}(X,Y)=\mathbb{E}[(X-\mu_X)(Y-\mu_Y)]\]</span>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Cov}(X,Y)&gt;0\)</span>，说明 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 大时 <span displaypfx="inline-" class="mathjax-container">\(Y\)</span> 也倾向变大；若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Cov}(X,Y)&lt;0\)</span>，说明一个变大时另一个倾向变小。直觉例子是：身高与体重常为正协方差，室外温度与暖气功率常为负协方差。</p>
<p>还要区分一点：协方差为 0 不必然意味着独立。例如令 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(\{-1,1\}\)</span> 上等概率取值，定义 <span displaypfx="inline-" class="mathjax-container">\(Y=X^2\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(Y\)</span> 被 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 完全决定，但 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Cov}(X,Y)=0\)</span>。因此“零相关”比“独立”弱得多。</p>
<p>协方差矩阵（Covariance Matrix）<span displaypfx="inline-" class="mathjax-container">\(\Sigma\)</span> 在 AI 中极其关键：PCA、马氏距离（Mahalanobis Distance）、卡尔曼滤波与高斯模型都直接依赖它。它编码的是“各方向上的尺度”与“不同维度之间的耦合”。</p>
<div class="blog_h2"><span class="graybg">标准差（Standard Deviation）</span></div>
<p>标准差（Standard Deviation）衡量数据相对均值（Mean）的离散程度（Dispersion）。总体标准差（Population Standard Deviation）定义为：</p>
<span displaypfx="" class="mathjax-container">\[\sigma=\sqrt{\frac{1}{n}\sum_{i=1}^{n}(x_i-\mu)^2}\]</span>
<p>样本标准差（Sample Standard Deviation）使用贝塞尔校正（Bessel's Correction）：</p>
<span displaypfx="" class="mathjax-container">\[s=\sqrt{\frac{1}{n-1}\sum_{i=1}^{n}(x_i-\bar{x})^2}\]</span>
<p>先平方再平均是为了避免正负抵消并加重大偏差；最后开平方是为了把单位恢复到原始尺度。若直接用方差，单位会变成“平方单位”，解释往往不直观；标准差则可以直接说成“典型偏离均值大约多少个原始单位”。</p>
<p>例：数据 <span displaypfx="inline-" class="mathjax-container">\(\{2,4,4,4,5,5,7,9\}\)</span> 的均值是 <span displaypfx="inline-" class="mathjax-container">\(5\)</span>，总体方差是 <span displaypfx="inline-" class="mathjax-container">\(4\)</span>，标准差是 <span displaypfx="inline-" class="mathjax-container">\(2\)</span>。这意味着一个典型样本与均值的偏离量级大约是 2，而不是每个点都恰好偏 2。</p>
<p>标准差还常被用来做标准化（Standardization）。例如某考试分数 80 分，班级均值 70、标准差 5，则它的 z-score 是 <span displaypfx="inline-" class="mathjax-container">\((80-70)/5=2\)</span>，表示它比均值高 2 个标准差。近似正态分布下，经验上约有 68% 样本落在 <span displaypfx="inline-" class="mathjax-container">\(\mu\pm\sigma\)</span>，95% 落在 <span displaypfx="inline-" class="mathjax-container">\(\mu\pm2\sigma\)</span>，99.7% 落在 <span displaypfx="inline-" class="mathjax-container">\(\mu\pm3\sigma\)</span>；这就是常见的 68-95-99.7 规则。</p>
<div class="blog_h2"><span class="graybg">最大似然估计（MLE）</span></div>
<p>最大似然估计（Maximum Likelihood Estimation, MLE）先把已经观察到的数据（例如上面抛硬币10次有8次正面）固定住，再在参数空间里寻找“最能生成这批数据”的参数。设数据集为 <span displaypfx="inline-" class="mathjax-container">\(D=\{x_1,\dots,x_n\}\)</span>，参数为 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>，则定义是：</p>
<span displaypfx="" class="mathjax-container">\[\hat{\theta}_{\mathrm{MLE}}=\arg\max_{\theta}p(D|\theta)\]</span>
<p>其中<span displaypfx="inline-" class="mathjax-container">\(p(D|\theta)\)</span> 是“参数取 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 时看到整批数据 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 的概率/密度”； <span displaypfx="inline-" class="mathjax-container">\(\arg\max\)</span> 表示“找出让这个值最大的那个参数”。</p>
<p>若样本独立同分布（不相关且在同一个分布，例如抛硬币中10个独立事件。Independent and Identically Distributed, i.i.d.），联合似然可以拆成单个样本似然的乘积：</p>
<span displaypfx="" class="mathjax-container">\[p(D|\theta)=\prod_{i=1}^{n}p(x_i|\theta)\]</span>
<p>于是 MLE 常写成</p>
<span displaypfx="" class="mathjax-container">\[\hat{\theta}_{\mathrm{MLE}}=\arg\max_{\theta}\prod_{i=1}^{n}p(x_i|\theta)\]</span>
<p>实际训练几乎总是改为最大化对数似然（Log-Likelihood）：</p>
<span displaypfx="" class="mathjax-container">\[\ell(\theta)=\log p(D|\theta)=\sum_{i=1}^{n}\log p(x_i|\theta)\]</span>
<p><span style="background-color: #c0c0c0;">取对数不会改变最优解</span>，因为 <span displaypfx="inline-" class="mathjax-container">\(\log\)</span> 是单调递增函数；它只是把难处理的连乘变成易处理的求和。因此，最大化似然与最小化负对数似然（Negative Log-Likelihood, NLL）完全等价。</p>
<p>继续看抛硬币的例子。设单次结果 <span displaypfx="inline-" class="mathjax-container">\(x_i\in\{0,1\}\)</span>，其中 1 表示正面，0 表示反面。设正面概率为 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>，那么单个样本就服从伯努利分布（Bernoulli Distribution）。它的概率质量函数（Probability Mass Function, PMF，用于描述离散随机变量，直接给出概率。对应概率密度函数PDF，给出给定连续随机变量对应位置的密度，因为连续，特定点的概率必须为0，只能密度和变量范围积分得到概率）写成：</p>
<span displaypfx="" class="mathjax-container">\[p(x_i|\theta)=\theta^{x_i}(1-\theta)^{1-x_i},\quad x_i\in\{0,1\}\]</span>
<p>左边的 <span displaypfx="inline-" class="mathjax-container">\(p(\cdot|\theta)\)</span> 里的 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是“概率分布/概率质量函数”的记号， <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 是模型参数，也就是“正面概率”。很多教材会把参数也记成 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>，写成 <span displaypfx="inline-" class="mathjax-container">\(p(x_i|p)\)</span>；这并不算错，因为参数本身就是一个概率，但两个 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 同时出现时很容易视觉混淆，所以这里改用 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 来区分“模型符号”和“参数符号”。</p>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(x_i=1\)</span> 时，</p>
<span displaypfx="" class="mathjax-container">\[p(x_i=1|\theta)=\theta^1(1-\theta)^0=\theta\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(x_i=0\)</span> 时，</p>
<span displaypfx="" class="mathjax-container">\[p(x_i=0|\theta)=\theta^0(1-\theta)^1=1-\theta\]</span>
<p>如果 10 次里看到 7 次正面、3 次反面，那么整批数据的似然就是：</p>
<span displaypfx="" class="mathjax-container">\[p(D|\theta)=\theta^7(1-\theta)^3\]</span>
<p>对应的对数似然是</p>
<span displaypfx="" class="mathjax-container">\[\ell(\theta)=7\log \theta+3\log(1-\theta)\]</span>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-binomial-loglik.png"><img class="alignnone size-full wp-image-41091" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-binomial-loglik.png" alt="plot-binomial-loglik" width="100%" /></a></p>
<p>我们现在要最大化似然，需要对 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 求导并令导数为 0：</p>
<span displaypfx="" class="mathjax-container">\[\frac{d\ell(\theta)}{d\theta}=\frac{7}{\theta}-\frac{3}{1-\theta}=0\Rightarrow \theta=0.7\]</span>
<p>所以 <span displaypfx="inline-" class="mathjax-container">\(\hat{\theta}_{\mathrm{MLE}}=0.7\)</span>。即“在所有候选 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 里， <span displaypfx="inline-" class="mathjax-container">\(\theta=0.7\)</span> 最能让 7 正 3 反这份数据显得不奇怪”。</p>
<div class="blog_h3"><span class="graybg">从概率建模到损失函数</span></div>
<p>在机器学习里，损失函数往往不是独立拍脑袋指定出来的，而是由<span style="background-color: #c0c0c0;">概率建模假设（Probabilistic Modeling Assumption）</span>诱导出来的。做法是先写下观测数据在参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 下的概率模型，再把训练集的负对数似然（Negative Log-Likelihood, NLL）当作优化目标。于是，最小化损失就不再只是一个工程规定，而是等价于最大化“已观测数据在模型下出现的可能性”。</p>
<p>以最常见的回归假设为例，若模型输出写成 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span>，并假设真实标签满足</p>
<span displaypfx="" class="mathjax-container">\[y=f_\theta(x)+\varepsilon,\qquad \varepsilon\sim\mathcal{N}(0,\sigma^2)\]</span>
<p>这里的高斯分布指的不是 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 本身，也不是 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span> 本身，而是<span style="background-color: #c0c0c0;">误差项，也就是残差 <span displaypfx="inline-" class="mathjax-container">\(y-f_\theta(x)\)</span> 的分布</span>。误差总是相对于模型当前给出的预测中心 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span> 来计算：预测值附近最可能出现，偏离越远，概率越低。</p>
<p>从这个误差模型出发，可以把“误差分布”直接改写成“真实标签在给定输入下的条件分布”。因为 <span displaypfx="inline-" class="mathjax-container">\(\varepsilon\)</span> 服从均值为 0 的高斯分布，所以等价地，真实标签服从一个均值为 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span> 的高斯分布：</p>
<span displaypfx="" class="mathjax-container">\[y\,|\,x \sim \mathcal{N}(f_\theta(x),\sigma^2)\]</span>
<p>这条式子的含义是：给定输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 之后，模型并不把输出 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 看成一个确定值，而是把它看成一个<span style="background-color: #c0c0c0;">以预测值 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span> 为中心、方差为 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 的高斯随机变量</span>。根据高斯分布的概率密度函数，可进一步写出条件概率密度：</p>
<span displaypfx="" class="mathjax-container">\[p_\theta(y|x)=\frac{1}{\sqrt{2\pi\sigma^2}}\exp\left(-\frac{(y-f_\theta(x))^2}{2\sigma^2}\right)\]</span>
<p>这时 <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y_i|x_i)\)</span> 的含义就自然了：它评估的是在当前参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 下，训练集中已经观测到的真实标签 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span>，作为“围绕 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span> 摆动的一个样本”是否足够合理。被评分的始终是<span style="background-color: #c0c0c0;">真实观测到的 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span></span>，而不是模型预测出来的 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span>。<span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span> 的作用不是直接成为被评分对象，而是作为条件分布的均值或位置参数，决定观测值最可能出现的位置。</p>
<p>这种思路并不限于高斯回归。分类任务里，条件分布不再是高斯，而会改成 Bernoulli 或 Categorical；若把噪声假设换成拉普拉斯分布（Laplacian Distribution），则导出的损失也会从平方误差变成绝对误差。更严谨地说，<span style="background-color: #c0c0c0;">损失函数是概率模型在训练集上的负对数似然写开后的结果</span>：高斯噪声导出平方误差（Squared Error），拉普拉斯噪声导出绝对误差（Absolute Error），Bernoulli 与 Categorical 分布则导出二分类或多分类交叉熵（Cross-Entropy）。</p>
<div class="blog_h3"><span class="graybg">高斯分布的MLE和最小二乘</span></div>
<p>先看监督回归（Supervised Regression）。训练集写成 <span displaypfx="inline-" class="mathjax-container">\(D=\{(x_i,y_i)\}_{i=1}^N\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 是输入特征，<span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 是真实输出。按照上面的统一框架，这里仍然是把 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 当作给定条件，只为输出建模条件分布 <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y|x)\)</span>，并让真实观测到的 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 在模型下尽可能不意外：</p>
<span displaypfx="" class="mathjax-container">\[\hat\theta_{\mathrm{MLE}}=\arg\max_{\theta}\prod_{i=1}^{N}p_\theta(y_i|x_i)\]</span>
<p>这里的参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 不在 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 里，而是出现在假设函数 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span> 里。例如线性回归（Linear Regression）可写成 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)=\mathbf{w}^\top x+b\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(\theta=(\mathbf{w},b)\)</span>。回归里常说的“高斯分布”指的不是整张训练集的 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 服从高斯分布，也不是模型预测值 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\)</span> 本身服从高斯分布，而是指<span style="background-color: #c0c0c0;">误差项，也就是残差 <span displaypfx="inline-" class="mathjax-container">\(y_i-f_\theta(x_i)\)</span> 的分布</span>。误差总是相对于当前模型给出的中心值 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span> 来计算。</p>
<p>具体假设是：</p>
<span displaypfx="" class="mathjax-container">\[y_i=f_\theta(x_i)+\varepsilon_i,\quad \varepsilon_i\sim\mathcal{N}(0,\sigma^2)\ \text{i.i.d.}\]</span>
<p>等价写成条件分布：</p>
<span displaypfx="" class="mathjax-container">\[y_i\,|\,x_i \sim \mathcal{N}(f_\theta(x_i),\sigma^2)\]</span>
<p>上式的意思是：在给定输入 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 的条件下，输出 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 不再被视为确定值，而被建模为随机变量，并且服从均值为 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span>、方差为 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 的一维高斯分布（Gaussian Distribution），当然，这里的不确定性就是模型引入的。根据高斯分布的概率密度公式，并结合 i.i.d. 假设（联合似然为逐样本似然的乘积），整批数据的似然为</p>
<span displaypfx="" class="mathjax-container">\[p(D|\theta,\sigma^2)=\prod_{i=1}^{N}\frac{1}{\sqrt{2\pi\sigma^2}}\exp\left(-\frac{(y_i-f_\theta(x_i))^2}{2\sigma^2}\right)\]</span>
<p>取负对数似然（Negative Log-Likelihood, NLL）得到训练时真正被最小化的量：</p>
<span displaypfx="" class="mathjax-container">\[-\log p(D|\theta,\sigma^2)=\frac{N}{2}\log(2\pi\sigma^2)+\frac{1}{2\sigma^2}\sum_{i=1}^{N}(y_i-f_\theta(x_i))^2\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 视为常数时，第一项与 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 无关，因此最小化 NLL 关于 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 等价于最小化平方误差和（Sum of Squared Errors, SSE）：</p>
<span displaypfx="" class="mathjax-container">\[\hat\theta_{\mathrm{MLE}}=\arg\min_\theta\sum_{i=1}^{N}(y_i-f_\theta(x_i))^2\]</span>
<p>把残差（Residual）记为 <span displaypfx="inline-" class="mathjax-container">\(r_i=y_i-f_\theta(x_i)\)</span>，则上式就是最小化 <span displaypfx="inline-" class="mathjax-container">\(\sum_i r_i^2\)</span>。因此，在“高斯噪声 + 固定方差”的回归假设下，<span style="background-color: #c0c0c0;">最大化似然（等价最大化对数似然）就是最小化残差平方和</span>。</p>
<p>这就是“最小二乘（Least Squares）”为什么会从概率建模里自然出现；均方误差（Mean Squared Error, MSE）只是把 SSE 再除以 <span displaypfx="inline-" class="mathjax-container">\(N\)</span>，不改变最优解。</p>
<p>如果 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 也未知，则 MLE 会同时估计 <span displaypfx="inline-" class="mathjax-container">\((\theta,\sigma^2)\)</span>。在固定 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 时，对 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 的 MLE 为</p>
<span displaypfx="" class="mathjax-container">\[\hat\sigma^2_{\mathrm{MLE}}=\frac{1}{N}\sum_{i=1}^{N}(y_i-f_\theta(x_i))^2\]</span>
<p>注意这里是 <span displaypfx="inline-" class="mathjax-container">\(1/N\)</span>（MLE），而不是无偏估计常用的 <span displaypfx="inline-" class="mathjax-container">\(1/(N-1)\)</span>。</p>
<p>顺带一提：把回归模型退化为“常数预测” <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x)\equiv \mu\)</span>，就回到“在高斯噪声下估计均值 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span>”，其 MLE 是样本均值。这也是很多教材先从 <span displaypfx="inline-" class="mathjax-container">\(x_i=\mu+\varepsilon_i\)</span> 入手的原因：那是回归的一个最小特例（此时 <span displaypfx="inline-" class="mathjax-container">\(\theta=\mu\)</span>）。</p>
<div class="blog_h3"><span class="graybg">Bernoulli / Categorical 分布和交叉熵</span></div>
<p>分类任务与回归不同：标签通常是离散变量，因此这里使用的不是概率密度函数（Probability Density Function, PDF），而是概率质量函数（Probability Mass Function, PMF）。做最大似然估计时，最常见的做法同样是把输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 视为给定条件，只为标签建模条件分布 <span displaypfx="inline-" class="mathjax-container">\(p_\theta(y|x)\)</span>，然后让已观测标签在模型下尽可能“显得合理”。</p>
<p>先看二分类（Binary Classification）。设标签 <span displaypfx="inline-" class="mathjax-container">\(y_i\in\{0,1\}\)</span>，模型输出正类概率 <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(x_i)=p_\theta(y_i=1|x_i)\)</span>，则单个样本服从伯努利分布（Bernoulli Distribution）：</p>
<span displaypfx="" class="mathjax-container">\[p_\theta(y_i|x_i)=\pi_\theta(x_i)^{y_i}\bigl(1-\pi_\theta(x_i)\bigr)^{1-y_i}\]</span>
<p>在 i.i.d. 假设下，整批数据的似然为</p>
<span displaypfx="" class="mathjax-container">\[p(D|\theta)=\prod_{i=1}^{N}\pi_\theta(x_i)^{y_i}\bigl(1-\pi_\theta(x_i)\bigr)^{1-y_i}\]</span>
<p>取负对数似然，得到</p>
<span displaypfx="" class="mathjax-container">\[-\log p(D|\theta)=-\sum_{i=1}^{N}\Big[y_i\log \pi_\theta(x_i)+(1-y_i)\log\bigl(1-\pi_\theta(x_i)\bigr)\Big]\]</span>
<p>这正是二分类交叉熵（Binary Cross-Entropy, BCE）。因此，在“标签服从伯努利分布”的假设下，<span style="background-color: #c0c0c0;">最大化似然等价于最小化二分类交叉熵</span>。若再把 <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(x)\)</span> 写成 sigmoid 作用在 logit <span displaypfx="inline-" class="mathjax-container">\(z_\theta(x)\)</span> 上，即 <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(x)=\sigma(z_\theta(x))\)</span>，就得到工程实现里最常见的 sigmoid + BCE 训练形式。</p>
<p>再看多分类（Multiclass Classification）。设类别总数为 <span displaypfx="inline-" class="mathjax-container">\(C\)</span>，并用 one-hot 向量 <span displaypfx="inline-" class="mathjax-container">\(y_i=(y_{i1},\dots,y_{iC})\)</span> 表示真实标签，其中只有真实类别对应那一项为 1，其余为 0。若模型输出类别概率 <span displaypfx="inline-" class="mathjax-container">\(\pi_{\theta,1}(x_i),\dots,\pi_{\theta,C}(x_i)\)</span>，且满足 <span displaypfx="inline-" class="mathjax-container">\(\sum_{c=1}^{C}\pi_{\theta,c}(x_i)=1\)</span>，则单个样本服从类别分布（Categorical Distribution）：</p>
<span displaypfx="" class="mathjax-container">\[p_\theta(y_i|x_i)=\prod_{c=1}^{C}\pi_{\theta,c}(x_i)^{y_{ic}}\]</span>
<p>整批数据的似然为</p>
<span displaypfx="" class="mathjax-container">\[p(D|\theta)=\prod_{i=1}^{N}\prod_{c=1}^{C}\pi_{\theta,c}(x_i)^{y_{ic}}\]</span>
<p>取负对数似然，得到</p>
<span displaypfx="" class="mathjax-container">\[-\log p(D|\theta)=-\sum_{i=1}^{N}\sum_{c=1}^{C}y_{ic}\log \pi_{\theta,c}(x_i)\]</span>
<p>这正是多分类交叉熵（Categorical Cross-Entropy）。若标签是标准 one-hot，那么每个样本只会保留真实类别那一项，损失就退化成 <span displaypfx="inline-" class="mathjax-container">\(-\log \pi_{\theta,c^*}(x_i)\)</span>；若标签本身是软标签（Soft Label），则公式不变，只是 <span displaypfx="inline-" class="mathjax-container">\(y_{ic}\)</span> 不再只有 0 和 1，而是一个分布。</p>
<div class="blog_h3"><span class="graybg">拉普拉斯分布的MLE和绝对误差</span></div>
<p>若回归任务不再假设误差服从高斯分布，而是假设误差项 <span displaypfx="inline-" class="mathjax-container">\(\varepsilon\)</span> 服从拉普拉斯分布（Laplacian Distribution），即</p>
<span displaypfx="" class="mathjax-container">\[y_i=f_\theta(x_i)+\varepsilon_i,\qquad \varepsilon_i\sim \mathrm{Laplace}(0,b)\ \text{i.i.d.}\]</span>
<p>那么等价地，真实标签在给定输入后的条件分布可写成</p>
<span displaypfx="" class="mathjax-container">\[y_i\,|\,x_i \sim \mathrm{Laplace}(f_\theta(x_i),b)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span> 是位置参数（Location Parameter），<span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是尺度参数（Scale Parameter）。拉普拉斯分布的一维概率密度函数为</p>
<span displaypfx="" class="mathjax-container">\[p_\theta(y_i|x_i)=\frac{1}{2b}\exp\left(-\frac{|y_i-f_\theta(x_i)|}{b}\right)\]</span>
<p>在 i.i.d. 假设下，整批数据的似然为</p>
<span displaypfx="" class="mathjax-container">\[p(D|\theta,b)=\prod_{i=1}^{N}\frac{1}{2b}\exp\left(-\frac{|y_i-f_\theta(x_i)|}{b}\right)\]</span>
<p>取负对数似然，得到</p>
<span displaypfx="" class="mathjax-container">\[-\log p(D|\theta,b)=N\log(2b)+\frac{1}{b}\sum_{i=1}^{N}|y_i-f_\theta(x_i)|\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 视为常数时，第一项与 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 无关，因此最小化 NLL 关于 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 等价于最小化绝对误差和（Sum of Absolute Errors, SAE）：</p>
<span displaypfx="" class="mathjax-container">\[\hat\theta_{\mathrm{MLE}}=\arg\min_\theta\sum_{i=1}^{N}|y_i-f_\theta(x_i)|\]</span>
<p>这正是平均绝对误差（Mean Absolute Error, MAE）背后的概率来源。与高斯分布相比，拉普拉斯分布在中心更尖、尾部更厚，因此它对应的损失不再像平方误差那样强烈放大大残差，而是按线性方式惩罚偏差。也正因为如此，MAE 通常比 MSE 对离群点（Outlier）更稳健。</p>
<p>因此，高斯分布与最小二乘、拉普拉斯分布与绝对误差、Bernoulli / Categorical 分布与交叉熵，其实是同一个统计逻辑在不同任务类型下的三种展开：<span style="background-color: #c0c0c0;">先假设观测数据服从某个条件分布，再对训练集做最大似然估计；把负对数似然写开后，就得到具体的训练损失</span>。</p>
<div class="blog_h2"><span class="graybg">最大后验估计（MAP）</span></div>
<p>最大后验估计（Maximum A Posteriori, MAP）在 MLE 的“数据解释能力”之外，再加入参数的先验（Prior）信息。它选择后验概率（Posterior）最大的参数：</p>
<span displaypfx="" class="mathjax-container">\[\hat{\theta}_{\mathrm{MAP}}=\arg\max_{\theta}p(\theta|D)\]</span>
<p>根据贝叶斯定理：</p>
<span displaypfx="" class="mathjax-container">\[p(\theta|D)=\frac{p(D|\theta)p(\theta)}{p(D)}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(p(D|\theta)\)</span> 是似然（Likelihood）， <span displaypfx="inline-" class="mathjax-container">\(p(\theta)\)</span> 是先验， <span displaypfx="inline-" class="mathjax-container">\(p(D)\)</span> 是证据（Evidence）。因为 <span displaypfx="inline-" class="mathjax-container">\(p(D)\)</span> 与参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 无关，所以在求最大值时它只是常数，可以直接忽略：</p>
<span displaypfx="" class="mathjax-container">\[\hat{\theta}_{\mathrm{MAP}}=\arg\max_{\theta}p(D|\theta)p(\theta)=\arg\max_{\theta}\big(\log p(D|\theta)+\log p(\theta)\big)\]</span>
<p>这个式子清楚地说明了 MAP 的结构： <span displaypfx="inline-" class="mathjax-container">\(\log p(D|\theta)\)</span> 负责拟合数据， <span displaypfx="inline-" class="mathjax-container">\(\log p(\theta)\)</span> 负责约束参数不要偏离先验认知。若改写成最小化形式，则有</p>
<span displaypfx="" class="mathjax-container">\[\hat{\theta}_{\mathrm{MAP}}=\arg\min_\theta\big(-\log p(D|\theta)-\log p(\theta)\big)\]</span>
<p>因此，MAP 可以理解为“负对数似然 + 先验诱导的惩罚项（Penalty）”。这也是为什么很多正则化（Regularization）能够从贝叶斯视角解释：L2 正则通常对应高斯先验，L1 正则通常对应拉普拉斯先验。若先验在可行域内与 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 无关（例如均匀先验： <span displaypfx="inline-" class="mathjax-container">\(p(\theta)=c\)</span>），则 <span displaypfx="inline-" class="mathjax-container">\(\log p(\theta)=\log c\)</span> 是关于 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的常数；在 <span displaypfx="inline-" class="mathjax-container">\(\arg\max_\theta\)</span> 中加上或去掉这一项都不改变最优点，因此 MAP 与 MLE 给出同一组参数（若均匀先验还隐含“只在某个范围内为常数、范围外为 0”，则等价于在该范围约束下做 MLE）。</p>
<p>继续用抛硬币解释。现在参数已经统一记作 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>，因此先验也写成 <span displaypfx="inline-" class="mathjax-container">\(p(\theta)\)</span>，而不再写 <span displaypfx="inline-" class="mathjax-container">\(p(p)\)</span>。若对 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 设 Beta 分布（Beta Distribution）先验</p>
<p>Beta 分布是定义在区间 <span displaypfx="inline-" class="mathjax-container">\([0,1]\)</span> 上的连续分布，最常用来描述“某个概率值本身的不确定性”，因此非常适合作为伯努利参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的先验。</p>
<span displaypfx="" class="mathjax-container">\[p(\theta)\propto \theta^{\alpha-1}(1-\theta)^{\beta-1},\quad 0\le \theta\le 1\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\propto\)</span> 表示“正比于”，意思是左边真正的概率密度函数还差一个归一化常数（Normalization Constant），这个常数负责保证密度在区间 <span displaypfx="inline-" class="mathjax-container">\([0,1]\)</span> 上积分为 1。对 MAP 而言，这个常数不影响最大值位置，所以通常省略不写。</p>
<p>参数 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\beta\)</span> 控制分布形状： <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 越大，分布越偏向较大的 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>； <span displaypfx="inline-" class="mathjax-container">\(\beta\)</span> 越大，分布越偏向较小的 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>。例如 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Beta}(2,2)\)</span> 会把概率质量更多放在中间区域，表达“更倾向认为硬币接近公平”； <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Beta}(8,2)\)</span> 则更偏向 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 接近 1。</p>
<p>那么它表达的是：在看到数据之前，我们已经对“硬币正面概率应该落在什么范围”有一个先验偏好。</p>
<p>把这个先验与 7 正 3 反的似然 <span displaypfx="inline-" class="mathjax-container">\(p(D|\theta)=\theta^7(1-\theta)^3\)</span> 相乘，得到后验：</p>
<span displaypfx="" class="mathjax-container">\[p(\theta|D)\propto \theta^{7+\alpha-1}(1-\theta)^{3+\beta-1}\]</span>
<p>这说明后验仍然是 Beta 分布：</p>
<span displaypfx="" class="mathjax-container">\[p(\theta|D)=\mathrm{Beta}(7+\alpha,3+\beta)\]</span>
<p>众数（Mode）指的是<span style="background-color: #c0c0c0;">概率密度函数最高的那个位置</span>，也就是“这个分布最偏好的参数值”。因为 MAP 的定义本来就是寻找后验概率/后验密度最大的参数，所以当后验分布已经写出来时，MAP 估计就是这个后验分布的众数。</p>
<p>因为后验与 <span displaypfx="inline-" class="mathjax-container">\(\theta^{7+\alpha-1}(1-\theta)^{3+\beta-1}\)</span> 成正比，所以只需最大化这个函数。取对数后，等价目标变成</p>
<span displaypfx="" class="mathjax-container">\[(7+\alpha-1)\log \theta+(3+\beta-1)\log(1-\theta)\]</span>
<p>对 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 求导并令导数为 0：</p>
<span displaypfx="" class="mathjax-container">\[\frac{7+\alpha-1}{\theta}-\frac{3+\beta-1}{1-\theta}=0\]</span>
<p>移项并整理可得</p>
<span displaypfx="" class="mathjax-container">\[(7+\alpha-1)(1-\theta)=(3+\beta-1)\theta\Rightarrow \hat{\theta}_{\mathrm{MAP}}=\frac{7+\alpha-1}{10+\alpha+\beta-2}\]</span>
<p>因此，当 <span displaypfx="inline-" class="mathjax-container">\(\alpha,\beta&gt;1\)</span> 时，Beta 分布的众数（Mode）也就是 MAP 估计。</p>
<p>若取 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Beta}(2,2)\)</span> 先验，则</p>
<span displaypfx="" class="mathjax-container">\[\hat{\theta}_{\mathrm{MAP}}=\frac{7+2-1}{10+2+2-2}=\frac{8}{12}=\frac{2}{3}\]</span>
<p>它比 MLE 的 <span displaypfx="inline-" class="mathjax-container">\(0.7\)</span> 更靠近 <span displaypfx="inline-" class="mathjax-container">\(0.5\)</span>。原因很具体： <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Beta}(2,2)\)</span> 可以理解为在真实观测之外，先额外放入了“1 次正面 + 1 次反面”的温和偏好，所以最终估计不会被7正3反的样本完全带着走。样本很少时，先验的影响明显；样本很多时，数据项占主导，MAP 会逐渐逼近 MLE。</p>
<p>在机器学习里，MAP 与正则化（Regularization）本质上是同一件事的两种表述。不是“随意加一个惩罚项”，而是：<span style="background-color: #c0c0c0;">一旦你给参数指定先验，取负对数后，这个先验就会自动变成优化目标里的正则项</span>。</p>
<p>先看一般形式。MAP 最大化的是后验概率 <span displaypfx="inline-" class="mathjax-container">\(p(\theta|D)\)</span>，等价于最小化负对数后验：</p>
<span displaypfx="" class="mathjax-container">\[-\log p(\theta|D)=-\log p(D|\theta)-\log p(\theta)+\mathrm{const}\]</span>
<p>第一项 <span displaypfx="inline-" class="mathjax-container">\(-\log p(D|\theta)\)</span> 是数据拟合项，第二项 <span displaypfx="inline-" class="mathjax-container">\(-\log p(\theta)\)</span> 就是先验带来的惩罚项。因此“正则化”并不是凭空发明出来的工程技巧，而是贝叶斯先验在优化问题里的直接体现。</p>
<p>若参数向量 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 服从零均值各向同性高斯先验（Isotropic Gaussian Prior），可以写成</p>
<span displaypfx="" class="mathjax-container">\[p(\theta)\propto \exp\left(-\frac{1}{2\tau^2}\|\theta\|_2^2\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\tau^2\)</span> 控制先验方差：方差越小，越强地偏好参数靠近 0。对它取负对数得到</p>
<span displaypfx="" class="mathjax-container">\[-\log p(\theta)=\frac{1}{2\tau^2}\|\theta\|_2^2+\mathrm{const}\]</span>
<p>把常数 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2\tau^2}\)</span> 记成 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span>，就得到熟悉的目标：</p>
<span displaypfx="" class="mathjax-container">\[-\log p(D|\theta)+\lambda\|\theta\|_2^2\]</span>
<p>这正是 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 正则。它的作用可以理解为：先验认为“大权重不太可信”，所以优化时不仅要拟合数据，还要为过大的参数付出代价。由于高斯先验在 0 附近平滑、尾部衰减快，它通常会把参数整体压小，但不特别鼓励大量参数精确等于 0。</p>
<p>若先验改成拉普拉斯（Laplacian）分布：</p>
<span displaypfx="" class="mathjax-container">\[p(\theta)\propto \exp\big(-\lambda\|\theta\|_1\big)\]</span>
<p>则负对数先验变成</p>
<span displaypfx="" class="mathjax-container">\[-\log p(\theta)=\lambda\|\theta\|_1+\mathrm{const}\]</span>
<p>于是 MAP 等价于最小化</p>
<span displaypfx="" class="mathjax-container">\[-\log p(D|\theta)+\lambda\|\theta\|_1\]</span>
<p>这就是 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 正则。它与 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 的关键区别在于：拉普拉斯先验在 0 处尖得更厉害，因此更强地偏好很多参数直接变成 0，也就是产生稀疏性（Sparsity）。</p>
<p>所以可以把对应关系记成一条很清楚的链：<span style="background-color: #c0c0c0;">高斯先验对应 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span>，拉普拉斯先验对应 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span>；正则项的形状，本质上就是先验密度形状的负对数</span>。很多看起来像“人为添加的惩罚项”，其实都可以解释为“在做 MAP”。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/regularization-distribution-assumptions.png"><img class="alignnone size-full" src="https://blog.gmem.cc/wp-content/uploads/2026/03/regularization-distribution-assumptions.png" alt="regularization-distribution-assumptions" width="1920" height="1064" /></a></p>
<p>这里的分布假设有两个不同落点。若假设的是数据误差或残差 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 的分布，它进入的是似然 <span displaypfx="inline-" class="mathjax-container">\(p(D|\theta)\)</span>，决定数据拟合项的形状；若假设的是参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 自身的分布，它进入的是先验 <span displaypfx="inline-" class="mathjax-container">\(p(\theta)\)</span>，决定正则项的形状。前者回答“数据会怎样围绕模型波动”，后者回答“参数本身更可能落在什么区域”。</p>
<p>因此，高斯和拉普拉斯并不只用于描述数据集。高斯残差假设会把负对数似然化成平方误差，拉普拉斯残差假设会把负对数似然化成绝对误差；与此同时，零均值高斯参数先验会把负对数先验化成 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 正则，零均值拉普拉斯参数先验会把负对数先验化成 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 正则。它们使用的是同一类分布族，但一个作用在数据项 <span displaypfx="inline-" class="mathjax-container">\(p(D|\theta)\)</span>，一个作用在参数项 <span displaypfx="inline-" class="mathjax-container">\(p(\theta)\)</span>。</p>
<div class="blog_h2"><span class="graybg">大数定律与中心极限定理</span></div>
<div class="blog_h3"><span class="graybg">大数定律（Law of Large Numbers, LLN）</span></div>
<p>大数定律说明：当独立同分布样本越来越多时，样本平均会稳定地靠近真实期望（Expectation）。若 <span displaypfx="inline-" class="mathjax-container">\(X_1,\dots,X_n\)</span> 独立同分布，且 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]\)</span> 存在，则</p>
<span displaypfx="" class="mathjax-container">\[\frac{1}{n}\sum_{i=1}^{n}X_i \to \mathbb{E}[X],\quad n\to\infty\]</span>
<p>这里的重点是“平均值会稳定下来”，而不是“每一次新观测都会让结果更接近真值”。它描述的是长期趋势：样本量越大，随机波动在平均过程中被不断抵消，样本平均就越不容易偏离真实期望太远。</p>
<p>最经典的例子是抛公平硬币。设正面记为 1、反面记为 0，则单次试验的期望是 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[X]=0.5\)</span>。前几次抛掷时，正面比例可能是 1、0、0.67、0.25，波动很大；但抛掷次数达到几百、几千次后，样本平均 <span displaypfx="inline-" class="mathjax-container">\(\bar{X}\)</span> 会越来越稳定地靠近 0.5。大数定律回答的是“为什么频率会稳定到概率附近”。</p>
<div class="blog_h3"><span class="graybg">中心极限定理（Central Limit Theorem, CLT）</span></div>
<p>中心极限定理讨论的不是“均值会不会收敛”，而是<span style="background-color: #c0c0c0;">样本均值在真值附近如何波动，以及这种波动的分布长什么样</span>。在独立同分布且方差有限的条件下，标准化后的样本均值近似服从均值为 0、方差为 1 的正态分布：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\sqrt{n}(\bar{X}-\mu)}{\sigma}\Rightarrow \mathcal{N}(0,1)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mu=\mathbb{E}[X]\)</span> 是总体均值， <span displaypfx="inline-" class="mathjax-container">\(\sigma^2=\mathrm{Var}(X)\)</span> 是总体方差。所谓<span style="background-color: #c0c0c0;">标准化（Standardization）</span>，就是先减去均值 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span>，把中心移到 0；再除以标准差 <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span>，把尺度统一成“多少个标准差”；再乘上 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{n}\)</span>，把样本均值随着样本量增大而缩小的波动重新放回可比较的尺度。标准化之后，不同样本量下的波动可以放到同一个参考系里比较。</p>
<p>所谓<span style="background-color: #c0c0c0;">方差有限</span>，就是随机变量的波动强度不是无限大，满足 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(X)&lt;\infty\)</span>。直观上，这意味着样本虽然会波动，但不会被极端值以“无限强”的方式主导。像伯努利分布、二项分布、泊松分布、均匀分布和高斯分布都满足这个条件；而某些重尾分布则可能不满足，因此不能直接套用最基础的 CLT 表述。</p>
<p>为什么要乘上 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{n}\)</span>？因为样本均值的波动规模大约是 <span displaypfx="inline-" class="mathjax-container">\(\sigma/\sqrt{n}\)</span>：样本越多，均值越稳定。如果不乘 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{n}\)</span>，当 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 增大时， <span displaypfx="inline-" class="mathjax-container">\(\bar{X}-\mu\)</span> 会越来越接近 0，看不出其分布形状；乘上 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{n}\)</span> 后，波动被拉回到常数量级，才会显现出稳定的正态极限。</p>
<p>继续看硬币例子。设 <span displaypfx="inline-" class="mathjax-container">\(X_i\in\{0,1\}\)</span> 且正面概率为 0.5，则 <span displaypfx="inline-" class="mathjax-container">\(\mu=0.5\)</span>， <span displaypfx="inline-" class="mathjax-container">\(\sigma^2=0.25\)</span>，标准差 <span displaypfx="inline-" class="mathjax-container">\(\sigma=0.5\)</span>。CLT 说的是：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\sqrt{n}(\bar{X}-0.5)}{0.5}\Rightarrow \mathcal{N}(0,1)\]</span>
<p>例如当 <span displaypfx="inline-" class="mathjax-container">\(n=100\)</span> 时，正面比例 <span displaypfx="inline-" class="mathjax-container">\(\bar{X}\)</span> 的标准差大约是 <span displaypfx="inline-" class="mathjax-container">\(0.5/\sqrt{100}=0.05\)</span>。这意味着正面比例通常会落在 <span displaypfx="inline-" class="mathjax-container">\(0.5\pm 0.1\)</span> 这一量级附近；更精确地，用正态近似可写成</p>
<span displaypfx="" class="mathjax-container">\[\bar{X}\approx \mathcal{N}\left(0.5,\frac{0.25}{100}\right)=\mathcal{N}(0.5,0.0025)\]</span>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-clt.png"><img class="alignnone size-full wp-image-40929" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot-clt.png" alt="plot-clt" width="100%" /></a></p>
<p>上图是样本量分别为1, 5, 20, 100的Bernoulli(0.5)分布，分别进行了30000轮测试，绘制的（每轮）标准化后的分布，可以看到样本量足够多时，其均值倾向于向中心（未标准化前的0.5对应此伯努利分布的总体均值）靠近。</p>
<p>所以 CLT 回答的是“为什么很多统计量在样本足够大时会近似高斯”，而 LLN 回答的是“为什么样本平均会逼近真值”。前者给出分布近似，后者给出收敛结论。置信区间、显著性检验、A/B test 和 mini-batch 梯度噪声分析，主要依赖的是 CLT 提供的近似正态性。</p>
<div class="blog_h3"><span class="graybg">置信度与置信区间</span></div>
<p>置信度（Confidence Level）与置信区间（Confidence Interval）属于统计推断（Statistical Inference）中的区间估计（Interval Estimation）。它们处理的不是“样本本身长什么样”，而是<span style="background-color: #c0c0c0;">在只看到一批有限样本时，怎样给未知总体参数画出一个有覆盖保证的范围</span>。在机器学习实验、A/B test、离线评测和指标报表里，样本均值、准确率、点击率、转化率和误差率后面常跟着一个区间，这个区间就是区间估计的产物。</p>
<p>若目标是估计总体均值 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span>，样本均值为 <span displaypfx="inline-" class="mathjax-container">\(\bar{X}\)</span>，且样本量足够大或总体近似正态，那么基于中心极限定理，可以构造近似的双侧置信区间：</p>
<span displaypfx="" class="mathjax-container">\[\bar{X}\pm z_{1-\alpha/2}\frac{\sigma}{\sqrt{n}}\]</span>
<p>若总体标准差 <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 未知，而样本来自正态总体或样本量适中且使用样本标准差 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 近似，则常写作：</p>
<span displaypfx="" class="mathjax-container">\[\bar{X}\pm t_{1-\alpha/2,\,n-1}\frac{s}{\sqrt{n}}\]</span>
<p>这里每个元素的含义分别是：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\bar{X}\)</span>：样本均值（Sample Mean），是当前样本对总体均值的点估计（Point Estimate）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(n\)</span>：样本量（Sample Size）。样本越多，区间通常越窄。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span>：总体标准差（Population Standard Deviation）；若未知，常用样本标准差 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 替代。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\frac{\sigma}{\sqrt{n}}\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\frac{s}{\sqrt{n}}\)</span>：标准误（Standard Error），表示样本均值本身的波动尺度，而不是单个样本点的波动。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span>：显著性水平（Significance Level）。若置信度为 95%，则 <span displaypfx="inline-" class="mathjax-container">\(\alpha=0.05\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(z_{1-\alpha/2}\)</span>：标准正态分布的分位数（Quantile）；95% 置信度时大约是 <span displaypfx="inline-" class="mathjax-container">\(1.96\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(t_{1-\alpha/2,\,n-1}\)</span>：Student t 分布的分位数，带有 <span displaypfx="inline-" class="mathjax-container">\(n-1\)</span> 个自由度（Degrees of Freedom），用于总体方差未知时。</li>
</ul>
<p>在这个表达式里，<span style="background-color: #c0c0c0;">置信区间</span> 指的是最终得到的区间本身，例如 <span displaypfx="inline-" class="mathjax-container">\([1.8,2.4]\)</span>；<span style="background-color: #c0c0c0;">置信度</span> 指的是构造这个区间的方法在重复抽样下的长期覆盖率，例如 95%、99%。因此，95% 置信区间的严格含义是：如果用同一种抽样方式和同一种区间构造公式反复重复实验，那么大约 95% 的区间会覆盖真实参数。</p>
<p>这一定义有一个极其重要的解释边界。总体参数 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 在经典频率学派（Frequentist）统计里被视为固定但未知的常数；随机的是样本，因此随机的是区间端点。样本一旦观察完成，这次得到的区间要么覆盖 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span>，要么不覆盖，概率意义已经不再施加在参数本身上。于是，95% 置信度描述的是<span style="background-color: #c0c0c0;">区间构造程序的长期可靠性</span>，而不是“参数有 95% 概率落在这次区间里”的后验概率表述。</p>
<p>置信度并不局限于连续分布。它的定义依赖的是“重复抽样下的覆盖率”，而不是总体分布是否连续。对伯努利分布（Bernoulli Distribution）、二项分布（Binomial Distribution）、泊松分布（Poisson Distribution）这类离散分布，同样可以对参数构造 95% 或 99% 的区间估计。例如对二项分布中的成功概率 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>，就可以根据观测到的成功次数构造 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 的置信区间；若参数或目标对象不是一维连续量，更宽泛的名称则是置信集合（Confidence Set）。</p>
<p>离散分布与连续分布的差别不在“能不能谈置信度”，而在于区间构造的细节。连续情形下，区间端点可以较平滑地调节到目标覆盖率附近；离散情形下，统计量只能取离散值，覆盖率往往无法恰好等于名义值，例如正好 95%，而常常只能做到“不低于 95%”。因此，离散分布里的精确区间常带有一定保守性（Conservativeness）：区间更宽一些，但覆盖保证更稳。这也是为什么在离散统计推断里，除了近似正态区间外，还常见 Clopper–Pearson 这类精确区间构造方法。</p>
<p>一个具体例子最容易说明这两个概念。设某模型在 100 次独立测试中的平均准确率估计为 <span displaypfx="inline-" class="mathjax-container">\(\bar{X}=0.82\)</span>，样本标准差为 <span displaypfx="inline-" class="mathjax-container">\(s=0.10\)</span>。若采用近似 95% 置信区间，可写成：</p>
<span displaypfx="" class="mathjax-container">\[0.82\pm 1.96\times \frac{0.10}{\sqrt{100}}=0.82\pm 0.0196\]</span>
<p>于是区间约为：</p>
<span displaypfx="" class="mathjax-container">\[[0.8004,\ 0.8396]\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\([0.8004,0.8396]\)</span> 是置信区间，95% 是置信度。它表示：按这种抽样和构造方式长期重复下去，约 95% 的此类区间会覆盖真实平均准确率；这次具体得到的这个区间只是其中一个实现结果。</p>
<p>区间宽度主要由三个因素共同决定：</p>
<ul>
<li>样本波动越大，即 <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 越大，区间越宽。</li>
<li>样本量越大，即 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 越大，标准误越小，区间越窄。</li>
<li>置信度越高，例如从 95% 提高到 99%，对应分位数更大，区间也会变宽。</li>
</ul>
<p>因此，置信区间不是“模型不确定性的一个装饰性数字”，而是在样本有限时对参数估计可靠性的定量表达；而置信度则是这套区间构造方法愿意给出的覆盖承诺。两者必须一起理解：只有区间没有置信度，范围没有统计保证；只有置信度没有区间，覆盖承诺也无法落到具体参数估计上。</p>
<div class="blog_h2"><span class="graybg">随机过程</span></div>
<p>前面讨论的随机变量（Random Variable）通常只对应一次不确定试验，例如抛一次硬币、测一次身高、抽取一个样本标签。随机过程（Stochastic Process）则讨论<span style="background-color: #c0c0c0;">一串彼此有关、按时间或空间索引起来的随机变量</span>。它适合描述会随时间演化的不确定系统，例如天气变化、股价波动、队列长度、用户行为序列、传感器读数，以及自然语言中的 token 序列。</p>
<p>形式上，随机过程可记为：</p>
<span displaypfx="" class="mathjax-container">\[\{X_t\}_{t\in T}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 是索引集合（Index Set），常表示时间； <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span> 是时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的随机变量。若 <span displaypfx="inline-" class="mathjax-container">\(T=\{0,1,2,\dots\}\)</span>，过程是离散时间（Discrete-time）的；若 <span displaypfx="inline-" class="mathjax-container">\(T=[0,\infty)\)</span>，过程则是连续时间（Continuous-time）的。每个 <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span> 都有自己的分布，但更重要的是这些随机变量之间的联合分布（Joint Distribution）与依赖结构，因为随机过程关心的不是“某一时刻单独会怎样”，而是“整个序列怎样一起变化”。</p>
<p>从结果形态看，一次随机过程的实现不再是一个点，而是一条轨迹（Trajectory）或样本路径（Sample Path）。例如，设 <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 天的气温，那么一次观测得到的是一整段温度序列 <span displaypfx="inline-" class="mathjax-container">\((X_1,X_2,\dots,X_T)\)</span>；若 <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span> 表示语言模型在位置 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 生成的 token，那么一次实现就是一整句文本。</p>
<p>随机过程之所以在机器学习里重要，是因为很多任务天生就是时序问题。时间序列预测关心未来值如何演化，隐马尔可夫模型（Hidden Markov Model, HMM）关心隐藏状态如何随时间转移，强化学习关心状态—动作—奖励序列怎样展开，自回归语言模型则是在建模 token 序列的联合概率。因此，随机过程可以看作“把单个随机变量扩展到整条时间轴上的概率建模语言”。</p>
<div class="blog_h3"><span class="graybg">马尔可夫性与马尔可夫过程</span></div>
<p>马尔可夫性（Markov Property）讨论的是随机过程中的“记忆如何被压缩”。它刻画这样一种情形：<span style="background-color: #c0c0c0;">如果当前状态已经把与未来演化有关的信息概括完整，那么预测下一步时就不再需要显式回看更久的历史</span>。在这种表示下，过去对未来的影响已经通过当前状态被浓缩进来了。</p>
<p>设随机过程为 <span displaypfx="inline-" class="mathjax-container">\(\{X_t\}_{t=0}^{\infty}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span> 表示时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的随机状态。若它满足一阶马尔可夫性，则有：</p>
<span displaypfx="" class="mathjax-container">\[P(X_{t+1}\mid X_t,X_{t-1},\dots,X_0)=P(X_{t+1}\mid X_t)\]</span>
<p>这条式子的含义是：在已经知道当前状态 <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span> 的前提下，再额外知道更早的历史 <span displaypfx="inline-" class="mathjax-container">\(X_{t-1},\dots,X_0\)</span>，不会改变对下一时刻 <span displaypfx="inline-" class="mathjax-container">\(X_{t+1}\)</span> 的条件分布判断。左边是“条件在完整历史上的下一步分布”，右边是“条件在当前状态上的下一步分布”；两者相等，正是马尔可夫性的定义。</p>
<p>一个直接例子是天气变化。若把每天的天气记作随机变量 <span displaypfx="inline-" class="mathjax-container">\(X_t\)</span>，状态空间为 <span displaypfx="inline-" class="mathjax-container">\(\{\text{晴},\text{阴},\text{雨}\}\)</span>，那么常见建模假设是：明天是否下雨主要由今天的天气决定，而不需要把更早几天的天气逐项保留。此时“当前天气”就扮演了压缩历史信息的状态摘要。若今天是雨天，明天继续下雨的概率可以较大；若今天是晴天，明天下雨的概率可以较小。这种“只通过当前状态决定下一步分布”的结构，就是马尔可夫性。</p>
<p>满足马尔可夫性的随机过程，称为马尔可夫过程（Markov Process）或马尔可夫链（Markov Chain，离散时间、有限或可数状态时的常见名称）。因此，马尔可夫性是一个性质，马尔可夫过程是满足该性质的一类随机过程。若状态空间有限，常把一步转移概率写成转移矩阵（Transition Matrix） <span displaypfx="inline-" class="mathjax-container">\(P\)</span>，其中：</p>
<span displaypfx="" class="mathjax-container">\[P_{ij}=P(X_{t+1}=j\mid X_t=i)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(P_{ij}\)</span> 表示“当前在状态 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 时，下一步转移到状态 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的概率”。矩阵的每一行对应当前状态固定后的下一步分布，因此每一行元素都非负，且行和为 1。</p>
<p>马尔可夫过程之所以重要，是因为它把一个潜在上非常复杂的时序依赖问题，压缩成了局部转移规律。隐马尔可夫模型（Hidden Markov Model, HMM）、马尔可夫决策过程（Markov Decision Process, MDP）、很多时间序列状态模型，以及强化学习中的环境状态转移，都建立在这一思想之上。它们的差别不在于是否使用马尔可夫性，而在于：状态是否可见、是否存在动作、是否附带奖励，以及是否只关心下一步还是关心长期回报。</p>
<p>马尔可夫性是一种建模假设，不是自然界自动保证的真理。若“当前状态”定义得不充分，历史信息就没有被真正压缩进去，此时过程在这个状态表示下就不具有马尔可夫性。例如，仅用“当前股价”预测明天走势通常不够，因为成交量、市场情绪、宏观事件等信息并未包含在状态中；但若把更完整的市场状态向量一并纳入，马尔可夫近似会更合理。因此，马尔可夫性的关键不只是公式，而是<span style="background-color: #c0c0c0;">当前状态是否足以成为过去对未来影响的充分摘要</span>。</p>
<div class="blog_h2"><span class="graybg">信息论</span></div>
<div class="blog_h3"><span class="graybg">熵（Entropy）</span></div>
<p>熵（Entropy）刻画一个概率分布的不确定性（Uncertainty）。对离散分布 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[H(p)=-\sum_i p_i\log p_i\]</span>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(-\log p(x)\)</span> 理解为“事件 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的信息量（Information Content）/惊奇”，熵就是它的期望值：越均匀的分布越难预测，熵越大；越尖锐（某个事件概率接近 1）的分布越确定，熵越小。</p>
<p>例：若 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个类别上均匀（<span displaypfx="inline-" class="mathjax-container">\(p_i=1/K\)</span>），则</p>
<span displaypfx="" class="mathjax-container">\[H(p)=\log K\]</span>
<p>对数底决定单位：以 <span displaypfx="inline-" class="mathjax-container">\(\log_2\)</span> 为底单位是比特（Bits），以自然对数为底单位是纳特（Nats）。</p>
<p>语言也能帮助建立对熵的直觉。以字符为单位时，汉字集合更大，单个常用字符的平均出现概率往往更低，因此 <span displaypfx="inline-" class="mathjax-container">\(-\log p(x)\)</span> 更大；同时，汉语序列里的下一字符通常也更难直接预测，所以常给人“字符级信息更密、熵更高”的直觉。对比之下，韩语的字符系统更小，序列模式也通常更规则，下一字符更容易预测，因此字符级冗余感更强、熵的直觉更低。</p>
<p>若再做一个极粗略的字符级估算：把常用汉字集合近似看作 <span displaypfx="inline-" class="mathjax-container">\(V=3000\)</span>、把韩文字母集合近似看作 <span displaypfx="inline-" class="mathjax-container">\(V=40\)</span>，并暂时假设“下一字符”在各自词表上均匀分布，则最大熵分别约为 <span displaypfx="inline-" class="mathjax-container">\(\log_2 3000\approx 11.55\)</span> bits 与 <span displaypfx="inline-" class="mathjax-container">\(\log_2 40\approx 5.32\)</span> bits。这对应的就是：词表越大、单符号平均概率越低，单位符号的潜在信息量上界越高。</p>
<div class="blog_h3"><span class="graybg">KL 散度</span></div>
<p>KL 散度（Kullback–Leibler Divergence）衡量两个分布之间的“相对熵”（Relative Entropy）。对离散分布 <span displaypfx="inline-" class="mathjax-container">\(p,q\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[D_{\mathrm{KL}}(p\|q)=\sum_i p_i\log\frac{p_i}{q_i}\]</span>
<p>它满足 <span displaypfx="inline-" class="mathjax-container">\(D_{\mathrm{KL}}(p\|q)\ge 0\)</span>，且当且仅当 <span displaypfx="inline-" class="mathjax-container">\(p=q\)</span> 时取 0；但它一般不对称（Asymmetric），即 <span displaypfx="inline-" class="mathjax-container">\(D_{\mathrm{KL}}(p\|q)\ne D_{\mathrm{KL}}(q\|p)\)</span>。信息论解释是：用 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 的码本去编码来自 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 的样本，会额外多付出多少平均码长。</p>
<p>在机器学习里，KL 常作为正则项出现，例如把新策略/新模型约束在参考分布附近（KL Regularization）。</p>
<div class="blog_h3"><span class="graybg">交叉熵</span></div>
<p>交叉熵（Cross Entropy）是信息论里衡量“用分布 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 去编码来自分布 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 的样本”所需平均信息量的量。这里 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是真实分布（True Distribution）/数据分布， <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 是模型分布（Model Distribution）/预测分布：</p>
<span displaypfx="" class="mathjax-container">\[H(p,q)=-\sum_{i} p_i\log q_i\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 固定时，最小化 <span displaypfx="inline-" class="mathjax-container">\(H(p,q)\)</span> 等价于最小化 KL 散度（Kullback–Leibler Divergence）：</p>
<span displaypfx="" class="mathjax-container">\[H(p,q)=H(p)+D_{\mathrm{KL}}(p\|q)\]</span>
<p>分类问题里，真实标签通常用 one-hot 分布表示，这会把交叉熵简化成对“真实类别概率”的负对数：交叉熵损失（Cross-Entropy Loss）本质上就是负对数似然（Negative Log-Likelihood）。</p>
<p>“信息量为什么和概率有关？”因为在最优编码（Optimal Coding）里，一个事件 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的最短平均码长与 <span displaypfx="inline-" class="mathjax-container">\(-\log p(x)\)</span> 同阶；以 <span displaypfx="inline-" class="mathjax-container">\(\log_2\)</span> 为底时单位是比特（Bits），以自然对数为底时单位是纳特（Nats）。熵（Entropy）就是期望信息量。</p>
<p>一个具体例子：设真实分布 <span displaypfx="inline-" class="mathjax-container">\(p=(0.5,0.5)\)</span>，模型分布 <span displaypfx="inline-" class="mathjax-container">\(q=(0.9,0.1)\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[H(p,q)=-0.5\log 0.9-0.5\log 0.1\approx 1.204\ (\text{nats})\]</span>
<p>而真实熵 <span displaypfx="inline-" class="mathjax-container">\(H(p)=-0.5\log 0.5-0.5\log 0.5\approx 0.693\)</span>，两者的差就是 <span displaypfx="inline-" class="mathjax-container">\(D_{\mathrm{KL}}(p\|q)\approx 0.511\)</span>：模型越“错得自信”，KL 越大：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/plot_entropy_cross.png"><img class="alignnone size-full wp-image-40943" src="https://blog.gmem.cc/wp-content/uploads/2026/03/plot_entropy_cross.png" alt="plot_entropy_cross" width="100%" /></a></p>
<div class="blog_h4"><span class="graybg">惊奇（Surprise）</span></div>
<p>惊奇（Surprise）/信息量是针对单个事件的度量：若事件 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 的概率为 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Surprise}(x)=-\log p(x)\]</span>
<p>概率越小，惊奇越大；概率越大，惊奇越小。在语言建模里，若当前模型参数已经固定，那么真实 token 的 <span displaypfx="inline-" class="mathjax-container">\(-\log p_\theta(y_t|c_t)\)</span> 本质上是该 token 的负对数概率，也就是它的“惊奇”。这里 <span displaypfx="inline-" class="mathjax-container">\(c_t\)</span> 表示当前位置之前的上下文， <span displaypfx="inline-" class="mathjax-container">\(y_t\)</span> 表示当前位置真实出现的 token。若把同一个式子看成关于参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的函数，它又是该 token 对参数的负对数似然（Negative Log-Likelihood, NLL）。因此这里的“惊奇”和 token-level NLL 在数值上是同一个对象，只是视角不同。如果再按各个事件的真实概率对这种“惊奇”做加权平均，得到的就是熵（Entropy）：<span displaypfx="inline-" class="mathjax-container">\(H(X)=\mathbb{E}[-\log p(X)]\)</span>。换句话说，<span style="background-color: #c0c0c0;">惊奇是单个事件的信息量，熵是这种信息量在整个分布下的期望</span>。</p>
<div class="blog_h4"><span class="graybg">困惑度（Perplexity）</span></div>
<p>困惑度（Perplexity）把“平均惊奇”指数化，得到一个更直观的尺度：可把它理解为模型在每一步预测时的“有效分支数（Effective Branching Factor）”。对长度为 <span displaypfx="inline-" class="mathjax-container">\(N\)</span> 的 token 序列，模型给出的条件概率是 <span displaypfx="inline-" class="mathjax-container">\(q(x_t|x_{&lt;t})\)</span>，则平均 NLL（也就是交叉熵）为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{NLL}=-\frac{1}{N}\sum_{t=1}^{N}\log q(x_t|x_{&lt;t})\]</span>
<p>若使用自然对数，困惑度定义为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{PPL}=\exp(\mathrm{NLL})\]</span>
<p>因此：分布越均匀（更不确定），熵越高，平均惊奇越大，困惑度也越高。注意困惑度强烈依赖 tokenization 与评测语料，跨不同分词器/词表直接比较往往没有意义。</p>
<div class="blog_h1"><span class="graybg">常用算法</span></div>
<div class="blog_h2"><span class="graybg">基础数据结构和算法</span></div>
<p>这一节处理的核心问题是：当面对搜索、更新、统计、调度、最短路径、依赖分析或训练流水线等任务时，数据应该怎样组织，操作应该怎样执行，才能既正确又高效。数据结构（Data Structure）决定“数据在内存里如何表示”，算法（Algorithm）决定“在这种表示上如何完成查询、插入、删除、遍历、排序与优化”。很多系统性能问题，本质上不是算力不足，而是底层组织方式与操作方式不匹配。</p>
<p>可以把它理解成“仓库布局与搬运规则”的组合：同样一批货物，若排成连续货架、串成链式节点、组织成树状目录，或连接成路网，后续的查找、插入、合并与运输成本会完全不同。现代 AI 工程虽然把注意力集中在模型上，但数据加载器、特征流水线、参数缓存、向量检索、计算图调度、图学习和索引系统，最终都建立在这些基础结构之上。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">结构 / 算法</td>
<td style="text-align: center;">核心能力</td>
<td style="text-align: center;">典型复杂度</td>
<td style="text-align: center;">常见场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>数组 / 动态数组</td>
<td>按下标随机访问；顺序扫描效率高</td>
<td>访问 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>；中间插入/删除 <span displaypfx="inline-" class="mathjax-container">\(O(n)\)</span></td>
<td>张量、批数据、embedding、排序、滑动窗口</td>
</tr>
<tr>
<td>链表</td>
<td>已知位置后插入/删除代价低</td>
<td>局部插删 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>；查找 <span displaypfx="inline-" class="mathjax-container">\(O(n)\)</span></td>
<td>LRU、任务拼接、频繁重排的序列</td>
</tr>
<tr>
<td>栈（Stack）</td>
<td>后进先出（LIFO）</td>
<td>push / pop / top 均为 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span></td>
<td>递归展开、表达式解析、单调栈</td>
</tr>
<tr>
<td>队列（Queue）</td>
<td>先进先出（FIFO）</td>
<td>enqueue / dequeue 均为 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span></td>
<td>BFS、任务队列、流式缓冲</td>
</tr>
<tr>
<td>哈希表（Hash Table）</td>
<td>按键快速索引</td>
<td>平均查找/插入/删除 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span></td>
<td>字典、词表、缓存、去重</td>
</tr>
<tr>
<td>Bloom Filter</td>
<td>近似集合成员查询</td>
<td>插入/查询均为 <span displaypfx="inline-" class="mathjax-container">\(O(k)\)</span></td>
<td>缓存预检查、去重预过滤、存储层键存在性判断</td>
</tr>
<tr>
<td>树（Tree）</td>
<td>表达层次关系与有序结构</td>
<td>平衡查找常为 <span displaypfx="inline-" class="mathjax-container">\(O(\log n)\)</span></td>
<td>索引、优先队列、前缀匹配、规则分裂</td>
</tr>
<tr>
<td>图（Graph）</td>
<td>表达任意对象之间的关系</td>
<td>遍历通常为 <span displaypfx="inline-" class="mathjax-container">\(O(|V|+|E|)\)</span></td>
<td>社交网络、知识图谱、路线规划、依赖分析</td>
</tr>
</tbody>
</table>
<p>复杂度表只给出渐近上界，不能直接替代工程判断。真实系统还要同时考虑缓存友好性（Cache Locality）、常数项、并发开销、内存占用和实现复杂度。例如链表在理论上支持常数时间插入，但它对 CPU 缓存并不友好；数组在理论上中间插入较慢，但顺序扫描极快，因此在现代硬件上经常更有优势。</p>
<div class="blog_h3"><span class="graybg">数组与动态数组</span></div>
<p>数组（Array）处理的核心问题是：当元素类型一致、数量可以按顺序编号时，如何支持最低成本的随机访问与批量扫描。它的关键性质是<span style="background-color: #c0c0c0;">连续内存（Contiguous Memory）</span>。若每个元素大小为 <span displaypfx="inline-" class="mathjax-container">\(s\)</span>，首地址为 <span displaypfx="inline-" class="mathjax-container">\(\text{base}\)</span>，则第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个元素地址为</p>
<span displaypfx="" class="mathjax-container">\[\text{addr}(a_i)=\text{base}+i\cdot s\]</span>
<p>这个式子说明数组访问为何是 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>：位置可以直接计算，不需要沿指针逐步跳转。矩阵、张量、mini-batch、时间序列缓存、本地特征块和 embedding 表中的一行，本质上都依赖这种“地址可算”的结构。</p>
<p>数组的代价也非常明确：若在中间插入或删除元素，后面的元素必须整体搬移，因此复杂度通常是 <span displaypfx="inline-" class="mathjax-container">\(O(n)\)</span>。这意味着数组适合“读多写少、顺序稳定”的任务，不适合“在任意位置频繁插入”的任务。</p>
<p>动态数组（Dynamic Array）是在数组上的工程扩展：容量不足时申请更大的连续空间，把原有元素整体拷贝过去，再继续追加。一次扩容代价很高，但若容量按倍数增长，则追加操作的均摊（Amortized）复杂度仍可视为 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>。Python 的 list、C++ 的 vector、Java 的 ArrayList 都遵循这一思想。</p>
<p>直觉上，数组像按编号排好的货架：拿第 137 件货非常快，但若要把一件货塞进中间，后面整排货物都要整体后移。</p>
<div class="blog_h3"><span class="graybg">链表</span></div>
<p>链表（Linked List）处理的是另一类问题：当序列顺序经常变化时，能否避免数组那样的大规模搬移。链表不要求连续内存，而是让每个节点（Node）保存数据和指向下一个节点的指针（Pointer）；双向链表（Doubly Linked List）还会额外保存前驱指针。</p>
<p>若已经拿到某个节点的位置，那么在其前后插入或删除节点只需要调整局部指针，代价通常是 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>。但链表无法像数组那样通过下标直接定位第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个元素，查找往往必须从头逐个走过去，因此通常是 <span displaypfx="inline-" class="mathjax-container">\(O(n)\)</span>。</p>
<p>链表适合做“结构改动频繁、定位方式不是按下标而是按已有节点句柄”的任务。例如 LRU 缓存中，经常需要把刚访问的元素移到头部；若配合哈希表记录节点位置，链表就能高效完成重排。</p>
<p>链表像一串用绳子串起来的标签。改顺序很方便，但想直接摸到第 500 个标签，就只能沿着绳子一个个数过去。</p>
<div class="blog_h3"><span class="graybg">栈（Stack）</span></div>
<p>栈（Stack）定义的是一种<span style="background-color: #c0c0c0;">后进先出（Last In First Out, LIFO）</span>的访问约束。它处理的问题不是“怎样存更多数据”，而是“怎样强制最近进入的状态最先退出”。典型操作包括入栈 push、出栈 pop 与查看栈顶 top，它们都发生在同一端，因此实现代价通常是 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>。</p>
<p>函数调用栈、递归回溯、表达式求值、括号匹配、深度优先搜索中的显式状态保存，都依赖这种结构。其本质是把“尚未处理完的上下文”按嵌套顺序压起来，等内部任务结束后再按相反顺序恢复。</p>
<p>单调栈（Monotonic Stack）是栈在算法中的重要变体。它通过维护一个单调递增或单调递减的栈，把“下一个更大元素”“柱状图最大矩形”等问题从 <span displaypfx="inline-" class="mathjax-container">\(O(n^2)\)</span> 降到 <span displaypfx="inline-" class="mathjax-container">\(O(n)\)</span>。原因在于每个元素最多入栈和出栈各一次。</p>
<div class="blog_h3"><span class="graybg">队列（Queue）</span></div>
<p>队列（Queue）定义的是<span style="background-color: #c0c0c0;">先进先出（First In First Out, FIFO）</span>的访问约束。进入得早的元素先被处理，后来进入的元素排在尾部等待。它适合表达“任务排队、波前扩张、按到达顺序消费”的过程。</p>
<p>广度优先搜索（Breadth-First Search, BFS）之所以使用队列，正是因为 BFS 要按距离层层扩展：先处理距离起点为 1 的节点，再处理距离为 2 的节点。这个“分层推进”机制与 FIFO 完全一致。</p>
<p>循环队列（Circular Queue）通过把底层数组首尾相连，可以避免频繁搬移；双端队列（Deque）则允许两端都做插入和删除，因此能够支持滑动窗口最值、0-1 BFS 等更复杂的算法模式。</p>
<div class="blog_h3"><span class="graybg">哈希表（Hash Table）</span></div>
<p>哈希表（Hash Table）处理的核心问题是：当数据按“键（Key）”组织，而不是按位置组织时，如何快速找到对应的值（Value）。其思想是先通过哈希函数（Hash Function）把键映射成一个整数，再把这个整数映射到桶（Bucket）或槽位（Slot）上。</p>
<p>若哈希函数分布均匀，且装载因子（Load Factor）控制合理，则查找、插入和删除的平均复杂度都可接近 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>。这正是词表映射、去重、缓存索引、参数名字典和特征 ID 映射大量采用哈希表的原因。</p>
<p>哈希表的难点在冲突（Collision）处理。多个键可能映射到同一位置，常见解决方案包括链地址法（Separate Chaining）和开放定址法（Open Addressing）。因此“哈希表平均 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span>”并不意味着永远常数时间，它依赖于哈希函数质量、负载控制和冲突处理策略。</p>
<div class="blog_h4"><span class="graybg">Bloom Filter（布隆过滤器）</span></div>
<p>Bloom Filter 本质上属于<span style="background-color: #c0c0c0;">概率型数据结构（Probabilistic Data Structure）</span>，更准确地说，是一种近似集合成员查询结构（Approximate Membership Query, AMQ）。它解决的问题并不是“把元素精确存下来”，而是“用极小内存快速判断某元素是否可能出现过”。因此它通常作为哈希表、数据库索引或缓存系统之前的一层预过滤结构。</p>
<p>Bloom Filter 由一个长度为 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 的比特数组 <span displaypfx="inline-" class="mathjax-container">\(B\in\{0,1\}^m\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个哈希函数 <span displaypfx="inline-" class="mathjax-container">\(h_1,\dots,h_k\)</span> 构成，其中每个哈希函数都把元素 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 映射到区间 <span displaypfx="inline-" class="mathjax-container">\(\{0,1,\dots,m-1\}\)</span> 中的一个位置。插入元素 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 时，执行</p>
<span displaypfx="" class="mathjax-container">\[B[h_1(x)]=B[h_2(x)]=\cdots=B[h_k(x)]=1\]</span>
<p>查询元素 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 时，检查 <span displaypfx="inline-" class="mathjax-container">\(B[h_1(x)],\dots,B[h_k(x)]\)</span>。只要其中至少有一个位置为 0，就可以断定 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 一定不在集合中；若这些位置全部为 1，则只能说明 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 可能在集合中。</p>
<p>这一定义直接带来 Bloom Filter 最重要的判定性质：<span style="background-color: #c0c0c0;">它允许假阳性（False Positive），但不允许假阴性（False Negative）</span>。原因在于，不同元素可能把同一批 bit 位置反复置为 1，于是一个从未插入过的元素也可能“碰巧”命中全 1；但只要某个位置仍为 0，就说明没有任何已插入元素覆盖过这条哈希路径，因此该元素一定不存在。</p>
<p>设一共插入了 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 个元素，则某个 bit 在所有插入结束后仍为 0 的概率近似为 <span displaypfx="inline-" class="mathjax-container">\(\left(1-\frac{1}{m}\right)^{kn}\approx e^{-kn/m}\)</span>。于是查询一个未出现元素时， <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个位置恰好都为 1 的假阳性概率近似为</p>
<span displaypfx="" class="mathjax-container">\[p\approx \left(1-e^{-kn/m}\right)^k\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 是 bit 数组长度， <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 是已插入元素数， <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 是哈希函数数目， <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是假阳性概率。这个公式揭示了 Bloom Filter 的基本权衡： <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 越大，冲突越少； <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 越大，数组越接近被“染满”； <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 太小会降低区分能力，太大则会过度占满 bit 位。固定 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 时，常见的近似最优选择是</p>
<span displaypfx="" class="mathjax-container">\[k\approx \frac{m}{n}\ln 2\]</span>
<p>直觉上，Bloom Filter 像一排共享的指示灯。每来一个元素，就按亮若干盏灯；查询时，只要对应灯中有一盏没亮，就可以确认它从未出现过。若全部亮着，也只能说明“这些灯曾被某些元素点亮过”，却不能保证就是当前这个元素点亮的。</p>
<p>Bloom Filter 最适合用于“先快速排除绝大多数不存在项，再把少量可疑项交给精确结构复核”的场景。例如缓存系统可先判断某个 key 是否可能在缓存中，若 Bloom Filter 直接给出“不在”，就可以避免无意义回源；LSM-Tree 存储系统可用它判断某个键是否可能存在于某个 SSTable；爬虫去重、黑名单预过滤、向量检索候选预筛都大量使用这一思想。</p>
<p>Bloom Filter 的边界也很明确。第一，它不保存原始元素，因此不能枚举集合内容，也不能像哈希表那样返回关联值。第二，标准 Bloom Filter 不支持安全删除，因为把某个 bit 清零可能误伤其他元素留下的痕迹；若确实需要删除，通常要改用计数 Bloom Filter（Counting Bloom Filter）。第三，当假阳性代价非常高、系统需要完全精确的成员判断时，应优先使用哈希表、B 树或其他精确索引结构。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/bloom.jpg"><img class="alignnone size-full wp-image-41339" src="https://blog.gmem.cc/wp-content/uploads/2026/03/bloom.jpg" alt="bloom" width="1024" height="559" /></a></p>
<div class="blog_h3"><span class="graybg">树（Tree）</span></div>
<p>树（Tree）处理的是“层次结构”和“递归划分”问题。树中的节点之间具有父子关系，除了根节点（Root）外，每个节点都有唯一父节点。它天然适合表达目录层级、决策分裂、区间划分、优先级组织与前缀共享。</p>
<p>树之所以重要，在于它把原本线性的搜索空间组织成递归结构，使很多操作能通过“向左还是向右”“进入哪个子树”逐步缩小问题规模。若每次都能把候选空间缩小到原来的一半，复杂度就会从线性级下降到对数级。</p>
<div class="blog_h4"><span class="graybg">二叉树与遍历</span></div>
<p>二叉树（Binary Tree）规定每个节点至多有两个孩子。前序遍历（Preorder）、中序遍历（Inorder）、后序遍历（Postorder）和层序遍历（Level-order）分别对应不同的信息读取顺序：前序适合序列化结构，中序适合读取二叉搜索树中的有序键，后序适合先处理子问题再合并，层序适合按深度观察整体形状。</p>
<div class="blog_h4"><span class="graybg">二叉搜索树与平衡树</span></div>
<p>二叉搜索树（Binary Search Tree, BST）在每个节点上保持“左子树键值更小、右子树键值更大”的顺序约束，因此查找、插入和删除都可以沿着比较路径进行。若树高度为 <span displaypfx="inline-" class="mathjax-container">\(h\)</span>，这些操作的复杂度一般与 <span displaypfx="inline-" class="mathjax-container">\(O(h)\)</span> 成正比。</p>
<p>问题在于普通 BST 在极端情况下会退化成链表，此时 <span displaypfx="inline-" class="mathjax-container">\(h=n\)</span>。平衡树（Balanced Tree）如 AVL 树、红黑树（Red-Black Tree）通过旋转（Rotation）维护高度受控，使 <span displaypfx="inline-" class="mathjax-container">\(h=O(\log n)\)</span>，从而把查找、插入和删除稳定在对数复杂度。数据库索引和有序映射容器大量依赖这一思想。</p>
<div class="blog_h4"><span class="graybg">堆与优先队列</span></div>
<p>堆（Heap）不是“完全有序树”，而是只维护局部顺序：在最小堆（Min-Heap）中，每个父节点都不大于子节点，因此根节点始终是全局最小值；最大堆（Max-Heap）则相反。它通常用数组实现，父子下标关系可以直接计算。</p>
<p>堆最适合实现优先队列（Priority Queue）：每次都要快速取出当前最重要、最小或最大的元素时，插入和弹出都只需 <span displaypfx="inline-" class="mathjax-container">\(O(\log n)\)</span>。Dijkstra、A* 搜索、任务调度、Top-K 维护和流式中位数都大量依赖优先队列。</p>
<div class="blog_h4"><span class="graybg">Trie 与前缀结构</span></div>
<p>Trie 树（Prefix Tree）把字符串按前缀共享组织起来。若插入单词集合 <span displaypfx="inline-" class="mathjax-container">\(\{w_1,\dots,w_m\}\)</span>，公共前缀只存一次，因此“是否存在某个前缀”“以某前缀开头的词有多少”都可以沿字符路径直接完成。</p>
<p>Trie 特别适合词典匹配、自动补全、敏感词过滤和子词切分。它牺牲了一部分空间，换来按字符长度而非按词典规模进行搜索的能力。</p>
<div class="blog_h3"><span class="graybg">图（Graph）</span></div>
<p>图（Graph）处理的是最一般的关系结构。若顶点集合为 <span displaypfx="inline-" class="mathjax-container">\(V\)</span>，边集合为 <span displaypfx="inline-" class="mathjax-container">\(E\)</span>，则图可写成 <span displaypfx="inline-" class="mathjax-container">\(G=(V,E)\)</span>。树本质上是图的一个特殊子类，但图允许环、允许多条连接、允许方向和权重，因此能表达社交关系、知识链接、网页跳转、道路网络、依赖图与神经网络计算图。</p>
<p>图的常见表示方式有邻接矩阵（Adjacency Matrix）和邻接表（Adjacency List）。前者适合稠密图，能 <span displaypfx="inline-" class="mathjax-container">\(O(1)\)</span> 判断两点是否相连；后者适合稀疏图，空间复杂度更低，遍历邻居更高效。</p>
<div class="blog_h4"><span class="graybg">BFS 与 DFS</span></div>
<p>广度优先搜索（BFS）与深度优先搜索（DFS）是图遍历的两种基本组织方式。BFS 使用队列按层推进，适合无权最短路、层次扩展与最少步数问题；DFS 使用递归或显式栈沿一条路径尽量走深，适合回溯、环检测、拓扑排序、强连通分量与树形动态规划。</p>
<p>对邻接表表示的图，两者的时间复杂度通常都是 <span displaypfx="inline-" class="mathjax-container">\(O(|V|+|E|)\)</span>。区别不在渐近复杂度，而在访问顺序：BFS 保证按距离层层扩展，DFS 更擅长描述“先深入、后回退”的结构性问题。</p>
<div class="blog_h4"><span class="graybg">最短路径</span></div>
<p>最短路径（Shortest Path）问题处理的是：从起点到终点，总代价最小的路径是什么。若图无权，BFS 就能得到边数最少的路径；若边权非负，常用 Dijkstra 算法。它每次从优先队列中取出当前距离估计最小的顶点，并尝试松弛（Relax）相邻边。</p>
<p>Dijkstra 的核心更新为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}[v]=\min\big(\mathrm{dist}[v],\mathrm{dist}[u]+w(u,v)\big)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{dist}[u]\)</span> 是当前已知的从源点到 <span displaypfx="inline-" class="mathjax-container">\(u\)</span> 的最短距离估计， <span displaypfx="inline-" class="mathjax-container">\(w(u,v)\)</span> 是边权。这个公式表达的是最短路的本质：若“先到 <span displaypfx="inline-" class="mathjax-container">\(u\)</span>，再走到 <span displaypfx="inline-" class="mathjax-container">\(v\)</span>”更便宜，就更新对 <span displaypfx="inline-" class="mathjax-container">\(v\)</span> 的距离认知。</p>
<div class="blog_h4"><span class="graybg">拓扑排序</span></div>
<p>拓扑排序（Topological Sort）处理的是有向无环图（Directed Acyclic Graph, DAG）中的依赖顺序。若边 <span displaypfx="inline-" class="mathjax-container">\(u\to v\)</span> 表示“<span displaypfx="inline-" class="mathjax-container">\(u\)</span> 必须先于 <span displaypfx="inline-" class="mathjax-container">\(v\)</span>”，那么拓扑序就是一种满足全部先后约束的线性排列。</p>
<p>课程先修关系、编译依赖、工作流调度、神经网络计算图执行次序，本质上都属于这一问题。拓扑排序的价值不只是“排出一个顺序”，而是把依赖图转成一条能够实际执行的流水线。</p>
<div class="blog_h4"><span class="graybg">最小生成树（Minimum Spanning Tree, MST）</span></div>
<p>最小生成树（Minimum Spanning Tree, MST）处理的是这样的问题：给定一个连通无向带权图 <span displaypfx="inline-" class="mathjax-container">\(G=(V,E)\)</span>，需要从边集合 <span displaypfx="inline-" class="mathjax-container">\(E\)</span> 中选出一部分边，把所有顶点连成一个整体，同时不产生环，并使总权重最小。若把所有生成树的集合记为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{T}(G)\)</span>，则标准形式可以写成</p>
<span displaypfx="" class="mathjax-container">\[T^*=\arg\min_{T\in \mathcal{T}(G)}\sum_{e\in T} w(e)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(T^*\)</span> 是最优生成树；<span displaypfx="inline-" class="mathjax-container">\(w(e)\)</span> 是边 <span displaypfx="inline-" class="mathjax-container">\(e\)</span> 的权重；<span displaypfx="inline-" class="mathjax-container">\(\sum_{e\in T} w(e)\)</span> 表示树中全部边的总代价。这里的“生成树”有三个同时成立的约束：第一， <span displaypfx="inline-" class="mathjax-container">\(T\subseteq E\)</span>；第二，图在边集 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 下必须连通；第三， <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 不能含环，因此边数必然满足 <span displaypfx="inline-" class="mathjax-container">\(|T|=|V|-1\)</span>。</p>
<p>这个定义明确了 MST 不是“找一棵看上去便宜的树”，而是在<span style="background-color: #c0c0c0;">所有能够覆盖全部顶点的无环连通方案</span>中做全局最小化。只强调“连通”会多出冗余边，只强调“边权小”又可能导致图不连通；MST 同时满足这两个条件。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/mst-graph.png"><img class="alignnone size-full wp-image-41335" src="https://blog.gmem.cc/wp-content/uploads/2026/03/mst-graph.png" alt="mst-graph" width="6242" height="2905" /></a></p>
<p>直觉上，可以把 MST 理解成“以最低总造价把一组城市接通，但不修多余的回路”。如果形成了环，说明这条网络中至少有一条边是重复支出；如果某些城市没有接入，说明方案根本不可用。MST 就是在“全覆盖”和“最低成本”之间取得最紧的平衡。</p>
<p>MST 成立的核心理论基础是<span style="background-color: #c0c0c0;">割性质（Cut Property）</span>：把顶点集切成两个不相交部分后，跨越这个切分的最小权边，一定存在于某棵最小生成树中。这个性质的含义是：局部最便宜的“安全边（Safe Edge）”可以被逐步加入，而不会破坏全局最优性。Prim 与 Kruskal 虽然组织方式不同，但本质上都在不断选择这样的安全边。</p>
<p>Prim 算法的思路是“从一个起点向外生长一棵树”。设当前已经纳入树中的顶点集合为 <span displaypfx="inline-" class="mathjax-container">\(S\)</span>，则 Prim 每一步都在所有满足 <span displaypfx="inline-" class="mathjax-container">\(u\in S,\ v\notin S\)</span> 的边中，选择权重最小的一条，把新顶点接入当前树。这个过程像不断把新城市接入已经建好的主干网，因此特别适合用优先队列维护“当前边界上最便宜的边”。若图用邻接表存储并配合二叉堆实现优先队列，时间复杂度通常为 <span displaypfx="inline-" class="mathjax-container">\(O(|E|\log |V|)\)</span>。</p>
<p>Kruskal 算法的思路是“按全图范围从便宜到昂贵依次选边”。它先对所有边按权重升序排序，然后从小到大扫描：若当前边连接的是两个不同连通块，就把它加入结果；若会在当前结构中形成环，就跳过。为了高效判断“两个端点是否已经连通”，Kruskal 通常配合并查集（Disjoint Set Union, DSU）。排序代价主导总复杂度，因此复杂度通常写成 <span displaypfx="inline-" class="mathjax-container">\(O(|E|\log |E|)\)</span>，与 <span displaypfx="inline-" class="mathjax-container">\(O(|E|\log |V|)\)</span> 在数量级上接近。</p>
<p>两种算法解决的是同一个优化问题，但适合的工程语境不同。Prim 更像“从局部网络不断扩张”，适合稠密图或从某个核心节点逐步向外建设的场景；Kruskal 更像“全局看所有候选边，再逐一合并连通块”，在边集天然可排序、图较稀疏时实现尤其直接。</p>
<p>一个最小例子可以把公式和过程连起来。设四个顶点 <span displaypfx="inline-" class="mathjax-container">\(A,B,C,D\)</span>，边权为： <span displaypfx="inline-" class="mathjax-container">\(w(A,B)=1\)</span>， <span displaypfx="inline-" class="mathjax-container">\(w(B,C)=2\)</span>， <span displaypfx="inline-" class="mathjax-container">\(w(A,C)=4\)</span>， <span displaypfx="inline-" class="mathjax-container">\(w(B,D)=3\)</span>， <span displaypfx="inline-" class="mathjax-container">\(w(C,D)=5\)</span>。Kruskal 会先按边权排序： <span displaypfx="inline-" class="mathjax-container">\((A,B),(B,C),(B,D),(A,C),(C,D)\)</span>。前 3 条边分别把 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(C\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 连起来，此时已经得到 <span displaypfx="inline-" class="mathjax-container">\(|V|-1=3\)</span> 条边，且图连通无环，于是生成树为</p>
<span displaypfx="" class="mathjax-container">\[T=\{(A,B),(B,C),(B,D)\}\]</span>
<p>其总代价为</p>
<span displaypfx="" class="mathjax-container">\[\sum_{e\in T}w(e)=1+2+3=6\]</span>
<p>若改选边集 <span displaypfx="inline-" class="mathjax-container">\(\{(A,B),(A,C),(B,D)\}\)</span>，总代价是 <span displaypfx="inline-" class="mathjax-container">\(1+4+3=8\)</span>；若再加入 <span displaypfx="inline-" class="mathjax-container">\((B,C)\)</span>，虽然成本局部看不高，但边数会超过 <span displaypfx="inline-" class="mathjax-container">\(|V|-1\)</span> 并形成环，因此不再是树。这个例子把“最低成本”“连通”“无环”三项约束如何同时生效展示得很清楚。</p>
<p>MST 常见于网络布线、电力传输、骨架路网设计、图像分割、聚类和图压缩。层次聚类中的单链接（Single Linkage）就可以通过图的最小生成树来理解：先把点看成顶点，把样本间距离看成边权，再在 MST 上剪断最长的若干条边，就得到若干连通簇。这也是为什么 MST 不只是图论题型，而是很多数据分析与机器学习方法的底层结构。</p>
<p>MST 也有明确边界。它只适用于<span style="background-color: #c0c0c0;">无向、连通、带权</span>图上的“全连通最低总成本”问题；若任务要求的是“从源点到其余点的最短路”，应使用最短路径算法；若图有方向，目标就不再是普通 MST，而会进入最小树形图（Minimum Arborescence）等更复杂的问题。</p>
<div class="blog_h2"><span class="graybg">动态规划（Dynamic Programming, DP）</span></div>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>动态规划（Dynamic Programming, DP）处理的是这样一类问题：目标是求一个全局最优值、最优路径，或所有路径的总和，但如果直接把所有可能性全部枚举出来，计算量会迅速爆炸。它常见于序列决策、路径规划、字符串匹配、图搜索，以及隐马尔可夫模型（HMM）、条件随机场（CRF）这类结构化预测模型。</p>
<p>这类问题通常有两个共同特征。第一，<span style="background-color: #c0c0c0;">重叠子问题（Overlapping Subproblems）</span>：同一个中间子问题会被反复计算。第二，<span style="background-color: #c0c0c0;">最优子结构（Optimal Substructure）</span>：大问题的最优解可以由小问题的最优解递推得到。例如，在长度为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 的序列上，若每一步有 <span displaypfx="inline-" class="mathjax-container">\(|\mathcal{S}|\)</span> 个可能状态，直接枚举所有状态路径往往需要考虑 <span displaypfx="inline-" class="mathjax-container">\(|\mathcal{S}|^T\)</span> 条候选路径；当 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 稍大时，这种暴力方法几乎不可用。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>动态规划的核心不是“把问题拆小”这么简单，而是：<span style="background-color: #c0c0c0;">先定义能够代表子问题的状态，再写出状态之间的递推关系，并把已经算过的结果缓存下来复用</span>。因此，它本质上是一种计算组织方式，而不是某个固定公式。</p>
<p>一个直观比喻是出差换乘。设想要从起点出发，经过很多站点，最终到达目的地。暴力法会把“到达每一站的所有走法”全部记下来；动态规划不会这样做。它只会为每个中间站保留一份最有价值的摘要，例如“到达这个站的最低成本”或“到达这个站的最大得分”。当继续前往下一站时，系统只需要查这份账本，而不必回头展开所有历史路径。</p>
<p>因此，动态规划通常包含四个步骤：定义状态、定义边界条件、写出转移方程、确定计算顺序。状态定义决定“中间结果要存什么”；边界条件决定“第一步从哪里开始”；转移方程决定“当前结果如何从更小问题得到”；计算顺序则保证所有依赖项在使用前已经计算完毕。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/dp.jpg"><img class="alignnone size-full wp-image-41269" src="https://blog.gmem.cc/wp-content/uploads/2026/03/dp.jpg" alt="dp" width="1408" height="768" /></a></p>
<div class="blog_h3"><span class="graybg">为什么局部次优前缀不会漏掉全局最优</span></div>
<p>动态规划能够丢弃大量“暂时看起来不够好”的前缀路径，前提不是“这些路径永远不可能翻盘”，而是<span style="background-color: #c0c0c0;">状态（State）已经完整刻画了未来决策所需的全部信息</span>。一旦这个条件成立，到达同一状态的两条前缀路径，未来能够接上的可行后缀集合完全相同，因此只需要保留其中更优的那一条。</p>
<p>设两条前缀路径都到达同一状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span>，其当前累计代价分别为 <span displaypfx="inline-" class="mathjax-container">\(f_1(s)\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(f_2(s)\)</span>，且 <span displaypfx="inline-" class="mathjax-container">\(f_1(s)\le f_2(s)\)</span>。若从状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 出发，后续任意可行决策产生的附加代价记为 <span displaypfx="inline-" class="mathjax-container">\(g(s)\)</span>，并且这个附加代价只由当前状态决定，而不再依赖此前的完整历史，则有</p>
<span displaypfx="" class="mathjax-container">\[f_1(s)+g(s)\le f_2(s)+g(s)\]</span>
<p>这个不等式表明：在同一状态上，较差的前缀会被较优前缀完全支配（Dominated）。无论后面接哪一段后缀路径，较差前缀都不可能反超。因此 Bellman 最优性原理允许动态规划只保留“到达该状态的最优值”，而不必保留全部历史路径。</p>
<p>所谓“一个当前次优的路径，后来却通向全局最优”，本质上对应另一种情形：这条路径与当前更优路径虽然看起来到达了同一个位置，但它们对未来的可行动作并不相同。此时它们实际上并不属于同一个状态，而是状态定义缺失了关键信息。</p>
<p>一个典型例子是带资源约束的路径规划。若状态只写成当前位置 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span>，那么两条到达同一格子的路径会被合并；但若其中一条还保留一次传送机会，另一条已经把传送用掉，则它们未来的决策空间显然不同。正确的状态应扩展为 <span displaypfx="inline-" class="mathjax-container">\((i,j,\mathrm{used})\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\((i,j,\mathrm{fuel})\)</span> 这类更完整的形式。只有在状态把“剩余资源、上一步动作、已使用预算、是否持仓”等会影响未来的因素都编码进去后，动态规划的剪枝才是安全的。</p>
<p>因此，动态规划处理“局部次优可能导向全局最优”的方式，不是保留所有看起来有潜力的路径，而是通过<span style="background-color: #c0c0c0;">正确设计状态，使真正会影响未来的差异体现在不同状态上</span>。同一状态内部只保留最优前缀；不同状态之间分别递推。动态规划的正确性，最终依赖的正是这一点：未来只依赖当前状态，而不依赖通向当前状态的完整历史。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>若记 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 为阶段或时间步， <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 为当前状态，一个非常典型的动态规划写法是：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{DP}[t,s]=\max_{s'\in \mathrm{Prev}(s)}\left(\mathrm{DP}[t-1,s']+\mathrm{score}(s',s,t)\right)\]</span>
<p>这条式子表达的是：要得到“第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步处于状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 时的最优值”，不必重新枚举所有完整路径，而是只需查看所有能够转移到 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 的前驱状态 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span>，并在它们已有的最优值基础上，加上这一步的局部得分，再从中取最大。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{DP}[t,s]\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步、状态为 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 的最优子问题值。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{Prev}(s)\)</span>：所有可以转移到状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 的前驱状态集合。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{score}(s',s,t)\)</span>：从 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span> 转移到 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 时，在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步新增的局部得分或代价。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\max\)</span>：表示当前任务要找“最好的一条路径”。若任务目标是最小代价，则可改为 <span displaypfx="inline-" class="mathjax-container">\(\min\)</span>；若任务目标是把所有路径概率加总，则可改为 <span displaypfx="inline-" class="mathjax-container">\(\sum\)</span>。</li>
</ul>
<p>边界条件通常写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{DP}[1,s]=\mathrm{init}(s)+\mathrm{local}(s,1)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{init}(s)\)</span> 表示序列从状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 开始的初始代价或初始分数， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{local}(s,1)\)</span> 表示第一步在该状态产生的局部贡献。没有这个起点，后续递推就无从展开。</p>
<p>动态规划真正带来的收益来自复杂度压缩。以一阶序列模型为例，若每一步有 <span displaypfx="inline-" class="mathjax-container">\(|\mathcal{S}|\)</span> 个候选状态、总长度为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span>，暴力枚举往往需要考虑 <span displaypfx="inline-" class="mathjax-container">\(|\mathcal{S}|^T\)</span> 条完整路径；而若采用“时间步 + 当前状态”的动态规划状态定义，则通常只需在每个时间步枚举所有前驱状态，计算复杂度可以降为 <span displaypfx="inline-" class="mathjax-container">\(O(T|\mathcal{S}|^2)\)</span>。这种从指数级到多项式级的下降，正是动态规划在序列模型中不可替代的原因。</p>
<p>若任务不仅要求最优值，还要求恢复最优路径，则通常还会额外保存“当前最优值来自哪个前驱状态”的回溯信息（Backpointer）。这意味着动态规划不仅能回答“最优值是多少”，还能回答“这条最优路径具体怎么走”。</p>
<p>更重要的是，动态规划并不只对应一种运算。对于最优路径问题，递推中的核心运算往往是 <span displaypfx="inline-" class="mathjax-container">\(\max\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\min\)</span>；对于总概率、配分函数这类问题，核心运算则是 <span displaypfx="inline-" class="mathjax-container">\(\sum\)</span>。这也是为什么 HMM 的维特比算法、前向算法，以及 CRF 的前向后向算法，虽然目标不同，但都属于动态规划。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>在 HMM 中，动态规划最典型地体现在两类问题上。第一类是维特比算法（Viterbi Algorithm）：它要求“给定观测序列后，哪一条隐藏状态路径最可能”，因此递推中的核心运算是 <span displaypfx="inline-" class="mathjax-container">\(\max\)</span>。第二类是前向算法（Forward Algorithm）：它要求“所有隐藏状态路径合起来，总概率是多少”，因此递推中的核心运算是 <span displaypfx="inline-" class="mathjax-container">\(\sum\)</span>。两者使用的是同一张状态网格，只是“每一步如何聚合前驱信息”不同。</p>
<p>在 CRF 中，动态规划同样是核心计算工具。训练时，需要对所有可能标签路径做归一化，这对应配分函数（Partition Function）的计算；解码时，需要找得分最高的那条标签路径，这对应最优路径搜索。在线性链 CRF 中，这两件事都可以通过“时间步 + 当前标签”的动态规划状态来高效完成，否则若直接枚举所有标签序列，计算量会随序列长度呈指数增长。</p>
<p>因此，在机器学习语境里，动态规划可以概括为：<span style="background-color: #c0c0c0;">把原本必须整体枚举的结构化问题，改写成一系列局部状态上的递推计算，并通过缓存中间结果把重复计算消掉</span>。一旦看到“序列路径很多、局部决策可递推、同类子问题会重复出现”这三个信号，通常就应该优先考虑动态规划。</p>
<div class="blog_h4"><span class="graybg">例子 1：编辑距离（Edit Distance）</span></div>
<p>设字符串 <span displaypfx="inline-" class="mathjax-container">\(A=a_1a_2\dots a_m\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(B=b_1b_2\dots b_n\)</span>。编辑距离要回答的问题是：至少经过多少次插入、删除、替换，才能把 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 变成 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>。若直接枚举所有编辑序列，可能性会指数增长；但若定义 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{DP}[i,j]\)</span> 表示“把 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 的前 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个字符变成 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 的前 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个字符所需的最小编辑次数”，问题就能递推解决。</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{DP}[i,j]=\min\begin{cases}\mathrm{DP}[i-1,j]+1\\ \mathrm{DP}[i,j-1]+1\\ \mathrm{DP}[i-1,j-1]+\mathbf{1}(a_i\ne b_j)\end{cases}\]</span>
<p>这里三项分别对应：删除 <span displaypfx="inline-" class="mathjax-container">\(a_i\)</span>、插入 <span displaypfx="inline-" class="mathjax-container">\(b_j\)</span>、或把 <span displaypfx="inline-" class="mathjax-container">\(a_i\)</span> 替换成 <span displaypfx="inline-" class="mathjax-container">\(b_j\)</span>（若本来相同，则替换代价为 0）。边界条件是 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{DP}[0,j]=j\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathrm{DP}[i,0]=i\)</span>，因为空串变成长度为 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的串需要做 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 次插入，反之需要做 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 次删除。这个例子非常典型地体现了动态规划：状态是前缀长度，转移是三种编辑操作，目标是求最小总代价。</p>
<div class="blog_h4"><span class="graybg">例子 2：网格最短路径（Grid Shortest Path）</span></div>
<p>设一个 <span displaypfx="inline-" class="mathjax-container">\(m\times n\)</span> 网格，每个格子 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 都有进入代价 <span displaypfx="inline-" class="mathjax-container">\(w_{i,j}\)</span>，只能向右或向下移动。问题是：从左上角走到右下角的最小总代价是多少。若定义 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{DP}[i,j]\)</span> 表示“到达格子 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 的最小总代价”，则递推很直接：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{DP}[i,j]=w_{i,j}+\min\big(\mathrm{DP}[i-1,j],\mathrm{DP}[i,j-1]\big)\]</span>
<p>因为到达 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 只有两种可能：从上方 <span displaypfx="inline-" class="mathjax-container">\((i-1,j)\)</span> 走下来，或从左侧 <span displaypfx="inline-" class="mathjax-container">\((i,j-1)\)</span> 走过来。边界条件是第一行与第一列只能沿单一路径累计。这个例子说明，动态规划并不局限于字符串或序列模型；只要问题具有“局部来源有限、全局目标可递推”的结构，就可以用同样的思路求解。</p>
<div class="blog_h2"><span class="graybg">贪心算法（Greedy Algorithm）</span></div>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>贪心算法（Greedy Algorithm）处理的是这样一类问题：希望快速构造一个全局可行解，并且每一步都只做当前看来最优的局部选择，而不回头修改已经作出的决定。它广泛出现在排序、调度、压缩、近似优化，以及许多机器学习训练与推断流程中。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>贪心的核心假设是：<span style="background-color: #c0c0c0;">当前最好的局部选择，能够导向全局最优，或至少导向足够好的近似解</span>。它像走山路时每一步都先选眼前最高、最稳的落脚点，而不是先把整座山的所有路径都完全规划出来。贪心的优势是快、简单、容易实现；风险是局部最优未必等于全局最优。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>若记第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步可选动作集合为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{A}_t\)</span>，一个抽象的贪心选择可写为：</p>
<span displaypfx="" class="mathjax-container">\[a_t^*=\arg\max_{a\in\mathcal{A}_t}\ \mathrm{score}(a\mid \text{current state})\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{score}(a\mid \text{current state})\)</span> 是当前状态下动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 的局部收益；<span displaypfx="inline-" class="mathjax-container">\(\arg\max\)</span> 表示从所有可选动作里挑出得分最高的那个。贪心算法关心的是“眼下哪一步最好”，而不是“未来所有步骤联合起来后哪条完整路径最好”。</p>
<p>因此，贪心方法是否正确，取决于问题本身是否满足贪心选择性质（Greedy-choice Property）。如果这个性质成立，局部最优就能拼成全局最优；如果不成立，贪心通常只能作为启发式方法或近似算法。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>决策树训练就是一个典型例子。每个节点都不会提前规划整棵树的全局最优结构，而是只在当前节点上选择信息增益、基尼下降或误差下降最大的切分。这个过程本质上就是贪心：<span style="background-color: #c0c0c0;">每一步都先把当前最值得切的地方切开</span>。它训练快、解释性强，但也正因为是局部选择，单棵树通常不是全局最优树结构。</p>
<div class="blog_h2"><span class="graybg">分治算法（Divide and Conquer）</span></div>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>分治算法（Divide and Conquer）处理的是“大问题可以被拆成若干个同结构小问题”的场景。它广泛出现在排序、搜索、矩阵运算、索引构建，以及大规模数据处理与并行计算中。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>分治的思想可以概括为三步：<span style="background-color: #c0c0c0;">分解（Divide）— 递归求解（Conquer）— 合并（Combine）</span>。它像整理一大堆文档时，先按主题拆成若干小堆，再分别处理，最后再合并成有序结果。与动态规划不同，分治更强调“子问题相互独立”，而不是“子问题结果需要反复复用”。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>分治算法的时间复杂度常写成递推式：</p>
<span displaypfx="" class="mathjax-container">\[T(n)=aT\left(\frac{n}{b}\right)+f(n)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 是问题规模；<span displaypfx="inline-" class="mathjax-container">\(a\)</span> 表示被拆成多少个子问题；<span displaypfx="inline-" class="mathjax-container">\(n/b\)</span> 是每个子问题的规模；<span displaypfx="inline-" class="mathjax-container">\(f(n)\)</span> 是“分解 + 合并”本身的额外代价。这个式子不告诉我们具体怎么做，但它准确描述了分治算法的结构骨架。</p>
<p>例如，归并排序（Merge Sort）把长度为 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 的数组分成两个规模约为 <span displaypfx="inline-" class="mathjax-container">\(n/2\)</span> 的子数组，递归排好序后再线性合并，因此它的复杂度递推就是 <span displaypfx="inline-" class="mathjax-container">\(T(n)=2T(n/2)+O(n)\)</span>。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>在机器学习工程中，分治思想常见于大规模近邻索引构建。例如构建 kd-tree 时，算法会按某个维度把样本集递归切成两半，再在左右子集上继续构树。这样得到的层次化空间划分，能显著加速后续的近邻搜索。它的本质并不是“学习一个模型”，而是通过递归拆分把原本需要全表扫描的搜索过程组织得更高效。</p>
<div class="blog_h2"><span class="graybg">图搜索与最短路径</span></div>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>许多软件与机器学习问题都可以抽象成图（Graph）：节点（Node）表示状态、样本、词、网页或知识实体，边（Edge）表示转移、相似性、依赖关系或可达关系。图搜索与最短路径算法要回答的问题是：如何从起点高效找到目标节点，或找到总代价最小的一条路径。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>图搜索的核心是“沿着边扩展状态空间，但尽量避免无意义的重复探索”。无权图最短路径常用广度优先搜索（BFS），因为它按层扩展，第一次到达目标通常就是步数最少的路径；带非负权图常用 Dijkstra，因为它总是优先扩展当前总代价最小的候选节点。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>带权最短路径算法里的基本更新步骤通常写成“松弛（Relaxation）”：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}(v)=\min\big(\mathrm{dist}(v),\ \mathrm{dist}(u)+w(u,v)\big)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{dist}(u)\)</span> 是当前已知从起点到节点 <span displaypfx="inline-" class="mathjax-container">\(u\)</span> 的最小代价， <span displaypfx="inline-" class="mathjax-container">\(w(u,v)\)</span> 是边 <span displaypfx="inline-" class="mathjax-container">\(u\rightarrow v\)</span> 的权重， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{dist}(u)+w(u,v)\)</span> 则是“先到 <span displaypfx="inline-" class="mathjax-container">\(u\)</span> 再走到 <span displaypfx="inline-" class="mathjax-container">\(v\)</span>”这条新候选路径的总代价。若它比当前记录的 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{dist}(v)\)</span> 更小，就更新。</p>
<p>这个式子看起来和动态规划很像，原因是二者都在做“由已知子结果递推新结果”。区别在于：图搜索更强调如何选择下一个要扩展的节点，以及如何在一般图结构中避免重复访问。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>在语音识别、机器翻译和图搜索推断中，解码过程经常会把候选状态组织成图或格（Lattice）。此时，寻找最优输出序列本质上就是图上的路径搜索问题。很多动态规划解码器也可以从“图上最优路径”的角度理解，因此图搜索是连接通用软件算法与结构化机器学习推断的重要桥梁。</p>
<div class="blog_h2"><span class="graybg">二分查找（Binary Search）</span></div>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>二分查找（Binary Search）处理的是“搜索空间有序，或可行性判断具有单调性”的问题。它不仅用于有序数组查找，也广泛用于阈值搜索、参数调优、数值逼近和工程系统中的边界定位。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>二分查找的核心是：<span style="background-color: #c0c0c0;">每次利用单调性砍掉一半搜索空间</span>。它像猜数字游戏：如果知道答案一定在某个区间里，而且中点左侧和右侧满足不同性质，那么每问一次都能把候选范围减半。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>若当前搜索区间为 <span displaypfx="inline-" class="mathjax-container">\([l,r]\)</span>，中点通常取：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{mid}=\left\lfloor\frac{l+r}{2}\right\rfloor\]</span>
<p>接着依据单调判定函数 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{check}(\mathrm{mid})\)</span> 缩小区间：</p>
<ul>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{check}(\mathrm{mid})\)</span> 为真，说明答案在左半边或恰好是中点，则令 <span displaypfx="inline-" class="mathjax-container">\(r=\mathrm{mid}\)</span>。</li>
<li>若为假，说明答案在右半边，则令 <span displaypfx="inline-" class="mathjax-container">\(l=\mathrm{mid}+1\)</span>。</li>
</ul>
<p>算法正确性的关键不在于公式本身，而在于维护区间不变式（Invariant）：在每一步更新后，真正的答案仍然留在当前区间中。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>在机器学习里，二分查找常用于阈值定位。例如，当需要找到“使召回率至少达到某个目标值的最小分类阈值”时，只要阈值越大召回率越低这一单调关系成立，就可以在阈值区间上做二分查找，而不必逐点穷举。类似地，很多数值求根、超参数边界搜索、分位数定位问题也都可写成二分框架。</p>
<div class="blog_h2"><span class="graybg">随机采样（Random Sampling）</span></div>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>随机采样（Random Sampling）处理的是这样一类问题：总体太大、精确计算太贵，或者目标本身就是概率性的，因此只能通过抽样近似整体行为。它是统计学习、蒙特卡洛估计、bootstrap、自助重采样、mini-batch 训练和负采样的共同基础。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>随机采样的核心是：<span style="background-color: #c0c0c0;">不必每次都看完整总体，而是通过足够有代表性的随机子样本估计总体性质</span>。它像民意调查：不可能每天逐个询问所有人，但若抽样方式合理，少量样本也能给出相对稳定的总体估计。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>若目标是估计随机变量 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 下某个函数 <span displaypfx="inline-" class="mathjax-container">\(f(X)\)</span> 的期望 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[f(X)]\)</span>，最常见的蒙特卡洛估计写为：</p>
<span displaypfx="" class="mathjax-container">\[\hat{\mu}=\frac{1}{n}\sum_{i=1}^{n} f(x_i),\qquad x_i\sim p(x)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(x_i\sim p(x)\)</span> 表示样本 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 是按分布 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span> 随机抽到的； <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 是样本数； <span displaypfx="inline-" class="mathjax-container">\(\hat{\mu}\)</span> 是用样本均值近似真实期望的估计量。样本越多，估计通常越稳定，但代价也越高。</p>
<p>这一思想在机器学习里非常普遍：SGD 不是每次都在全量数据上算梯度，而是用 mini-batch 的样本均值近似全数据梯度；negative sampling 不是对所有负类都求和，而是随机抽一小部分负样本近似完整目标。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>bootstrap 是一个很典型的例子。随机森林训练时，会对原始训练集做有放回采样，得到多份不同的 bootstrap 子集，再分别训练多棵树。这里真正起作用的不是“树”本身，而是随机采样制造了多个略有差异的数据视角，从而让集成后的模型更稳。</p>
<div class="blog_h1"><span class="graybg">机器学习基础概念</span></div>
<p>机器学习基础概念（Machine Learning Foundations）回答四类核心问题：数据从哪里来、模型在学什么、模型为什么能泛化、结果该如何评价。把这些问题分开看，会比死记算法名称更有效：学习范式决定监督信号来自哪里，假设空间与归纳偏置决定模型愿意相信什么，数据集工程决定模型实际看到了什么，模型评估决定这些学习结果是否真的能迁移到未见样本。</p>
<div class="blog_h2"><span class="graybg">假设/目标/代价/损失</span></div>
<p>这四个词描述的是同一条“训练=优化”的概念链，但位于不同层级。把层级理清后，公式与实现会自然对齐：模型 <span displaypfx="inline-" class="mathjax-container">\(f_\theta\)</span> 先给出预测，再用损失函数把预测变成数值误差，最后把误差在数据集上汇总成代价函数，并加入正则/约束得到最终的目标函数。</p>
<div class="blog_h3"><span class="graybg">假设函数（Hypothesis Function）</span></div>
<p>假设函数（Hypothesis Function）也常被直接称为模型（Model）或预测函数（Predictor），记作 <span displaypfx="inline-" class="mathjax-container">\(f_\theta\)</span>。它回答的问题是：<span style="background-color: #c0c0c0;">给定输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，模型输出什么</span>。参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 决定这条映射的具体形状。</p>
<p>线性回归（Linear Regression）的假设函数是最经典的例子：</p>
<span displaypfx="" class="mathjax-container">\[\hat y=f_\theta(x)=\mathbf{w}^\top x+b,\quad \theta=(\mathbf{w},b)\]</span>
<div class="blog_h3"><span class="graybg">目标函数（Objective Function）</span></div>
<p>目标函数（Objective Function）记作 <span displaypfx="inline-" class="mathjax-container">\(J(\theta)\)</span>，是优化器真正要优化的函数。工程上最常见、也最清晰的写法是：<span style="background-color: #c0c0c0;">目标函数 = 代价函数 + 正则化项</span>（没有正则化时可视为正则项为 0，因此 <span displaypfx="inline-" class="mathjax-container">\(J(\theta)=L(\theta)\)</span>）。</p>
<span displaypfx="" class="mathjax-container">\[J(\theta)=L(\theta)+\lambda\,\Omega(\theta)\]</span>
<p>在线性回归里，若用 L2 正则（Ridge / Weight Decay），常见目标函数可以写成：</p>
<span displaypfx="" class="mathjax-container">\[J(\theta)=L(\theta)+\lambda\|\mathbf{w}\|_2^2\]</span>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span> 展开后，就是：</p>
<span displaypfx="" class="mathjax-container">\[J(\theta)=\frac{1}{N}\sum_{i=1}^{N}(\mathbf{w}^\top x_i+b-y_i)^2+\lambda\|\mathbf{w}\|_2^2\]</span>
<div class="blog_h3"><span class="graybg">代价/成本函数（Cost Function）</span></div>
<p>代价函数/成本函数（Cost Function）记作 <span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span>，通常指把样本损失在训练集上做平均或求和后的整体量，也就是经验风险（Empirical Risk）。不少教材会把它直接称为训练损失（training loss），并且在不引起歧义时把它与目标函数混用。</p>
<p>在线性回归里，常用“均方误差的平均”作为代价函数：</p>
<span displaypfx="" class="mathjax-container">\[L(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell_i(\theta)\]</span>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(\ell_i(\theta)\)</span> 取为平方误差后，等价写法是：</p>
<span displaypfx="" class="mathjax-container">\[L(\theta)=\frac{1}{N}\sum_{i=1}^{N}(\mathbf{w}^\top x_i+b-y_i)^2\]</span>
<div class="blog_h3"><span class="graybg">损失函数（Loss Function）</span></div>
<p>损失函数（Loss Function）记作 <span displaypfx="inline-" class="mathjax-container">\(\ell\)</span>，通常定义在单个样本上，把“预测与目标的差距”映射为一个标量。它回答的问题是：<span style="background-color: #c0c0c0;">这一条样本我错了多少</span>。</p>
<p>在线性回归里，最常见的单样本损失是平方误差：</p>
<span displaypfx="" class="mathjax-container">\[\ell_i(\theta)=\ell(\hat y_i,y_i)=(\hat y_i-y_i)^2,\quad \hat y_i=f_\theta(x_i)\]</span>
<div class="blog_h2"><span class="graybg">假设空间、容量与归纳偏置</span></div>
<p>同一份训练数据，之所以会被不同模型学出完全不同的规律，根源在于每个模型都自带一套“允许学什么、不允许学什么”的结构约束。假设空间（Hypothesis Space）、模型容量（Model Capacity）与归纳偏置（Inductive Bias）共同描述的，就是这套约束。</p>
<div class="blog_h3"><span class="graybg">假设空间</span></div>
<p>假设空间（Hypothesis Space）是模型可表达函数的集合，常记为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{H}=\{f_\theta:\theta\in\Theta\}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(f_\theta\)</span> 是由参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 决定的预测函数， <span displaypfx="inline-" class="mathjax-container">\(\Theta\)</span> 是参数可取的范围。这个定义的关键不在于“参数有多少”，而在于<span style="background-color: #c0c0c0;">模型最终允许出现哪些映射形状</span>。例如，一元线性回归的假设空间只包含直线；二次多项式回归的假设空间包含抛物线；深度神经网络的假设空间则更大，能表示复杂得多的非线性函数。</p>
<p>因此，训练并不是在所有可能函数中盲目搜索，而是在某个特定假设空间里找一个最合适的函数。假设空间太小，真实规律可能根本装不进去；假设空间太大，模型又容易把偶然噪声也解释成模式。</p>
<div class="blog_h3"><span class="graybg">模型容量</span></div>
<p>模型容量（Model Capacity）描述的是假设空间的表达能力有多强，也就是模型能拟合多复杂规律。容量高，不代表一定更好；它只表示模型“有能力”表示复杂函数。是否真的学得好，还取决于数据量、正则化、优化过程和任务本身。</p>
<p>容量可以从多个角度理解。参数更多通常意味着容量更高，但这不是唯一标准；树的深度、核方法的核函数形式、特征维度、网络层数、隐藏维度、注意力头数，都会改变容量。工程上常用一个朴素判断：<span style="background-color: #c0c0c0;">如果模型连训练集主要结构都拟合不了，容量偏低；如果训练集几乎完美、验证集却明显变差，容量往往偏高或约束不足</span>。</p>
<p>容量与复杂度控制始终是一组平衡。表格数据上的浅层树模型可能已经足够；图像、语音、自然语言这类高度复杂任务，则通常需要更高容量的模型族。容量本身不是缺点，关键在于它是否与数据规模和任务难度匹配。</p>
<div class="blog_h3"><span class="graybg">归纳偏置</span></div>
<p>归纳偏置（Inductive Bias）是模型在有限样本下从已见数据推广到未见数据时，默认采用的结构性偏好。只靠训练集上有限个点，无法唯一确定整个输入空间上的函数；模型之所以还能做出泛化判断，是因为它隐含地偏好某些解释，而排斥另一些解释。</p>
<p>把学习目标写成经验风险最小化时，这一点会更清楚：</p>
<span displaypfx="" class="mathjax-container">\[\hat f=\arg\min_{f\in\mathcal{H}}\hat R_n(f),\qquad \hat R_n(f)=\frac{1}{n}\sum_{i=1}^{n}\ell(f(x_i),y_i)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\hat R_n(f)\)</span> 是经验风险（Empirical Risk），表示函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 在训练集上的平均损失； <span displaypfx="inline-" class="mathjax-container">\(\hat f\)</span> 是最终选出的模型。关键约束正是 <span displaypfx="inline-" class="mathjax-container">\(f\in\mathcal{H}\)</span>：优化并不是在所有可能函数中找最优解，而是在一个被模型结构预先限制过的空间里找解。这个限制本身就是归纳偏置。</p>
<p>归纳偏置的来源很多。线性模型偏好线性关系；KNN（K-Nearest Neighbors）偏好局部相似样本给出相似输出；卷积神经网络（CNN）偏好局部连接与平移等变（Translation Equivariance）；树模型偏好分段常数的轴对齐切分；Transformer 则偏好通过注意力在 token 之间建立可变依赖。正则化、数据增强、参数共享、预训练初始化、优化器的更新轨迹，也都会进一步塑造模型的归纳偏置。</p>
<div class="blog_h2"><span class="graybg">流形假设</span></div>
<p>流形假设（Manifold Hypothesis）给出了现代机器学习里一条极其重要的几何直觉：现实世界中有意义的数据，虽然表面上嵌在极高维空间里，但真正有效的变化自由度通常远低于表观维度。也就是说，高维观测往往不是填满整个空间，而是集中分布在一个低维流形（Low-dimensional Manifold）附近。</p>
<p>图像是最容易理解的例子。一个 <span displaypfx="inline-" class="mathjax-container">\(1024\times 1024\)</span> 的 RGB 图像在像素空间中维度极高，但自然图像并不会均匀占据这个巨大空间：物体形状、光照条件、视角变化、相机成像规律与纹理结构都受到强约束。因此，“像真实猫照片”的图像实际上只落在高维像素空间中的极小区域里。文本也类似。一个长度为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 的 token 序列组合数极其巨大，但真正同时符合语法、语义和任务约束的文本，只占离散组合空间中很小的一部分。模型之所以能够泛化，一个重要原因正是：它并不需要学会覆盖整个高维空间，而只需要学会沿着这些低维结构建模。</p>
<div class="blog_h3"><span class="graybg">什么是流形</span></div>
<p>流形（Manifold）首先是一个几何对象。它的关键性质不是“弯不弯”，而是<span style="background-color: #c0c0c0;">局部上看起来像普通的低维欧几里得空间，整体上却可以弯曲、卷曲并嵌入到更高维空间中</span>。更正式地说，若一个集合对其上每一点，都存在一个足够小的邻域，可以用 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^d\)</span> 中的局部坐标平滑描述，那么这个集合就可以看作一个 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 维流形。</p>
<p>地球表面是最直观的例子。站在操场或街道上时，局部地面几乎是平的，可以用二维坐标定位；但从整体看，地球表面显然不是二维平面，而是嵌入三维空间中的弯曲曲面。因此，地球表面就是一个嵌在三维空间里的二维流形。对只能沿地面运动的观察者而言，真正相关的不是三维空间全部坐标，而是地表上那两个局部自由度。</p>
<p>机器学习教材里常见的瑞士卷（Swiss Roll）把这个概念进一步可视化。可以先想象一张二维纸，再把它卷进三维空间。卷起来之后，样本点在外部看来落在三维空间里，但沿着纸面定位某一点时，真正需要的仍然只是二维坐标。外在维度变高了，内在结构却没有变。这正是流形概念在机器学习里最重要的几何直觉：<span style="background-color: #c0c0c0;">数据的表观维度可以很高，但它的内在维度却可能很低</span>。</p>
<p>这类卷曲结构还带来另一个机器学习里非常关键的后果：<span style="background-color: #c0c0c0;">嵌入空间中的欧氏距离（Euclidean Distance）与流形上的测地距离（Geodesic Distance）并不一定一致</span>。两点在外部空间里看起来可能很近，因为一条直线可以直接“穿过空气”连接它们；但若真实数据只能沿流形本身变化，那么真正相关的距离应当是沿曲面或曲线走过去的那条路径长度。流形学习（Manifold Learning）之所以强调邻域图、测地近似和局部结构，正是因为外部直线距离经常不能反映数据在内在结构上的真实远近关系。</p>
<p>放到高维数据上，这个判断尤其关键。一张 <span displaypfx="inline-" class="mathjax-container">\(1000\times1000\)</span> 的灰度图像在像素空间里有一百万维，但“真实人脸图像”显然不会填满整个一百万维空间。姿态、光照、表情、年龄、拍摄距离等因素彼此耦合，使真实样本只落在高维像素空间中一个极薄、极小、受连续约束的区域附近。文本也是同样的逻辑：虽然 token 组合空间极其巨大，但真正同时满足语法、语义、上下文与任务约束的句子，只会沿着某种低维结构变化，而不会任意填满整个离散组合空间。</p>
<p>因此，在机器学习语境中谈流形，真正想表达的并不是完整搬运微分几何的全部形式系统，而是一个更直接的判断：<span style="background-color: #c0c0c0;">有意义的数据并不会随机散落在高维空间中，而是集中在某个低维、连续、受约束的结构附近</span>。后面关于自由度、主成分、隐空间、低维近似以及 LoRA 任务子空间的讨论，都是围绕这个判断展开的不同形式化视角。</p>
<div class="blog_h3"><span class="graybg">自由度</span></div>
<p>沿着前面“表观维度高、内在结构低”的判断继续往下走，就会自然落到自由度（Degrees of Freedom）这个概念上。表观维度说的是“数据在形式上有多少个坐标轴”；自由度说的是“这些数据实际上有多少种彼此独立的有效变化方式”。二者并不相同。一个对象可以嵌在极高维空间里，但真正能变化的自由度却很少。</p>
<p>例如，一张脸部图片在像素空间里有数百万维，但很多像素并不能独立随意变化：头部转向、光照强弱、表情变化、年龄纹理、拍摄距离这些因素彼此耦合，共同决定了大部分像素的联动变化。因此，“一张脸”看起来是高维数组，真正支配它变化的自由度却远小于像素总数。文本也一样。句子表面上由许多 token 组成，但语法结构、主题、语气、说话者意图与上下文约束，使它不可能在每个位置上完全独立自由地变化。</p>
<p>这也是流形假设真正重要的地方：它不是抽象地说“数据在低维流形上”，而是在说<span style="background-color: #c0c0c0;">有效自由度远少于表观维度</span>。一旦把这层理解清楚，后面关于主成分、隐空间、隐主题、低秩近似乃至 LoRA 的很多思想都会变得顺理成章，因为它们都在试图用更少的自由度，去抓住决定数据或参数变化的核心结构。</p>
<div class="blog_h3"><span class="graybg">主成分、隐空间与隐主题</span></div>
<p>一旦接受了“高维数据实际靠近低维结构”这一点，后面许多术语就会自然连起来。主成分（Principal Components）强调几何视角：在一组高维数据里，哪些方向承载了最主要的变化。隐空间（Latent Space）强调表示视角：把原始高维观测压缩到一个更低维、但仍保留关键信息的内部空间。隐主题（Latent Topics）则更偏语义视角：在文本与文档分解里，低维方向常常可以被解释为若干潜在语义因素，例如“体育”“金融”“法律”这类人类能命名的主题轴。</p>
<p>这三个词并不完全同义，但常常指向同一个底层事实：<span style="background-color: #c0c0c0;">高维观测可以通过少数主导方向或潜在因子来近似描述</span>。主成分更强调方差最大的坐标轴；隐空间更强调模型内部那间低维“房间”；隐主题则是在某些任务里，对这些低维方向做出的语义解释。它们分别对应几何、表示与语义三种语言，但共享同一条低维结构主线。</p>
<div class="blog_h3"><span class="graybg">PCA 与低维近似</span></div>
<p>PCA（Principal Component Analysis）是这条思路最经典、也最直接的算法形式。它在无监督条件下寻找方差最大的几个方向，并把数据投影到这些方向张成的低维子空间中。若数据矩阵为 <span displaypfx="inline-" class="mathjax-container">\(X\)</span>，PCA 本质上是在找一个低维线性子空间，使投影后的重建误差尽量小。在线性代数上，这与奇异值分解（SVD）直接对应：保留最大的前 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 个奇异值及其奇异向量，就得到最佳的 rank-<span displaypfx="inline-" class="mathjax-container">\(r\)</span> 近似。</p>
<p>这类思想并不只存在于经典降维里。自动编码器（Autoencoder）通过瓶颈层学习隐空间；潜在语义分析（Latent Semantic Analysis, LSA）和主题模型在文档-词矩阵里抽取潜在主题；词向量、句向量与深度表示模型把高维离散符号压缩到稠密向量空间。它们的目标函数和可解释性不同，但都默认：原始高维观测背后存在一个更低维、更有结构的变化空间。</p>
<div class="blog_h3"><span class="graybg">LoRA 与任务相关子空间</span></div>
<p>LoRA 也应放在这条主线上理解，但不能把它与 PCA 直接等同。LoRA 的核心不是对输入数据做主成分分析，而是对参数更新 <span displaypfx="inline-" class="mathjax-container">\(\Delta W\)</span> 施加低秩约束：</p>
<span displaypfx="" class="mathjax-container">\[\Delta W = BA,\quad B\in\mathbb{R}^{d_{\text{out}}\times r},\ A\in\mathbb{R}^{r\times d_{\text{in}}},\ r\ll \min(d_{\text{in}},d_{\text{out}})\]</span>
<p>这个约束的含义是：模型不能在完整高维参数空间中任意改动，而只能在一个 rank-<span displaypfx="inline-" class="mathjax-container">\(r\)</span> 的低维更新子空间里移动。从思想上看，它确实与“只保留主导方向”高度相似；若事后对某个全量更新矩阵做 SVD，最佳低秩近似也会只保留最主要的奇异方向。但 LoRA 学到的并不是传统 PCA 意义上“数据方差最大”的主成分，而是<span style="background-color: #c0c0c0;">对当前任务损失下降最有用的低维更新方向</span>。前者是无监督的统计主轴，后者是由反向传播和任务目标共同决定的优化子空间。</p>
<p>因此，把 LoRA 理解为“逼迫模型只在少数主导方向上修改参数”是成立的；但这些方向更准确地说是任务相关的低维适配方向，而不是直接等同于原始数据的 PCA 主成分。也正因为如此，LoRA 与内在维度（Intrinsic Dimension）讨论天然相连：如果一个下游任务真正需要修改的有效自由度本来就不高，那么让模型只在一个低秩子空间里更新，不仅不会显著损失性能，反而会自动抑制大量无意义的噪声方向。</p>
<div class="blog_h2"><span class="graybg">学习范式</span></div>
<p>这里先按监督信号来源划分学习范式。监督学习（Supervised Learning）、无监督学习（Unsupervised Learning）、自监督学习（Self-supervised Learning）和强化学习（Reinforcement Learning）回答的是同一个问题：模型训练时的监督信号究竟来自哪里。它们属于同一分类标准，因此可以并列讨论。</p>
<div class="blog_h3"><span class="graybg">监督学习</span></div>
<p>监督学习（Supervised Learning）使用带标签的数据对 <span displaypfx="inline-" class="mathjax-container">\((x,y)\)</span> 训练模型：输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是特征（Feature），输出 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 是目标或标签（Label）。模型学习的不是“记住答案”，而是学习一个映射 <span displaypfx="inline-" class="mathjax-container">\(f_\theta:x\to y\)</span>，使它对新样本也能给出合理预测。</p>
<p>但“学一个映射”还不够，训练时还必须回答另一个更具体的问题：<span style="background-color: #c0c0c0;">怎样才算模型学得好</span>。监督学习里最常见的回答，是在训练集上逐个比较预测与真实标签的差距，再把这些差距汇总成一个总体目标；这就导向经验风险最小化（Empirical Risk Minimization, ERM）。</p>
<p>经验风险最小化的典型目标写成：</p>
<span displaypfx="" class="mathjax-container">\[\frac{1}{N}\sum_{i=1}^{N}\ell\big(f_\theta(x_i),y_i\big)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(N\)</span> 是样本数， <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span> 是模型预测， <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 是真实标签， <span displaypfx="inline-" class="mathjax-container">\(\ell\)</span> 是损失函数（Loss Function）。这条式子的含义不是抽象求和，而是：<span style="background-color: #c0c0c0;">逐个样本计算“预测错了多少”，再取平均，把平均错误压到尽可能小</span>。</p>
<p>例：垃圾邮件分类里， <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 可以是邮件文本特征， <span displaypfx="inline-" class="mathjax-container">\(y\in\{0,1\}\)</span> 表示“正常/垃圾”；房价预测里， <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 可以是面积、地段、楼龄， <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 是价格。前者是分类（Classification），后者是回归（Regression），但“有标签地学映射、再用损失函数衡量误差”这一训练逻辑完全一致。</p>
<div class="blog_h4"><span class="graybg">分类任务</span></div>
<p>分类任务（Classification）要预测的是<span style="background-color: #c0c0c0;">离散类别</span>，也就是样本属于哪一类。输出可以是一个类别 id，也可以是一组类别概率。例如二分类里常见输出 <span displaypfx="inline-" class="mathjax-container">\(P(y=1|x)\)</span>，表示“给定特征 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 时，样本属于正类的概率”。</p>
<p>垃圾邮件识别、肿瘤良恶性判断、情感分析、图像里的猫狗识别都属于分类任务。它更像“做选择题”：模型最终要在有限候选里做判断。训练时常配合交叉熵（Cross-Entropy）这类损失，因为模型不仅要选对类别，还要给正确类别足够高的置信度。</p>
<div class="blog_h4"><span class="graybg">回归任务</span></div>
<p>回归任务（Regression）要预测的是<span style="background-color: #c0c0c0;">连续数值</span>，也就是标签不是几个固定类别，而是在某个数值区间内连续变化。输出通常直接是一个实数，或一个多维连续向量。</p>
<p>房价预测、销量预测、温度预测、广告点击率中的停留时长估计都属于回归任务。它更像“做填空题”：模型不能只说“高”或“低”，而必须给出具体数值。训练时常配合均方误差（MSE）或平均绝对误差（MAE），因为关心的是预测值与真实值到底差了多少。</p>
<p>分类与回归都属于监督学习，因为它们都有标签；真正的区别在于<span style="background-color: #c0c0c0;">标签空间的形状</span>：分类的标签空间是离散集合，回归的标签空间是连续区间。这个区别会直接决定模型输出层形式、损失函数选择以及评估指标。</p>
<div class="blog_h3"><span class="graybg">无监督学习</span></div>
<p>无监督学习（Unsupervised Learning）只有输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，没有人工标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>。它的目标不是拟合“标准答案”，而是从数据中发现结构（Structure），例如聚类（Clustering）、降维（Dimensionality Reduction）、密度估计（Density Estimation）与异常检测（Anomaly Detection）。</p>
<p>一个直观类比是：监督学习像“拿着答案册做题”，无监督学习像“没有答案册，只能自己把一堆材料按相似性归类”。例如电商用户没有现成“用户类型”标签，但可以根据浏览、购买、停留时间等行为聚成“价格敏感型”“冲动购买型”“高价值复购型”等群体，用于运营分层。</p>
<div class="blog_h3"><span class="graybg">自监督学习</span></div>
<p>自监督学习（Self-supervised Learning）介于监督与无监督之间：原始数据没有人工标签，但任务标签可以由数据本身自动构造出来。核心思想是<span style="background-color: #c0c0c0;">从数据内部制造预测任务</span>，让模型在完成这些任务的过程中学到可迁移表示（Representation）。</p>
<p>语言模型的下一个 token 预测就是最典型的自监督任务：前文是输入，后一个 token 是由原始文本自动给出的“监督信号”。图像领域里，旋转预测、遮挡恢复、不同增强视角匹配也属于同一路线。</p>
<div class="blog_h4"><span class="graybg">掩码预测</span></div>
<p>掩码预测（Masked Prediction）把输入中的一部分信息故意遮住，再要求模型恢复。例如 BERT 会把句子中的部分 token 替换成特殊标记 <span displaypfx="inline-" class="mathjax-container">\([MASK]\)</span>，模型要根据上下文预测被遮住的词。</p>
<p>类比来看，这像完形填空：你不是死记整句，而是学会根据上下文推断缺失信息。它迫使模型同时利用左侧和右侧上下文，因此特别适合编码器（Encoder）型表示学习。</p>
<div class="blog_h3"><span class="graybg">强化学习</span></div>
<p>强化学习（Reinforcement Learning, RL）最容易把人劝退的地方，是一上来就堆术语：马尔可夫、时序差分、优势函数、策略梯度……但把这些词全去掉后，它讲的其实只是一个极其朴素的故事：<span style="background-color: #c0c0c0;">一个智能体在环境里反复试错，慢慢学会怎样长期拿到更多奖励</span>。</p>
<p>监督学习像“做题立刻对答案”：你给模型输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，同时给它正确标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>。强化学习不是这样。它更像玩一整局游戏：你只能一边行动，一边看后果，一边修正打法。当前这一步看起来对不对，往往要过很多步以后才知道。</p>
<div class="blog_h4"><span class="graybg">核心循环：迷宫游戏</span></div>
<p>可以把强化学习先想成一个小机器人走迷宫。这个故事里有六个最核心的角色：</p>
<ul>
<li>智能体（Agent）：做决策的主体，也就是那个小机器人。</li>
<li>环境（Environment）：机器人所处的外部世界，例如迷宫、棋盘、游戏地图、推荐系统或对话上下文。</li>
<li>状态（State, <span displaypfx="inline-" class="mathjax-container">\(s\)</span>）：机器人当前看到的局面，例如“前面是墙，右边有路”。</li>
<li>动作（Action, <span displaypfx="inline-" class="mathjax-container">\(a\)</span>）：机器人此刻可以做的选择，例如左转、右转、前进。</li>
<li>奖励（Reward, <span displaypfx="inline-" class="mathjax-container">\(r\)</span>）：环境给这一步行动的反馈，例如捡到金币加 1，掉进陷阱减 1。</li>
<li>策略（Policy, <span displaypfx="inline-" class="mathjax-container">\(\pi\)</span>）：机器人脑子里的“通关秘籍”，也就是在什么状态下该怎么行动的规则。</li>
</ul>
<p>于是强化学习的目标可以直接翻译成一句人话：<span style="background-color: #c0c0c0;">不断修改这本“通关秘籍”，让整局游戏玩下来拿到的总分尽可能高</span>。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/rl.png"><img class="alignnone size-full wp-image-40981" src="https://blog.gmem.cc/wp-content/uploads/2026/03/rl.png" alt="rl" width="100%" /></a></p>
<p>为什么这里不只看即时奖励，而要看“整局总收益”？因为很多任务里，奖励会延迟出现。下棋时，一步好棋未必立刻得分，但可能为十步后的胜利铺路；推荐系统里，一次推荐是否合理，也要看用户后续点击、停留和转化。因此强化学习常用于游戏对战、机器人控制、资源调度、广告竞价、推荐排序，以及大模型对齐等场景。</p>
<p>为了衡量“从现在开始，这套打法最终值不值得”，强化学习定义长期回报（Return，也常称累计回报）：</p>
<span displaypfx="" class="mathjax-container">\[G_t=\sum_{k=0}^{\infty}\gamma^k r_{t+k+1},\quad \max_\pi\ \mathbb{E}_\pi[G_t]\]</span>
<p>这条式子不要硬背，可以直接按故事来读。前半部分 <span displaypfx="inline-" class="mathjax-container">\(G_t=\sum_{k=0}^{\infty}\gamma^k r_{t+k+1}\)</span> 说的是“从当前时刻开始，整局游戏最终能拿多少分”： <span displaypfx="inline-" class="mathjax-container">\(r_{t+k+1}\)</span> 是未来第 <span displaypfx="inline-" class="mathjax-container">\(k+1\)</span> 步得到的奖励， <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 是折扣因子（Discount Factor），表示未来奖励要不要打折。它的直觉很像“现在的 100 块通常比一年后的 100 块更值钱”：当 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 越接近 1，模型越重视长期收益；当它更小，模型就更短视。后半部分 <span displaypfx="inline-" class="mathjax-container">\(\max_\pi\ \mathbb{E}_\pi[G_t]\)</span> 则是在说：在所有可能的策略 <span displaypfx="inline-" class="mathjax-container">\(\pi\)</span> 里，去寻找那个能让期望长期回报最大的策略。这里的 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}_\pi\)</span> 是期望，后面的方括号 <span displaypfx="inline-" class="mathjax-container">\([G_t]\)</span> 只是表示“取期望的对象是 <span displaypfx="inline-" class="mathjax-container">\(G_t\)</span> 这个随机回报”，并不是额外的新符号。之所以要写期望，是因为同一个策略在同一个环境里重复运行，过程里往往带有随机性——例如环境可能有随机事件，策略本身也可能按概率选动作。所以强化学习真正优化的不是“某一次刚好打得特别好”的偶然结果，而是“长期重复玩这局游戏时，平均下来能拿到的总分”尽可能高。</p>
<div class="blog_h4"><span class="graybg">把故事写成数学：MDP</span></div>
<p>马尔可夫决策过程（Markov Decision Process, MDP）不是额外的新东西，它只是把上面的迷宫故事写成数学记号。最常见的写法是五元组 <span displaypfx="inline-" class="mathjax-container">\((\mathcal{S},\mathcal{A},P,R,\gamma)\)</span>：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathcal{S}\)</span>：状态空间（State Space），也就是所有可能局面的集合。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathcal{A}\)</span>：动作空间（Action Space），也就是所有可选动作的集合。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(P(s'|s,a)\)</span>：状态转移概率，表示在状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 做动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 后，到达下一状态 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span> 的概率。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(R(s,a)\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(R(s,a,s')\)</span>：奖励函数，表示这一步能拿到什么反馈。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span>：折扣因子，决定未来奖励在总目标里占多大比重。</li>
</ul>
<p>“马尔可夫”这个词听着吓人，意思其实很简单：<span style="background-color: #c0c0c0;">如果当前状态已经把关键信息都概括好了，那么未来只取决于当前状态和当前动作</span>。像下棋时，只看当前棋盘局面就足够，不必把每一步历史都原样背下来。</p>
<p>理解了这个核心循环后，主流强化学习算法其实只是三大门派：价值派、策略派，以及在大模型时代出现的对齐派。它们不是在解决不同问题，而是在用不同方式学习同一件事：怎样改进策略。</p>
<div class="blog_h4"><span class="graybg">价值派：Q-Learning 与 DQN</span></div>
<p>价值派（Value-Based）的思路像一个精打细算的记账员。它不直接记“现在该怎么做”，而是先问一个更根本的问题：<span style="background-color: #c0c0c0;">如果我在当前状态下做这个动作，从长期看到底划不划算</span>。这个“划算程度”的估计，就是动作价值函数（Action-value Function） <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span>。</p>
<p>这里最容易混淆的是： <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 不是“这一步眼前立刻拿到的奖励”，也不是单独指“这一步的成本”。它表示的是：<span style="background-color: #c0c0c0;">在状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 下先执行动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span>，然后后面继续尽量做好选择，最终总共大约能拿到多少长期回报</span>。如果某个动作会消耗时间、体力、资源或带来风险，这些通常会被写进奖励函数里，表现成负奖励；因此 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 看的是长期净效果，而不是只看眼前奖励，也不是只看单独成本。</p>
<p>Q-Learning 之所以叫这个名字，是因为它要学习的对象就是动作价值函数 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 的估计（Q 源自 quality，表示动作在某状态下的“好坏程度”）。Q-Learning 的直觉是：如果我在状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 下选择动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span>，环境会先立刻返还一个即时奖励 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>，然后把我带到下一状态 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span>。这里的 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 只是在给“这一步刚刚发生了什么”打分；而 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 想学的则是“这一步连同后面整局走势一起看，最终值多少”。所以 Q-Learning 做的事情，不是把即时奖励直接当成答案，而是用这一步新看到的结果，回头修正账本里对 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 的动作价值估计：</p>
<span displaypfx="" class="mathjax-container">\[Q(s,a)\leftarrow Q(s,a)+\alpha\Big(r+\gamma\max_{a'}Q(s',a')-Q(s,a)\Big)\]</span>
<p>这条更新式最好逐项拆开看：</p>
<ul>
<li>更新式左边的 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 表示“这次更新后，要重新写回账本的那个值”。</li>
<li>箭头右边最后那个 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 表示“更新前账本里原来记着的旧估计”。同一个符号在箭头两边都出现，是因为它表示的是<span style="background-color: #c0c0c0;">同一个账本位置更新前后的值</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(s\)</span> 是当前状态，例如“机器人现在站在岔路口，左边是墙，右边能走”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(a\)</span> 是当前状态 <span displaypfx="inline-" class="mathjax-container">\(s\)</span> 下刚刚执行的那个动作，例如“向右走一步”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(r\)</span> 是执行动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 后，环境立刻返还的即时奖励。例如这一步踩到金币就加 1，踩到陷阱就减 1。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(s'\)</span> 是执行完当前动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 之后到达的下一状态。这里的撇号只是表示“下一个状态”，不是别的特殊运算。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(a'\)</span> 表示在下一状态 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span> 中任意一个可选动作。它带撇号，只是为了和当前动作 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 区分开。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(Q(s',a')\)</span> 表示：如果机器人已经来到下一状态 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span>，此时选择动作 <span displaypfx="inline-" class="mathjax-container">\(a'\)</span>，那么从那以后预期长期回报大约是多少。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\max_{a'}Q(s',a')\)</span> 的意思是：把下一状态 <span displaypfx="inline-" class="mathjax-container">\(s'\)</span> 里所有可选动作 <span displaypfx="inline-" class="mathjax-container">\(a'\)</span> 的长期价值都估一遍，再挑出其中最大的那个估计。它回答的问题是：<span style="background-color: #c0c0c0;">如果我已经走到了下一状态，并且从下一步开始尽量做最好的选择，后面大概还能再拿到多少长期回报</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\gamma\max_{a'}Q(s',a')\)</span> 表示把“未来还能拿到的最好长期回报”按折扣因子 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 打折后算回来，因为越远的未来通常权重越小。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(r+\gamma\max_{a'}Q(s',a')\)</span> 是新的目标值（TD target）：它把“这一步立刻拿到的即时奖励”与“从下一步开始最好情况下还能拿到的折扣后长期回报”加在一起。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(r+\gamma\max_{a'}Q(s',a')-Q(s,a)\)</span> 是新目标与旧估计之间的差，也就是时序差分误差（TD error）。如果这个差为正，说明原来低估了；如果这个差为负，说明原来高估了。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 是学习率，控制这次修正到底改多少。 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 大，账本改得快但更容易抖；小则更稳，但学得更慢。</li>
</ul>
<p>把整条式子连起来看，就是：先记录“当前这一步实际拿到了多少即时奖励”，再估计“到了下一状态后，如果接下来尽量做最好的选择，还能拿到多少折扣后长期回报”，把这两部分合成一个新的目标值，然后用它去修正旧账本里对 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 的动作价值估计。这就是 Q-Learning 的核心。</p>
<p>DQN（Deep Q-Network）并没有改变这个思路，它只是把“小本子记账”升级成“让神经网络来记账”。当状态非常大时，例如 Atari 游戏的原始像素画面，手工建一个巨大 Q 表几乎不可能，这时就用深度网络直接输入状态，输出每个动作的价值估计。DQN 的本质不是新的哲学，而是：<span style="background-color: #c0c0c0;">让价值派能处理高维感知输入</span>。</p>
<p>用迷宫里的“死胡同”再对照一遍，会更容易把这件事和更新式对上。常见有两种奖励设计：</p>
<ul>
<li>如果每走一步都有即时奖励（很多任务里是每步一个小的负奖励，表示时间或能量成本），那么一旦走进死胡同，哪怕还没走到尽头，后续回退/绕路的每一步都会继续累积这些代价。沿着死胡同走得越深，后续必然要付出的额外代价越多，于是死胡同深处的状态会先学到更低的 <span displaypfx="inline-" class="mathjax-container">\(\max_{a'}Q(s,a')\)</span>；再通过更新式里的 <span displaypfx="inline-" class="mathjax-container">\(\gamma\max_{a'}Q(s',a')\)</span> 把“更差的未来”逐步回传到入口处，最终拉低“最开始走进死胡同那一步”的 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span>。</li>
<li>如果奖励非常稀疏：只有到达终点那一刻给一次奖励，其余步奖励为 0，那么走进死胡同本身不会产生额外的负奖励，它的影响来自“把终点奖励推迟了”。假设终点奖励为 <span displaypfx="inline-" class="mathjax-container">\(R_{\mathrm{goal}}\)</span>，到达终点的时间为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span>，则从时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 起的长期回报约为 <span displaypfx="inline-" class="mathjax-container">\(G_t=\gamma^{T-t-1}R_{\mathrm{goal}}\)</span>：走到终点越晚，<span displaypfx="inline-" class="mathjax-container">\(\gamma&lt;1\)</span> 时折扣越多，回报越小。这会表现为：越靠近终点的状态动作对先学到较大的 Q，越远的状态动作对学到的 Q 会多乘几个 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span>。若环境还有最大步数上限（time limit），超过上限视为失败并给 0 回报，那么绕进死胡同也可能把轨迹拖到上限之外，使得许多状态动作对的 TD target 直接变为 0，从而把入口处那一步的 <span displaypfx="inline-" class="mathjax-container">\(Q(s,a)\)</span> 也压低。</li>
</ul>
<div class="blog_h4"><span class="graybg">策略派：Policy Gradient 与 PPO</span></div>
<p>策略派（Policy-Based）觉得记账太麻烦，尤其当动作不是“左转/右转”这种离散选项，而是“方向盘打多少角度”“机械臂关节转多少度”这种连续动作时，根本不适合把每个动作都列成账本。于是它选择直接练本能：给定状态，直接输出动作概率分布，这个分布本身就是策略 <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(a|s)\)</span>。</p>
<p>策略梯度（Policy Gradient）的核心思想非常直白：<span style="background-color: #c0c0c0;">凡是最终带来高回报的动作，就提高它以后再次发生的概率；凡是带来低回报的动作，就降低它的概率</span>。它的基本形式写成</p>
<span displaypfx="" class="mathjax-container">\[\nabla_\theta J(\theta)=\mathbb{E}\big[\nabla_\theta \log \pi_\theta(a_t|s_t)\,G_t\big]\]</span>
<p>这条式子同样最好逐项拆开看：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(J(\theta)\)</span> 是策略的总体目标，也就是“让整条策略长期回报尽可能大”这件事。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta J(\theta)\)</span> 表示“如果我想把总目标 <span displaypfx="inline-" class="mathjax-container">\(J(\theta)\)</span> 变大，参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 应该往哪个方向改”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 是策略模型的参数。在机器人里它可能是控制器参数，在神经网络里它就是网络权重。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(s_t\)</span> 是时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的状态，也就是当前看到的局面。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(a_t\)</span> 是时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 真正执行的那个动作。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(a_t|s_t)\)</span> 是“在状态 <span displaypfx="inline-" class="mathjax-container">\(s_t\)</span> 下，策略给动作 <span displaypfx="inline-" class="mathjax-container">\(a_t\)</span> 分配了多大概率”。它不是奖励，也不是价值，而是策略本身的偏好强弱。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\log \pi_\theta(a_t|s_t)\)</span> 不是又引入了一个神秘对象，它只是对这个概率取对数。这样做的主要原因是数学上更容易求导，并且能把一整条轨迹上的概率乘积改写成对数求和。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta \log \pi_\theta(a_t|s_t)\)</span> 表示：如果想让动作 <span displaypfx="inline-" class="mathjax-container">\(a_t\)</span> 在状态 <span displaypfx="inline-" class="mathjax-container">\(s_t\)</span> 下更容易再次发生，参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 应该朝哪个方向改。它给的是“增大该动作概率的方向”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(G_t\)</span> 是从时刻 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 开始，这次动作后面最终带来的长期回报。它在这里扮演“事后评分”的角色：若 <span displaypfx="inline-" class="mathjax-container">\(G_t\)</span> 高，说明这次动作最终效果好；若低，说明效果差。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta \log \pi_\theta(a_t|s_t)\,G_t\)</span> 这项可以直观理解为：先找到“怎样让这次动作更常发生”的方向，再用 <span displaypfx="inline-" class="mathjax-container">\(G_t\)</span> 来决定这个方向该被强化多少。动作后果越好，就推得越用力；后果越差，就反过来压制。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[\cdot]\)</span> 表示对很多次采样结果取期望，因为单次轨迹有随机性。策略梯度真正优化的不是“某一次刚好运气好”的结果，而是长期平均表现。</li>
</ul>
<p>把整条式子连起来看，就是：先执行一个动作，等整段后果出来后，再回头问“这次动作究竟值不值”。如果值，就提高它以后再次出现的概率；如果不值，就降低它的概率。这就是策略派“直接练本能”的本质。</p>
<p>PPO（Proximal Policy Optimization）是在策略梯度上加护栏。纯策略梯度的问题是：如果某一步更新太猛，策略可能一下子学歪，训练直接崩掉。PPO 的核心就是限制“这次别改太多”：</p>
<span displaypfx="" class="mathjax-container">\[\min\Big(r_t(\theta)A_t,\ \mathrm{clip}(r_t(\theta),1-\epsilon,1+\epsilon)A_t\Big)\]</span>
<p>这条式子也可以逐项拆开看：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(r_t(\theta)=\frac{\pi_\theta(a_t|s_t)}{\pi_{\theta_{\mathrm{old}}}(a_t|s_t)}\)</span> 是新旧策略对同一动作概率的比值。若它接近 1，说明这次更新前后，策略对这个动作的看法变化不大；若它远离 1，说明改动已经很大。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\pi_{\theta_{\mathrm{old}}}(a_t|s_t)\)</span> 是更新前旧策略给这个动作的概率； <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(a_t|s_t)\)</span> 是更新后新策略给它的概率。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(A_t\)</span> 是优势函数（Advantage）。它回答的问题不是“这次总共拿了多少分”，而是“这个动作比当前状态下的平均水平到底好多少”。若 <span displaypfx="inline-" class="mathjax-container">\(A_t&gt;0\)</span>，说明这步动作比平均更好；若 <span displaypfx="inline-" class="mathjax-container">\(A_t&lt;0\)</span>，说明更差。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(r_t(\theta)A_t\)</span> 可以理解成“如果完全按这次新旧概率比去更新，那么这个动作会被奖励或惩罚多少”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{clip}(r_t(\theta),1-\epsilon,1+\epsilon)\)</span> 是 PPO 最关键的护栏：它强行规定概率比不要偏离 1 太远。 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 就是护栏宽度。</li>
<li>外面的 <span displaypfx="inline-" class="mathjax-container">\(\min(\cdot,\cdot)\)</span> 表示：原始更新和“截断后的保守更新”两者里，只取更保守的那个。这样即使优化器很激进，也不会让策略一步跳太远。</li>
</ul>
<p>所以 PPO 的本质可以直接说成人话：<span style="background-color: #c0c0c0;">方向仍然按“好的动作多做，差的动作少做”来改，但每次只允许小步微调，防止策略训练失控</span>。这也是它在机器人控制和大模型对齐里都非常常用的原因。</p>
<div class="blog_h4"><span class="graybg">大模型对齐：RLHF、DPO 与 GRPO</span></div>
<p>语言模型本来做的是下一个 token 预测：给定前文，猜下一个词最可能是什么。这很像一只会续写的鹦鹉。大模型对齐（Alignment）要解决的问题是：怎样让它不只是“会接话”，还要更有帮助、更安全、更符合人类偏好。</p>
<p>把语言模型放进强化学习视角后，整个映射会立刻清楚起来：用户输入的 prompt 加上当前已生成前缀 <span displaypfx="inline-" class="mathjax-container">\((x,c_t)\)</span> 就是状态；当前要选的下一个 token <span displaypfx="inline-" class="mathjax-container">\(y_t\)</span> 就是动作；整段最终回答 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 就是一条完整轨迹；而模型本身给出的下一个 token 条件概率分布 <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta(y_t|c_t,x)\)</span>，就是所谓生成策略（Generation Policy）。所以“修改生成策略”并不神秘，本质上就是：<span style="background-color: #c0c0c0;">修改模型在每个上下文里更倾向说哪些 token</span>。</p>
<p>RLHF（Reinforcement Learning from Human Feedback）的做法是：先收集人类偏好数据，例如“回答 A 比回答 B 更好”；再训练一个奖励模型 <span displaypfx="inline-" class="mathjax-container">\(r_\phi(x,y)\)</span>，让它学会像裁判一样给回答打分；最后用 PPO 这类策略优化方法，把高分回答的概率调高、低分回答的概率调低。一个常见目标写成</p>
<span displaypfx="" class="mathjax-container">\[\max_\pi\ \mathbb{E}_{y\sim \pi(\cdot|x)}\big[r_\phi(x,y)-\beta\,\mathrm{KL}(\pi(\cdot|x)\|\pi_{\mathrm{ref}}(\cdot|x))\big]\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\pi_{\mathrm{ref}}\)</span> 是参考模型，通常是监督微调（SFT）后的初始模型；KL 项的作用是防止模型为了讨好奖励模型而“跑偏”太远。可以把 RLHF 理解成：先找人类裁判总结出一套评分标准，再让模型在这套标准下稳步调整说话方式。</p>
<p>DPO（Direct Preference Optimization）认为这套流程有点重：既要训练奖励模型，又要跑强化学习优化。它利用偏好数据做了数学改写，直接把“回答 A 优于回答 B”的信息作用到策略本身，跳过了显式奖励模型与完整 RL 回路。直观地说，DPO 像是把“裁判打分 + 再训练”合成一步，工程更简单，训练也更稳定。</p>
<p>GRPO（Group Relative Policy Optimization）则进一步强调“相对好坏”而不是绝对打分。它让模型针对同一个问题先生成一组候选答案，再在组内比较谁更好、谁更差，然后根据这个相对排名更新策略。它的直觉很像班级作文比赛：不是先给每篇作文一个绝对分，而是先把同一组作文排个名次，再让模型朝更优答案的方向调整。GRPO 的优势在于省掉了沉重的价值网络（Critic），因此特别适合数学推理、代码生成这类可以比较优劣、但很难稳定打绝对分的任务。</p>
<div class="blog_h2"><span class="graybg">表示学习与适配策略</span></div>
<p>与“学习范式”不同，下面这些概念不再按监督信号来源分类，而是分别回答另外几个问题：表示该怎样学、计算该怎样近似、已有知识该怎样迁移、在极少样本下又该怎样适配。因此它们更适合看成与学习范式并列的训练目标或训练策略。</p>
<div class="blog_h3"><span class="graybg">表示学习（Representation Learning）</span></div>
<p>表示学习（Representation Learning）讨论的是：如何把原始输入自动变换成更有用的特征表示，使后续任务更容易处理。它关心的不只是“最后预测对不对”，还关心模型内部是否学到了稳定、可迁移、对任务有判别力的中间表示。</p>
<p>传统特征工程（Feature Engineering）与表示学习处理的是同一个核心问题：如何把原始输入变成更适合下游任务的表示。但二者的方法论不同。特征工程主要依赖人工设计表示，例如词频、n-gram、统计量、规则特征与人工交叉特征；表示学习则强调由模型通过优化过程自动学出表示，例如 PCA、自编码器、词向量、上下文化表示以及深度网络中的隐藏状态。因此，传统手工特征本身通常不直接归入表示学习；只有当表示是通过训练自动获得时，它才更准确地属于表示学习范畴。</p>
<p>从 one-hot、BoW、词嵌入，到 BERT 的上下文化表示、Sentence-BERT 的句向量，主线始终一致：把原始符号或原始观测映射到更适合计算的表示空间。监督学习、自监督学习、对比学习都可以被用来学习表示；区别只在于监督信号来自哪里、训练目标如何设计。</p>
<div class="blog_h3"><span class="graybg">对比学习</span></div>
<p>对比学习（Contrastive Learning）通过“拉近正样本、推远负样本”学习表示。这里的关键不是类别标签本身，而是样本之间的相对关系：哪些应该相似，哪些必须区分。因此它特别适合表示学习、检索、多模态对齐和度量学习（Metric Learning）。</p>
<p>它的真正价值不只在于“学会匹配”，更在于学会<span style="background-color: #c0c0c0;">区分性特征（Discriminative Features）</span>。如果训练信号只告诉模型“这两个文本有关”，模型很容易停留在泛泛的共性描述上；而当训练持续提供“相似对”和“不相似对”，模型就被迫回答更尖锐的问题：究竟是什么让这两个文本属于同一语义区域，又是什么让它们必须分开。对比学习因此天然擅长抑制“表面上正确但没有区分度”的表示，转而强化真正决定语义边界的特征。</p>
<p>例如在商品评论表示学习里，句子“物流很快，包装也完整”和“物流很快，但东西是坏的”都包含“物流很快”这类高频表述。若模型只抓住表面词汇重叠，就可能把二者编码得非常接近；但在对比学习里，前者可能与“发货速度快、体验不错”构成正样本，后者则会与“收到商品后无法使用”“质量有问题”这类负面评价更接近。模型因此会逐步学会：真正决定语义边界的，不是共享的套话，而是“包装完整”“东西是坏的”这类改变整体语义走向的区分性片段。</p>
<p>从几何角度看，对比学习学到的是一个更有结构的向量空间。语义接近的文本会在局部形成簇（Cluster），语义无关或语义相反的文本则被推向更远位置。情感分析、语义检索、重复问句检测、意图聚类之所以能直接建立在 embedding 之上，本质上就是因为模型已经把“哪些内容应当靠近、哪些内容应当远离”编码进了空间结构，而不只是输出一个任务特定的分类分数。</p>
<p>在 NLP 中，这条路线并不是突然出现的。Word2Vec 已经体现了早期的对比式思想：真实共现词是正样本，随机采样词是负样本，模型通过区分“真实上下文”和“噪声配对”学习词向量。后来的句向量和文档向量模型，则把这种思想从词级扩展到句子级和文档级：正样本可以是复述句、问答配对、查询与相关文档，负样本则是不相关句子或困难反例（Hard Negatives）。</p>
<p>一个常见形式是 InfoNCE 损失：</p>
<span displaypfx="" class="mathjax-container">\[-\log \frac{\exp(\mathrm{sim}(z_i,z_i^+)/\tau)}{\sum_{j}\exp(\mathrm{sim}(z_i,z_j)/\tau)}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(z_i\)</span> 是当前样本表示， <span displaypfx="inline-" class="mathjax-container">\(z_i^+\)</span> 是与它匹配的正样本表示， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{sim}(\cdot,\cdot)\)</span> 是相似度函数（常用余弦相似度）， <span displaypfx="inline-" class="mathjax-container">\(\tau\)</span> 是温度参数（Temperature），控制分布尖锐程度。这个目标的含义是：在一堆候选中，让正确配对拿到最高分，同时把不相关样本推远。</p>
<p>对比学习在句向量任务中的意义尤其大。交叉编码器（Cross-Encoder）把两个句子拼接后联合编码，能够做非常细的交互判断，但它直接输出的是“这一对句子有多像”，而不是可复用的独立句向量；一旦候选集合很大，计算量会迅速爆炸。双编码器（Bi-Encoder）路线则把两个文本分别编码成独立向量，再用余弦相似度或点积比较。SBERT 正是这一路线的经典代表：它通过孪生网络（Siamese Network）与对比式微调，把原本不适合作为通用句向量的 BERT 表示空间，改造成适合检索、聚类与语义匹配的 embedding 空间。</p>
<p>工程上，负样本既可以来自同一 batch 中的其他样本（In-batch Negatives），也可以来自专门构造的困难负样本（Hard Negatives）。所谓困难负样本，指的不是“完全无关”的反例，而是<span style="background-color: #c0c0c0;">在表面上很像、但语义上不应被判为同一项</span>的样本。例如检索里，与查询主题相近但并不真正回答问题的文档；句向量训练里，措辞高度相似却语义立场不同的句子；推荐里，风格相近但用户最终没有点击或转化的候选。它们之所以“困难”，正是因为模型若只依赖浅层词汇重叠、模板结构或主题相近性，很容易把这类负样本误判成正样本。</p>
<p>困难负样本的价值在于：它迫使模型放弃过于粗糙的匹配捷径，转而学习更细粒度的区分信号。随机负样本通常太容易分开，训练后期提供的梯度会迅速变弱；而困难负样本更接近真实决策边界，能持续推动表示空间学习“看起来相似但本质不同”的区别。不过它也有代价：若负样本挖掘质量不高，容易把本来就相关的样本错当成负例，形成假负样本（False Negatives），反而会伤害表示质量。因此，现代检索和 embedding 训练里，Hard Negatives 往往与 in-batch negatives、教师模型挖掘（teacher mining）或 reranker 筛选结合使用，而不是完全依赖人工拍脑袋构造。</p>
<p>CLIP、Sentence-BERT、现代检索 embedding、推荐召回模型，乃至许多 query-document dual encoder，本质上都在利用这种“正样本拉近、负样本推远”的训练逻辑。区别主要不在原理，而在样本如何构造、负样本如何选择，以及表示对象是词、句子、文档还是跨模态对。</p>
<div class="blog_h4"><span class="graybg">负采样</span></div>
<p>负采样（Negative Sampling）是与对比学习和词向量训练密切相关的一类近似策略。它的核心动机是：当候选空间极大时，没有必要每次都与所有候选比较；只保留 1 个正样本和少量负样本，就能得到足够强的判别信号。换句话说，它把原本代价高昂的“大规模归一化选择问题”，近似成若干个“真配对还是噪声配对”的二分类判断。</p>
<p>在 Word2Vec 的 Skip-gram 中，若直接对全词表做 softmax，分母需要对 <span displaypfx="inline-" class="mathjax-container">\(|{\cal V}|\)</span> 个词求和，计算代价很高。负采样则对每个正样本对 <span displaypfx="inline-" class="mathjax-container">\((w,c)\)</span> 只保留少量噪声词 <span displaypfx="inline-" class="mathjax-container">\(w_i\)</span>，并最大化：</p>
<span displaypfx="" class="mathjax-container">\[\log\sigma(v_w^\top v_c)+\sum_{i=1}^{k}\log\sigma(-v_w^\top v_{w_i})\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 是 sigmoid 函数，第一项鼓励真实配对的内积更大，第二项鼓励噪声配对的内积更小。这样一来，计算量就从与词表大小同阶，降到与 <span displaypfx="inline-" class="mathjax-container">\(1+k\)</span> 个样本同阶。负样本也不一定完全随机：Word2Vec 常按词频的 <span displaypfx="inline-" class="mathjax-container">\(0.75\)</span> 次方采样；现代对比学习则常用 in-batch negatives 或 hard negative mining。推荐系统召回、知识图谱嵌入、句向量训练等任务里，这种思想到今天仍然非常常见。</p>
<div class="blog_h3"><span class="graybg">迁移学习</span></div>
<p>迁移学习（Transfer Learning）讨论的是：先在数据更丰富、任务更通用的源任务上学到参数或表示，再把这些知识迁移到目标任务。它不是按监督信号划分出来的独立“学习方式”，而是一种<span style="background-color: #c0c0c0;">跨任务复用知识的训练策略</span>。现代大模型先预训练、再微调，本质上就是迁移学习。</p>
<p>BERT 就是这一思路的典型例子。它通常先在大规模通用文本上做语言建模预训练，例如维基百科（Wikipedia）这类覆盖面很广的语料；模型先学到词法、句法、语义关系以及上下文表示能力。随后再把这一预训练模型迁移到具体任务上，例如情感分类、自然语言推断（NLI）、命名实体识别（NER）或文本匹配，只需接上任务头并用该任务的数据继续微调，就能把通用语言知识转化为面向目标任务的能力。</p>
<p>它与对比学习不在同一层面。对比学习回答的是“预训练阶段该用什么目标来学表示”；迁移学习回答的是“学到的表示如何迁到新任务”。两者经常配合出现：例如先在海量无标签图像上用对比学习预训练视觉编码器，再把该编码器迁移到医学影像分类、工业缺陷检测或小样本识别任务上。</p>
<div class="blog_h3"><span class="graybg">少样本学习</span></div>
<p>少样本学习（Few-shot Learning）处理的是“每个任务只有极少标注样本”时如何仍然快速泛化。它通常建立在迁移学习或预训练模型之上：模型先学到一套通用表示，再在很少示例下快速适配新任务。困难不在于单个任务本身，而在于模型必须把以往经验迁移到新任务上。直觉上，它更像“学会如何快速学习”，而不是“把一个任务彻底学透”。</p>
<div class="blog_h4"><span class="graybg">零样本（Zero-shot）</span></div>
<p>零样本（Zero-shot）指模型在目标任务上没有任何专门示例，也能凭借已有知识完成任务。大语言模型通过指令理解实现的很多能力都属于这一类。例：不给任何情感分类样例，只写“判断下面评论是正面还是负面”，模型仍可能完成分类。</p>
<div class="blog_h4"><span class="graybg">单样本（One-shot）</span></div>
<p>单样本（One-shot）指只给 1 个示例。这个示例的价值不是提供统计规律，而是告诉模型“输出格式、任务边界和你想要的判别标准”。例如先给一条“商品评论 → 正面”的例子，再让模型判断下一条评论。</p>
<div class="blog_h4"><span class="graybg">K 样本（K-shot）</span></div>
<p>K 样本（K-shot）指给每类或每任务提供 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个示例。随着 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 增大，模型更容易对任务意图和判别标准形成稳定估计。工程上，prompt 中的 few-shot 示例本质上就是在上下文窗口里做一种“临时任务适配”。</p>
<div class="blog_h4"><span class="graybg">元学习（Meta-learning / MAML）</span></div>
<p>元学习（Meta-learning）研究“让模型更快适应新任务”。MAML（Model-Agnostic Meta-Learning）的核心不是直接学一个最终答案，而是学一个好的初始化参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>，使模型只需少量梯度更新就能适配新任务。</p>
<p>MAML 的外层目标可概括为：</p>
<span displaypfx="" class="mathjax-container">\[\min_\theta \sum_{\mathcal{T}} \mathcal{L}_{\mathcal{T}}\big(\theta-\alpha\nabla_\theta \mathcal{L}_{\mathcal{T}}(\theta)\big)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{T}\)</span> 表示一个任务， <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 是内层更新步长。读法是：先用当前参数在某任务上走一步，再看更新后的参数在该任务上的表现好不好；如果“一步后就变好”，说明初始化是好的。类比来看，MAML 训练的不是“会做每道题的学生”，而是“只要老师讲一遍就能迅速举一反三的学生”。</p>
<div class="blog_h4"><span class="graybg">原型网络（Prototypical Networks）</span></div>
<p>原型网络（Prototypical Networks）把每个类别表示成嵌入空间中的一个“类中心（Prototype）”。对类别 <span displaypfx="inline-" class="mathjax-container">\(k\)</span>，其原型定义为该类支持集（Support Set）样本嵌入的平均：</p>
<span displaypfx="" class="mathjax-container">\[c_k=\frac{1}{|S_k|}\sum_{(x_i,y_i)\in S_k,\ y_i=k} f_\theta(x_i)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(f_\theta(x_i)\)</span> 是样本的向量表示， <span displaypfx="inline-" class="mathjax-container">\(S_k\)</span> 是类别 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 的支持样本集合。分类时，把新样本映射到嵌入空间，看它离哪个原型最近。直觉上，这像“每一类先算一个代表点，新样本按离哪个代表点最近来归类”。在 few-shot 图像分类中，这种方法往往比直接训练复杂分类头更稳。</p>
<div class="blog_h2"><span class="graybg">表示聚合与池化</span></div>
<p>池化（Pooling）可以先按一句人话来理解：<span style="background-color: #c0c0c0;">把一组相邻或相关的特征，压缩成更短、更稳定、更容易继续处理的摘要</span>。它不是重新发明新特征，而是对已有特征做聚合（Aggregation）或下采样（Downsampling）。这里的下采样指：<span style="background-color: #c0c0c0;">沿某些维度减少位置数或采样点数，让表示尺寸变小、分辨率变粗</span>。例如把 <span displaypfx="inline-" class="mathjax-container">\(4\times 4\)</span> 的特征图压成 <span displaypfx="inline-" class="mathjax-container">\(2\times 2\)</span>，或把一长段序列压成更短的摘要向量，都属于下采样。</p>
<p>若把一组输入特征记为 <span displaypfx="inline-" class="mathjax-container">\(x_1,\dots,x_k\)</span>，则池化可以抽象写成</p>
<span displaypfx="" class="mathjax-container">\[y=\mathrm{Pool}(x_1,\dots,x_k)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Pool}\)</span> 可以是最大值（Max Pooling）、平均值（Average Pooling）、求和（Sum Pooling）或更复杂的加权聚合。它们做的事不同，但主线一致：<span style="background-color: #c0c0c0;">把“多个位置/多个元素的表示”变成“更少的表示”</span>。</p>
<p>池化之所以重要，是因为很多任务并不需要保留每个细节位置的完整分辨率。图像分类不一定关心边缘恰好落在第 17 个还是第 18 个像素；句子分类也不一定要求记住某个情绪词出现在第 6 个还是第 7 个 token。此时，把局部细节适度压缩，往往能提升稳定性、降低计算量，并让后续层更关注“有没有出现模式”，而不是“模式的坐标是否一模一样”。</p>
<div class="blog_h3"><span class="graybg">最常见的几种池化</span></div>
<p>最大池化（Max Pooling）保留一组特征里最强的那个响应。若某个窗口里有一个边缘、某个关键词或某个邻居信号特别强，最大池化会把它留下来。它更像在问：<span style="background-color: #c0c0c0;">这一小块区域里，最显著的模式有没有出现</span>。</p>
<p>平均池化（Average Pooling）对一组特征取平均，更强调整体趋势而不是最强局部点。它更像在问：这一块区域总体上激活强不强、语义平均水平如何。</p>
<p>求和池化（Sum Pooling）常见于图网络和集合建模，用于累积总量信息。若节点数量本身有意义，求和会把“有多少邻居/总共多强”也编码进去；平均池化则更强调归一化后的平均强度。</p>
<p>全局池化（Global Pooling）表示不再只看局部窗口，而是直接把整张特征图、整段序列或整个节点集合压成一个向量。例如全局平均池化（Global Average Pooling, GAP）会把一整个空间维度平均掉，得到“每个通道在全局上的平均响应”。自适应池化（Adaptive Pooling）则把输出尺寸预先固定，例如无论输入特征图多大，最终都压成 <span displaypfx="inline-" class="mathjax-container">\(1\times 1\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(7\times 7\)</span>。</p>
<div class="blog_h3"><span class="graybg">不同网络里的含义</span></div>
<p>在卷积神经网络（CNN）里，池化最经典的含义是<span style="background-color: #c0c0c0;">沿空间维度做局部下采样</span>。例如一张特征图经过 <span displaypfx="inline-" class="mathjax-container">\(2\times 2\)</span> 最大池化后，宽高会缩小，局部最强响应被保留下来。它的直接收益有三点：减小特征图尺寸、扩大后续层的感受野（Receptive Field）、并降低模型对小幅平移和局部扰动的敏感度。</p>
<p>在时序模型和文本模型里，池化更常表示<span style="background-color: #c0c0c0;">沿时间或序列长度维度做聚合</span>。例如把所有 token 表示做平均池化，得到整句向量；把一段音频帧表示做最大池化，得到“这一整段里最强的模式”。这里池化的重点不再是二维空间下采样，而是把变长序列压成固定长度表示，方便做分类、检索或相似度计算。</p>
<p>在图神经网络（GNN）里，池化有两层常见含义。第一层是邻域聚合（Neighborhood Aggregation）：一个节点把邻居表示做均值、求和或最大值，再更新自己；这可以理解为“节点级局部池化”。第二层是图级读出（Graph-level Readout）：把整张图的节点表示再做一次全局聚合，得到整个图的表示，用于图分类、图回归等任务。</p>
<p>在 Transformer 里，池化通常不再以“池化层”这一模块形式高频出现，但概念仍然存在。句子分类常取 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{CLS}]\)</span> 位置表示，或对所有 token 做平均池化；Embedding 模型也常对最后一层隐藏状态做 mean pooling / max pooling 得到句向量。进一步看，注意力（Attention）本身也可以理解成一种<span style="background-color: #c0c0c0;">带内容依赖的加权聚合</span>：区别只在于普通池化的规则通常固定，而注意力的权重是由输入动态决定的。</p>
<div class="blog_h3"><span class="graybg">池化到底保留了什么、丢掉了什么</span></div>
<p>池化保留的是摘要信息，丢掉的是更精细的位置细节。最大池化更偏向“是否出现过显著模式”，平均池化更偏向“整体平均状态如何”，求和池化则更偏向“总量有多大”。因此，池化总带有一种 trade-off：表示更紧凑、更稳、更省算力，但精确定位能力会下降。</p>
<p>这也是为什么不同任务会选择不同聚合方式。图像分类往往欢迎一定程度的位置不敏感，因此池化很自然；语义检索希望一整句压成一个句向量，因此句级池化很常见；但像语义分割、目标检测、序列标注这类任务，输出本身依赖逐位置判断，就不能过早把位置信息池掉，否则细粒度边界会被抹平。</p>
<div class="blog_h3"><span class="graybg">上采样、插值与转置卷积</span></div>
<p>若说下采样（Downsampling）是在压缩表示，那么上采样（Upsampling）就是反过来<span style="background-color: #c0c0c0;">把较粗的表示扩展回更细的空间分辨率</span>。它做的不是凭空创造新信息，而是把低分辨率特征重新铺回更高分辨率的位置网格中，方便恢复空间结构、边界或局部细节。</p>
<p>插值（Interpolation）是最直接的上采样方式。最邻近插值（Nearest Neighbor Interpolation）相当于把原像素或原特征直接复制到更密的网格里，速度快、实现简单，但边界可能显得生硬；双线性插值（Bilinear Interpolation）则按周围位置做平滑加权，结果更连续，但也更容易把边界抹平。它们的共同特点是：上采样规则是固定的，不需要学习参数。</p>
<p>转置卷积（Transposed Convolution）则是<span style="background-color: #c0c0c0;">可学习的上采样</span>。它不是“把普通卷积矩阵简单转置后就 magically 还原图像”，而是指：若把普通卷积看作一个线性算子，转置卷积对应的是这个线性算子的转置形式，因此能够把较小的特征图映射到较大的特征图。因为它带有可学习卷积核，所以模型可以学习“该怎样把粗特征展开成更适合任务的细特征”。</p>
<p>在视觉任务里，这三者的分工很常见。语义分割、图像生成、超分辨率、U-Net 解码器、扩散模型解码器都需要上采样：有时先用插值把尺寸放大，再接普通卷积细化；有时直接用转置卷积一步完成“放大 + 学习性重组”。前者通常更稳、更少棋盘格伪影（Checkerboard Artifacts），后者表达力更强，但更依赖实现细节与核大小、步幅（Stride）设置。</p>
<div class="blog_h2"><span class="graybg">泛化、过拟合与偏差—方差</span></div>
<p>机器学习真正关心的不是训练集上错多少，而是模型离开训练集之后还能否维持稳定表现。泛化（Generalization）、过拟合（Overfitting）、欠拟合（Underfitting）与偏差—方差权衡（Bias–Variance Tradeoff）描述的，就是这件事。</p>
<div class="blog_h3"><span class="graybg">泛化</span></div>
<p>泛化（Generalization）指模型在未见样本上的表现。若训练数据与未来输入都来自同一数据分布 <span displaypfx="inline-" class="mathjax-container">\(P(X,Y)\)</span>，则模型的总体风险可写成：</p>
<span displaypfx="" class="mathjax-container">\[R(f)=\mathbb{E}_{(x,y)\sim P}\big[\ell(f(x),y)\big]\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(R(f)\)</span> 是真实风险（Population Risk），表示模型 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 在整个真实分布上的平均损失； <span displaypfx="inline-" class="mathjax-container">\(\ell(f(x),y)\)</span> 是单样本损失；期望 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}\)</span> 表示对所有可能样本做平均。训练时真正能看到的只有有限样本，因此优化的通常是经验风险 <span displaypfx="inline-" class="mathjax-container">\(\hat R_n(f)\)</span>，而不是这个理想化的总体风险。</p>
<p>训练误差与测试误差之间的差距，常称为泛化间隙（Generalization Gap）。间隙小说明模型在未见数据上比较稳定；间隙大则说明模型过度依赖训练样本中的偶然细节。</p>
<div class="blog_h3"><span class="graybg">过拟合与欠拟合</span></div>
<p>欠拟合（Underfitting）表示模型过于简单，连训练集里的主结构都学不出来；过拟合（Overfitting）表示模型对训练集学得过细，把噪声、异常点和偶然波动也当成了规律。两者都属于泛化失败，只是失败方式不同。</p>
<p>判断时通常同时看训练误差与验证误差。欠拟合时，训练误差和验证误差都偏高；过拟合时，训练误差可能很低，但验证误差明显更高。前者更像“模型看不懂题”，后者更像“模型把练习册答案背熟了，却没有真正掌握规律”。</p>
<p>过拟合并不只由参数多导致。高维稀疏特征、标签噪声、样本量不足、分布偏移、数据泄露、过长训练时间和过弱正则化，都会放大过拟合风险。欠拟合则常见于模型容量不足、特征表达太弱、训练尚未收敛，或者任务本身需要非线性而模型只允许线性表达。</p>
<div class="blog_h3"><span class="graybg">偏差与方差</span></div>
<p>偏差（Bias）与方差（Variance）是分析泛化误差来源的经典视角。对平方损失（Squared Loss），常见分解写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}\big[(Y-\hat f(X))^2\big]=\mathrm{Bias}^2+\mathrm{Variance}+\sigma^2\]</span>
<p>左边的 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[(Y-\hat f(X))^2]\)</span> 是模型的平均平方误差； <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Bias}^2\)</span> 表示模型平均预测与真实函数之间的系统性偏离； <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Variance}\)</span> 表示模型对训练样本波动的敏感度； <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 是数据本身不可约的噪声（Irreducible Noise），即使模型和训练过程都完美，也无法完全消除。</p>
<p>高偏差通常对应模型表达能力不足，例如用直线去拟合强非线性关系；高方差通常对应模型过于敏感，例如少量训练样本变化就会让决策边界大幅摆动。工程上，降低偏差常靠更强模型、更好特征和更充分训练；降低方差常靠更多数据、正则化、数据增强、早停（Early Stopping）和集成学习（Ensemble Learning）。很多建模决策，本质上都是在这两类误差之间做权衡。</p>
<div class="blog_h2"><span class="graybg">经验风险最小化与正则化</span></div>
<p>大部分监督学习训练都可以概括成同一条主线：先定义单样本损失，再在训练集上取平均形成经验风险，最后通过优化算法把它压低。正则化（Regularization）是在这条主线之上加入额外约束，用来控制复杂度并改善泛化。</p>
<div class="blog_h3"><span class="graybg">经验风险最小化</span></div>
<p>经验风险最小化（Empirical Risk Minimization, ERM）是统计学习的基本训练原则。设训练集为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{D}=\{(x_i,y_i)\}_{i=1}^{n}\)</span>，则经验风险定义为：</p>
<span displaypfx="" class="mathjax-container">\[\hat R_n(f)=\frac{1}{n}\sum_{i=1}^{n}\ell(f(x_i),y_i)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 是样本数， <span displaypfx="inline-" class="mathjax-container">\(f(x_i)\)</span> 是模型对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本的预测， <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 是真实标签， <span displaypfx="inline-" class="mathjax-container">\(\ell\)</span> 是损失函数。经验风险最小化就是在假设空间 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}\)</span> 中寻找让这条平均损失最小的函数：</p>
<span displaypfx="" class="mathjax-container">\[\hat f_{\mathrm{ERM}}=\arg\min_{f\in\mathcal{H}}\hat R_n(f)\]</span>
<p>这条原则覆盖范围极广。线性回归最小化的是平方误差经验风险，逻辑回归和多分类神经网络最小化的是交叉熵经验风险，序列标注模型最小化的是序列级条件对数似然。算法形式不同，骨架是一致的。</p>
<div class="blog_h3"><span class="graybg">正则化</span></div>
<p>正则化（Regularization）是在经验风险之外，再加入一个偏好“更简单、更平滑、更稳定”解的约束项。常见写法是：</p>
<span displaypfx="" class="mathjax-container">\[\hat f=\arg\min_{f\in\mathcal{H}}\hat R_n(f)+\lambda\,\Omega(f)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\Omega(f)\)</span> 是正则项（Regularizer），刻画模型复杂度； <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 是正则化强度，决定“拟合训练集”和“控制复杂度”之间的权衡。 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 越大，模型越保守；越小，模型越自由。</p>
<p>L2 正则（L2 Regularization）偏好较小权重，常写成 <span displaypfx="inline-" class="mathjax-container">\(\Omega(f)=\|\mathbf{w}\|_2^2\)</span>；L1 正则（L1 Regularization）偏好稀疏解，常写成 <span displaypfx="inline-" class="mathjax-container">\(\Omega(f)=\|\mathbf{w}\|_1\)</span>。更广义地看，早停、Dropout、数据增强、权重共享、标签平滑、参数冻结、低秩适配，都是在不同层面对模型自由度施加约束，因此都可以看作正则化思想的工程实现。</p>
<div class="blog_h2"><span class="graybg">IID 与分布偏移</span></div>
<p>机器学习中的很多训练与评估结论，都建立在一个默认前提上：训练样本与未来样本来自同一统计机制。这个前提通常写成 IID（Independent and Identically Distributed，独立同分布）假设。只要这个前提破坏，训练集表现与线上表现之间就可能出现明显断裂。</p>
<div class="blog_h3"><span class="graybg">IID 假设</span></div>
<p>设样本对 <span displaypfx="inline-" class="mathjax-container">\((x_i,y_i)\)</span> 来自某个联合分布 <span displaypfx="inline-" class="mathjax-container">\(P(X,Y)\)</span>，IID 假设写成：</p>
<span displaypfx="" class="mathjax-container">\[(x_1,y_1),\dots,(x_n,y_n)\overset{\mathrm{iid}}{\sim}P(X,Y)\]</span>
<p>这里“独立（Independent）”表示一个样本是否出现，不影响另一个样本的生成；“同分布（Identically Distributed）”表示所有样本都来自同一个联合分布 <span displaypfx="inline-" class="mathjax-container">\(P(X,Y)\)</span>。这个假设让训练集平均损失能够作为总体风险的近似，也让交叉验证、置信区间和很多泛化理论成立。</p>
<p>IID 是理想化近似，而不是自然界的铁律。时间序列、推荐系统、金融交易、医疗数据、A/B 实验日志、用户行为数据，常常都存在相关性、群组效应、时间漂移或采样偏差，因此不能机械套用 IID 设定。</p>
<div class="blog_h3"><span class="graybg">分布偏移</span></div>
<p>当训练分布与测试分布不一致时，就发生了分布偏移（Distribution Shift）：</p>
<span displaypfx="" class="mathjax-container">\[P_{\mathrm{train}}(X,Y)\neq P_{\mathrm{test}}(X,Y)\]</span>
<p>分布偏移有几种常见形式。协变量偏移（Covariate Shift）指 <span displaypfx="inline-" class="mathjax-container">\(P(X)\)</span> 变化，而 <span displaypfx="inline-" class="mathjax-container">\(P(Y|X)\)</span> 基本稳定；例如线上用户年龄结构变了，但“给定用户画像时是否点击”的规律没明显变。标签偏移（Label Shift）指 <span displaypfx="inline-" class="mathjax-container">\(P(Y)\)</span> 变化，例如欺诈率在促销期突然上升。概念漂移（Concept Drift / Concept Shift）指 <span displaypfx="inline-" class="mathjax-container">\(P(Y|X)\)</span> 本身发生变化，例如垃圾邮件发送策略升级后，原来有效的文本模式不再可靠。</p>
<p>因此，训练集、验证集与测试集的切分不能只追求随机均匀，还必须尽量模拟未来部署环境。若线上是时间推进场景，测试集就应按时间后移；若线上按用户或设备泛化，切分就应按实体隔离；若业务分布持续漂移，还需要做持续监控、重训和再校准。分布偏移不是评估里的边角问题，而是机器学习系统走向生产后的主要失效来源之一。</p>
<div class="blog_h2"><span class="graybg">数据集工程</span></div>
<p>数据集工程（Dataset Engineering）决定了模型看到什么、以什么尺度看到、又会被哪些偏差误导。很多所谓“模型问题”，根源其实是数据问题：标签噪声、分布漂移、类别极不均衡或特征泄漏，都会直接扭曲训练结果。</p>
<div class="blog_h3"><span class="graybg">黄金/白银数据集</span></div>
<p>数据集工程里常见一个实用分层：黄金数据集（Gold Dataset）与白银数据集（Silver Dataset）。黄金数据集通常指由高质量人工标注、规则严格审核或专家确认得到的小而精数据，标签噪声低，适合做最终评测、关键验证集或高价值监督信号；白银数据集则通常来自启发式规则、弱监督（Weak Supervision）、模型打标、日志回收或大规模自动清洗，规模更大、成本更低，但噪声也更高。实际工程中，常见策略不是二选一，而是用白银数据集提供覆盖面和规模，用黄金数据集提供校准、纠偏与最终可信评估。</p>
<div class="blog_h3"><span class="graybg">数据划分</span></div>
<p>数据划分的目标，是把<span style="background-color: #c0c0c0;">学参数、做模型选择、汇报最终结果</span>这三件事严格隔离开。若同一批数据既用来训练参数，又用来调超参数，最后还拿来汇报效果，评估结果通常会乐观得不真实，因为模型已经间接“看过”了答案。</p>
<p>从统计学习角度看，这三类数据分别承担三种不同职责：训练集负责让模型学习参数；验证集负责帮助人或训练流程做工程决策；测试集负责模拟真正的未知数据，给出最后一次、尽量无偏的泛化评估。三者分工清楚，模型评估才有可信度。</p>
<div class="blog_h4"><span class="graybg">训练集</span></div>
<p>训练集（Training Set）用于更新模型参数。监督学习中，训练集包含输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 与标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>；模型在这批样本上计算损失（Loss）、反向传播梯度（Gradient）并更新参数，因此训练集直接决定“模型学到了什么”。它回答的问题是：<span style="background-color: #c0c0c0;">在已观测样本上，模型有没有学会输入与输出之间的对应关系</span>。</p>
<p>训练集通常应占数据的大头，因为参数学习需要足够多的样本来稳定估计模式。实践中常见比例是 70% 到 80%，但这不是固定规则：若数据总量非常大，训练集比例可以更高；若数据本来就少，则往往需要把更多精力放在交叉验证（Cross Validation）而不是死守固定比例。</p>
<p>训练集上的误差通常是三者里最低的，这并不说明模型已经具有泛化能力。一个模型完全可能在训练集上表现极好，却只是记住了样本中的噪声与偶然性。训练集成绩更多反映“拟合能力”，而不是“真实上线表现”。</p>
<div class="blog_h4"><span class="graybg">验证集</span></div>
<p>验证集（Validation Set）用于模型选择（Model Selection）和超参数调优（Hyperparameter Tuning）。它不直接参与参数更新，但会影响训练流程中的关键决策，例如学习率（Learning Rate）、正则化强度、模型深度、树的数量、batch size、阈值选择，以及是否执行 Early Stopping。</p>
<p>验证集回答的问题不是“模型能不能学会”，而是：<span style="background-color: #c0c0c0;">在若干候选配置里，哪一个更可能在新数据上表现最好</span>。因此，验证集像训练过程中的“模拟考试”：它不是最终成绩单，但会决定你在训练期间如何改模型、如何调参数、何时停止训练。</p>
<p>验证集通常占总数据的 10% 到 15% 左右。若数据量很小，单独留出一份验证集的代价会较高，此时更常见的做法是使用 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 折交叉验证，让每个样本轮流充当验证数据，以减少一次随机划分带来的偶然性。交叉验证的细节放在后面的“模型评估”部分展开。</p>
<div class="blog_h4"><span class="graybg">测试集</span></div>
<p>测试集（Test Set）用于最终评估模型的泛化能力（Generalization）。它应尽量只在方案冻结之后使用：模型结构、超参数、训练策略、阈值和后处理规则都不再修改时，才在测试集上做一次最终评估。它回答的问题是：<span style="background-color: #c0c0c0;">如果把模型部署到真实世界，它在新样本上的表现大致会怎样</span>。</p>
<p>测试集通常占总数据的 10% 到 15%。它的重要性不在于比例有多大，而在于它必须保持“未参与决策”。如果开发过程中反复查看测试集结果，并据此继续改模型，那么测试集就已经被污染，不再是独立评测，而变成了另一个隐性的验证集。</p>
<p>因此，测试集更像真正的“高考卷”或“盲测集”：它的价值在于最后一次、尽量无偏的评估，而不是参与训练流程本身。</p>
<div class="blog_h4"><span class="graybg">如何划分数据集</span></div>
<p>最常见的简单划分是训练集 / 验证集 / 测试集 = 70% / 15% / 15%，或 80% / 10% / 10%。这种划分适合样本量较大、类别分布较稳定的任务，因为单次随机切分已经足以给出相对稳定的训练与评估结果。</p>
<p>当数据量较小、类别极不平衡、或者不同子群体差异明显时，划分策略就必须更谨慎。分类任务常采用分层抽样（Stratified Split），确保训练、验证、测试三部分的类别比例大致一致；时间序列任务则必须按时间顺序切分，避免未来信息泄漏到过去；用户级、设备级、病人级任务常需要按实体分组切分，防止同一实体的样本同时出现在训练集和测试集中，造成过于乐观的结果。</p>
<p>因此，“如何划分”本身就是建模的一部分。划分方式若与真实部署场景不一致，即使指标很好，也可能只是评估设定过于宽松，而不是真正泛化能力强。</p>
<div class="blog_h4"><span class="graybg">数据泄露</span></div>
<p>数据泄露（Data Leakage）指测试集或验证集中的信息以直接或间接方式进入训练过程，从而导致模型评估结果虚高。它的危险不在于“代码报错”，而在于模型会表现得看似极好，却无法在真实新数据上复现。</p>
<p>最常见的数据泄露有几类。第一类是<span style="background-color: #c0c0c0;">先对全量数据做预处理，再切分数据</span>，例如先用全量数据计算标准化均值和方差，再划分训练 / 测试集；这样测试集的信息已经进入了训练流程。第二类是<span style="background-color: #c0c0c0;">用测试集反复调参</span>，例如每改一次模型就看一次测试集成绩，直到测试集最好看为止。第三类是<span style="background-color: #c0c0c0;">特征中混入未来或标签信息</span>，例如用预测时不可能知道的字段做输入，或把目标变量的某种变形偷偷带进特征。</p>
<p>避免数据泄露的原则只有一句：<span style="background-color: #c0c0c0;">任何依赖数据分布统计量、特征构造规则、模型选择决策或阈值选择的步骤，都只能在训练集内部完成，再把同样的变换应用到验证集和测试集</span>。标准化、特征选择、缺失值填补、目标编码（Target Encoding）、降维（PCA）和重采样（Resampling）都要遵守这一原则。</p>
<p>因此，数据集划分不只是“把数据分三份”这么简单，而是整个实验设计（Experimental Design）的一部分。只有训练集、验证集、测试集的职责边界清晰，交叉验证使用得当，且数据泄露被严格控制，模型指标才具有解释价值和可复现实验意义。</p>
<div class="blog_h3"><span class="graybg">归一化与标准化</span></div>
<p>归一化（Normalization）与标准化（Standardization）都在解决“不同特征量纲和尺度差异过大”问题，但含义不同。最常见的最小-最大归一化把数据映射到固定区间：</p>
<span displaypfx="" class="mathjax-container">\[x'=\frac{x-x_{\min}}{x_{\max}-x_{\min}}\]</span>
<p>它把特征压到 <span displaypfx="inline-" class="mathjax-container">\([0,1]\)</span>，适合像像素值、比例值这类天然有上下界的量。标准化则是减去均值、再除以标准差：</p>
<span displaypfx="" class="mathjax-container">\[z=\frac{x-\mu}{\sigma}\]</span>
<p>标准化后的特征均值为 0、标准差为 1，更适合线性模型、距离模型和很多神经网络优化过程。类比来看，归一化像“把不同长度的尺子都缩到同一长度区间”；标准化像“先平移到共同中心，再按波动尺度统一单位”。</p>
<div class="blog_h3"><span class="graybg">特征工程</span></div>
<p>特征工程（Feature Engineering）是把原始数据加工成更利于模型学习的表示。它不是“多造点列”这么简单，而是在把领域知识编码进输入空间。例：时间戳可以拆成小时、星期、是否节假日；用户行为日志可以构造近 7 天点击次数、转化率、时间衰减统计；文本可以做 TF-IDF、n-gram 或实体抽取。</p>
<p>类比来看，特征工程像做菜前的备料：同样的原料，如果已经切片、去骨、配好比例，后续烹饪会顺畅得多。经典机器学习对特征工程高度依赖；深度学习则把一部分特征学习自动化了，但在表格数据、推荐、广告和风控里，特征工程仍然决定上限。</p>
<div class="blog_h3"><span class="graybg">类别不平衡处理</span></div>
<p>类别不平衡（Class Imbalance）指某些类别样本远多于另一些类别。欺诈检测、故障检测、医学筛查里最典型：正类往往极少。如果不处理，模型可能通过“永远预测多数类”获得看似不错的 Accuracy，却在关键少数类上彻底失效。</p>
<p>常见处理方法包括：重采样（过采样少数类、欠采样多数类）、类别加权（Class Weighting）、阈值调整（Threshold Tuning）和使用更合适的指标（如 Precision、Recall、PR-AUC）。例如在信用卡欺诈场景中，正类只占 0.1%，此时“全判正常”会有 99.9% Accuracy，但业务价值几乎为 0。</p>
<div class="blog_h2"><span class="graybg">模型评估</span></div>
<p>模型评估（Model Evaluation）回答的不是“模型能不能在训练集上做对”，而是“模型在新数据上是否可靠，以及错误代价如何”。不同任务对应的指标重点不同：分类关心类别区分，回归关心数值偏差，排序关心相对顺序。</p>
<div class="blog_h3"><span class="graybg">交叉验证</span></div>
<p>交叉验证（Cross Validation）在数据较少时特别重要。最常见的 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 折交叉验证把数据分成 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 份：每次用其中 1 份做验证、其余 <span displaypfx="inline-" class="mathjax-container">\(K-1\)</span> 份训练，循环 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 次，最后对 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个验证结果取平均。</p>
<p>它的作用像“轮流把不同一份数据拿出来当模拟考试卷”，从而降低一次随机划分带来的偶然性。对小数据集而言，单次划分可能刚好“运气好或坏”；交叉验证则给出更稳定的泛化估计。</p>
<div class="blog_h3"><span class="graybg">校准</span></div>
<p>校准（Calibration）讨论的是：<span style="background-color: #c0c0c0;">模型给出的概率值，是否真的能当概率解释</span>。分类模型不只输出“判成哪一类”，还常输出一个置信分数，例如 <span displaypfx="inline-" class="mathjax-container">\(0.9\)</span>。若一个模型在所有“预测概率约为 0.9”的样本子集上，最终真的有约 90% 预测正确，那么它就是校准良好的；若它经常把只有 60% 把握的样本说成 90%，就属于过度自信（Overconfident）。</p>
<p>二分类中，若模型输出正类概率 <span displaypfx="inline-" class="mathjax-container">\(\hat p(x)\in[0,1]\)</span>，理想校准条件可写成：</p>
<span displaypfx="" class="mathjax-container">\[P(Y=1\mid \hat p(X)=p)=p\]</span>
<p>这里左边表示：在所有预测概率等于 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 的样本中，真实为正类的条件概率；右边的 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是模型自己报出的概率。两者相等时，概率输出就与真实频率一致。多分类场景下，常把模型最大类别概率当作置信度，并检验“报 80% 置信度的样本，是否真的大约 80% 正确”。</p>
<p>校准与准确率不是同一件事。一个模型可以分类很准，但概率不可靠；也可以概率尺度较准，但分类边界并不最优。前者常见于深层神经网络：argmax 分类结果不错，但 softmax 概率偏尖，置信度系统性偏高。涉及风险控制、医学筛查、自动驾驶、检索重排、多阶段决策时，概率是否可信往往和“分对多少”同样重要，因为阈值决策、人工复核和代价加权都依赖这个概率尺度。</p>
<p>校准的可视化工具通常是可靠性图（Reliability Diagram）。做法是把预测置信度分成若干区间，例如 <span displaypfx="inline-" class="mathjax-container">\([0.0,0.1),[0.1,0.2),\dots\)</span>，然后对每个区间分别计算平均置信度与真实准确率。若图上的点接近对角线 <span displaypfx="inline-" class="mathjax-container">\(y=x\)</span>，说明校准较好；若点普遍落在对角线下方，说明模型报得比实际更自信；若点在对角线上方，则说明模型偏保守。</p>
<p>常用数值指标是期望校准误差（Expected Calibration Error, ECE）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{ECE}=\sum_{m=1}^{M}\frac{|B_m|}{n}\,\big|\mathrm{acc}(B_m)-\mathrm{conf}(B_m)\big|\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 是置信度分箱数， <span displaypfx="inline-" class="mathjax-container">\(B_m\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 个置信度区间中的样本集合， <span displaypfx="inline-" class="mathjax-container">\(|B_m|\)</span> 是该区间样本数， <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 是总样本数， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{acc}(B_m)\)</span> 是该区间的实际准确率， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{conf}(B_m)\)</span> 是该区间的平均预测置信度。ECE 的含义很直接：把每个置信区间里“说得多准”和“实际多准”的差值取绝对值，再按样本占比加权平均。ECE 越小，表示整体校准越好。</p>
<p>另一类常见指标是 Brier Score。对二分类，它定义为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Brier}=\frac{1}{n}\sum_{i=1}^{n}(\hat p_i-y_i)^2\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\hat p_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本的预测正类概率， <span displaypfx="inline-" class="mathjax-container">\(y_i\in\{0,1\}\)</span> 是真实标签。它既惩罚分类错误，也惩罚概率刻度不准，因此兼顾区分能力与概率质量。与单纯 Accuracy 不同，Brier Score 会区分“错得有多离谱”：把一个负样本报成 <span displaypfx="inline-" class="mathjax-container">\(0.51\)</span> 和报成 <span displaypfx="inline-" class="mathjax-container">\(0.99\)</span>，代价并不相同。</p>
<p>工程上最常见的后处理方法是温度缩放（Temperature Scaling）。设原始 logits 为 <span displaypfx="inline-" class="mathjax-container">\(z_i\)</span>，则缩放后的 softmax 概率写成：</p>
<span displaypfx="" class="mathjax-container">\[p_i=\frac{\exp(z_i/T)}{\sum_j \exp(z_j/T)}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(T&gt;0\)</span> 是温度参数。 <span displaypfx="inline-" class="mathjax-container">\(T&gt;1\)</span> 会把分布拉平，降低过度自信； <span displaypfx="inline-" class="mathjax-container">\(T&lt;1\)</span> 会把分布压尖，提高置信度。温度参数通常在验证集上通过最小化负对数似然（Negative Log-Likelihood, NLL）来拟合，然后固定用于测试或部署阶段。它不会改变类别排序，因此常能在几乎不影响 Accuracy 的前提下改善概率校准。</p>
<div class="blog_h3"><span class="graybg">分类指标</span></div>
<div class="blog_h4"><span class="graybg">混淆矩阵（Confusion Matrix）</span></div>
<p>分类指标通常从混淆矩阵（Confusion Matrix）出发。设正类预测结果统计为真阳性 <span displaypfx="inline-" class="mathjax-container">\(TP\)</span>、假阳性 <span displaypfx="inline-" class="mathjax-container">\(FP\)</span>、真阴性 <span displaypfx="inline-" class="mathjax-container">\(TN\)</span>、假阴性 <span displaypfx="inline-" class="mathjax-container">\(FN\)</span>。不同指标本质上是在回答不同问题：是看“总共判对多少”，还是看“判成正类时有多准”，还是看“真实正类抓到了多少”。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/confusion-matrix.png"><img class="alignnone size-full wp-image-41785" src="https://blog.gmem.cc/wp-content/uploads/2026/03/confusion-matrix.png" alt="confusion-matrix" width="1024" height="776" /></a></p>
<div class="blog_h4"><span class="graybg">Accuracy</span></div>
<p>准确率（Accuracy）定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Accuracy}=\frac{TP+TN}{TP+TN+FP+FN}\]</span>
<p>它衡量“总体上判对了多少比例”，适合类别相对平衡、不同错误代价接近的场景。但在类别极不平衡时会误导：例如癌症筛查里，99% 都是阴性时，模型全判阴性也可能有 99% Accuracy，却毫无检测价值。</p>
<div class="blog_h4"><span class="graybg">Precision</span></div>
<p>精确率（Precision）定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Precision}=\frac{TP}{TP+FP}\]</span>
<p>它回答的是：“所有被模型判成正类的样本里，有多少真的为正。”当误报成本很高时，Precision 特别重要。例：垃圾邮件过滤里，如果把正常邮件误判成垃圾邮件代价很高，就要关心 Precision。</p>
<div class="blog_h4"><span class="graybg">Recall</span></div>
<p>召回率（Recall）定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Recall}=\frac{TP}{TP+FN}\]</span>
<p>它回答的是：“所有真实正类里，有多少被模型找出来了。”当漏报成本很高时，Recall 更关键。例：医学筛查里漏掉患者可能比多做一次复检更危险，因此 Recall 往往比 Precision 更重要。</p>
<div class="blog_h4"><span class="graybg">F1 Score</span></div>
<p>F1 值（F1 Score）是 Precision 与 Recall 的调和平均：</p>
<span displaypfx="" class="mathjax-container">\[F_1=\frac{2\cdot \mathrm{Precision}\cdot \mathrm{Recall}}{\mathrm{Precision}+\mathrm{Recall}}\]</span>
<p>之所以用调和平均而不是普通平均，是因为它会惩罚“一高一低”的不平衡情况。若一个模型 Precision 极高但 Recall 很低，它并不能拿到高 F1。F1 适合正负样本不平衡、且希望兼顾漏报与误报的场景。</p>
<div class="blog_h4"><span class="graybg">AUC-ROC</span></div>
<p>AUC-ROC 衡量模型在不同分类阈值下区分正负样本的整体能力。ROC 曲线横轴是假阳性率（False Positive Rate），纵轴是真阳性率（True Positive Rate）。AUC 是曲线下面积，范围在 <span displaypfx="inline-" class="mathjax-container">\([0,1]\)</span>；越接近 1，说明模型越能把正样本排在负样本前面。</p>
<p>它不依赖某一个固定阈值，因此适合比较“排序能力”。但在极端不平衡数据上，PR 曲线（Precision-Recall Curve）常更敏感，因为 ROC 容易被大量真阴性“冲淡”。</p>
<div class="blog_h3"><span class="graybg">回归指标</span></div>
<p>回归指标（Regression Metrics）衡量预测值与真实值之间的数值偏差。它们关注的不是“判对类别”，而是“偏差有多大、对大误差是否敏感、模型解释了多少波动”。以房价预测为例，预测 300 万和真实 320 万之间的差距，就是典型回归误差。</p>
<div class="blog_h4"><span class="graybg">MAE</span></div>
<p>平均绝对误差（Mean Absolute Error, MAE）定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{MAE}=\frac{1}{N}\sum_{i=1}^{N}|\hat y_i-y_i|\]</span>
<p>它直接度量“平均差了多少个原始单位”，解释最直观。若房价单位是万元，MAE=12 就表示平均误差约 12 万元。由于使用绝对值，MAE 对离群点没有 MSE 那么敏感。</p>
<div class="blog_h4"><span class="graybg">MSE</span></div>
<p>均方误差（Mean Squared Error, MSE）定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{MSE}=\frac{1}{N}\sum_{i=1}^{N}(\hat y_i-y_i)^2\]</span>
<p>平方会放大大误差，因此 MSE 对离群点更敏感。它常用于你希望“大错要被重罚”的场景。高斯噪声假设下，最小化 MSE 还对应最大似然估计，因此它不仅是工程指标，也是概率建模结果。</p>
<div class="blog_h4"><span class="graybg">RMSE</span></div>
<p>均方根误差（Root Mean Squared Error, RMSE）是</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{RMSE}=\sqrt{\mathrm{MSE}}\]</span>
<p>它保留了 MSE 对大误差更敏感的性质，同时把单位拉回原始量纲，因此更易解释。若房价 RMSE 为 20 万元，可以直接理解为“典型误差量级约 20 万元”。</p>
<div class="blog_h4"><span class="graybg">R²</span></div>
<p><span displaypfx="" class="mathjax-container">\[R^2\]</span>（决定系数，Coefficient of Determination）回答的是：<span style="background-color: #c0c0c0;">相比于最朴素的瞎猜基线，你的回归模型到底把预测提升了多少</span>。要理解它，只需要在脑子里放两条线：一条是“什么都不知道时只能猜平均值”的水平线，另一条是模型给出的预测曲线。</p>
<p>先看最朴素的基线。假设你要预测一批房子的价格，但你手里没有面积、地段、楼龄这些特征，别人却逼着你给出预测。此时最不容易挨打的办法，不是胡乱报一个数字，而是对所有房子都猜样本平均价 <span displaypfx="inline-" class="mathjax-container">\(\bar y\)</span>。在散点图上，这对应一条横向的水平线。</p>
<p>真实房价 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 会散落在这条平均线的上下。每个点到平均线的垂直距离 <span displaypfx="inline-" class="mathjax-container">\(y_i-\bar y\)</span>，就是“瞎蒙平均值”时犯下的误差。把这些误差平方后全部加起来，就得到</p>
<span displaypfx="" class="mathjax-container">\[\sum_i(y_i-\bar y)^2\]</span>
<p>这就是公式里的分母。它衡量的不是模型误差，而是这批数据本身原来就有多分散、多混乱。也可以把它理解为目标变量的<span style="background-color: #c0c0c0;">总波动</span>、总混沌程度，或者说“在完全不用特征时，世界原本有多少东西解释不了”。</p>
<p>现在再看你的模型。你训练出一个回归模型，它根据输入特征给出预测 <span displaypfx="inline-" class="mathjax-container">\(\hat y_i\)</span>。在图上，这不再是那条死板的水平线，而是一条试图穿过散点云中心的预测曲线。模型当然不可能完美，所以每个真实值 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 与预测值 <span displaypfx="inline-" class="mathjax-container">\(\hat y_i\)</span> 之间仍会有垂直误差，这个误差就是残差（Residual）。</p>
<p>把这些模型仍然没解释掉的误差平方后加起来，就得到</p>
<span displaypfx="" class="mathjax-container">\[\sum_i(\hat y_i-y_i)^2\]</span>
<p>这就是公式里的分子，也叫残差平方和（Residual Sum of Squares, RSS）。它代表模型已经尽力之后，世界上<span style="background-color: #c0c0c0;">依然残存的混沌</span>。分子越小，说明模型越贴近真实数据；分子越大，说明模型虽然复杂，但其实没把问题解释清楚。</p>
<p>于是</p>
<span displaypfx="" class="mathjax-container">\[R^2=1-\frac{\sum_i(\hat y_i-y_i)^2}{\sum_i(y_i-\bar y)^2}\]</span>
<p>这条式子就可以直接读成一句大白话：先看模型还剩下多少解释不了的波动，再除以最开始总共有多少波动，得到“模型搞不定的比例”；最后用 1 减掉它，剩下的就是<span style="background-color: #c0c0c0;">模型成功解释掉的波动比例</span>。</p>
<p>因此，若 <span displaypfx="inline-" class="mathjax-container">\(R^2=0.8\)</span>，意思不是“正确率 80%”，而是目标变量原本有 100 份波动，模型大约解释掉了其中 80 份，只剩 20 份还没解释；若 <span displaypfx="inline-" class="mathjax-container">\(R^2=0\)</span>，说明你的模型折腾半天，效果和“永远预测平均值”完全一样；若 <span displaypfx="inline-" class="mathjax-container">\(R^2&lt;0\)</span>，则表示模型比这个最朴素基线还差，常见原因是模型设错了、特征没信息，或实现上有 bug。</p>
<p>从老板视角看， <span displaypfx="inline-" class="mathjax-container">\(R^2\)</span> 的灵魂拷问其实只有一句：<span style="background-color: #c0c0c0;">相比直接拿平均值糊弄事，你这个复杂回归模型到底多解释了多少真实波动</span>。这也是为什么 <span displaypfx="inline-" class="mathjax-container">\(R^2\)</span> 特别适合回答“模型有没有真正利用特征学到东西”，但它不能替代 MAE、RMSE——因为 <span displaypfx="inline-" class="mathjax-container">\(R^2\)</span> 讲的是解释比例，而不是误差到底有多少个原始单位。</p>
<div class="blog_h2"><span class="graybg">优化算法</span></div>
<p>优化算法（Optimization Algorithms）解决的问题非常朴素：<span style="background-color: #c0c0c0;">模型参数该往哪个方向改，才能让损失函数持续下降</span>。只要训练目标能写成“最小化某个损失函数”，背后就需要一套更新参数的规则。线性回归、逻辑回归、神经网络、大语言模型训练，本质上都绕不开这个问题。</p>
<p>这里要先分清两件事。优化（Optimization）关心的是“训练损失能不能降下来”；泛化（Generalization）关心的是“模型在新样本上好不好”。一个优化器可能把训练集拟合得很好，但泛化依然一般；反过来，一个优化器如果连训练损失都压不下去，模型通常也谈不上有效。因此优化算法决定的是<span style="background-color: #c0c0c0;">你如何走向一个解</span>，而不是直接保证这个解一定最好。</p>
<div class="blog_h3"><span class="graybg">梯度下降</span></div>
<p>梯度下降（Gradient Descent）是最核心的一阶优化思想。设损失函数为 <span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span>，参数为 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>，则梯度 <span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta L(\theta)\)</span> 给出“损失上升最快的方向”。既然梯度指向上坡，那么要让损失下降，就应沿着它的反方向更新参数：</p>
<span displaypfx="" class="mathjax-container">\[\theta_{t+1}=\theta_t-\eta\,\nabla_\theta L(\theta_t)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\theta_t\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步的参数， <span displaypfx="inline-" class="mathjax-container">\(\eta\)</span> 是学习率（Learning Rate），决定每一步走多大； <span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta L(\theta_t)\)</span> 是当前位置的斜率信息。学习率太大，容易一步跨过谷底甚至震荡发散；学习率太小，又会下降得极慢。它像蒙着雾下山：梯度告诉你脚下哪边更陡，学习率决定你每次迈多大步。</p>
<p>为什么很多模型不直接“解公式”，而要反复迭代？因为在深度学习里，参数维度极高，损失面又往往非凸（Non-convex），通常没有漂亮的闭式解（Closed-form Solution）。这时最现实的办法不是一次算出全局最优，而是利用局部斜率，一步一步把损失往下压。</p>
<p>当总损失是逐样本损失的平均时，梯度下降还可以写得更具体：</p>
<span displaypfx="" class="mathjax-container">\[L(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell(\theta;x_i)\]</span>
<span displaypfx="" class="mathjax-container">\[\nabla_\theta L(\theta)=\frac{1}{N}\sum_{i=1}^{N}\nabla_\theta \ell(\theta;x_i)\]</span>
<p>这也回答了“为什么可以批量喂输入”：梯度对求和是线性的，<span style="background-color: #c0c0c0;">整体梯度等于逐样本梯度的平均</span>，所以可以并行算每个样本的梯度，再求平均后统一更新参数。</p>
<p>满足这种“逐样本求和/求平均”结构的损失函数其实非常多。典型例子包括：线性回归里的均方误差（MSE）、平均绝对误差（MAE），二分类里的 logistic loss / binary cross-entropy，多分类里的交叉熵（Cross-Entropy），语言模型训练里的负对数似然（Negative Log-Likelihood, NLL）。它们都可以写成“每个样本先各自算一份损失，再在整个数据集上取平均”的形式，因此天然适合 mini-batch 训练。</p>
<p>但并不是所有目标都能严格写成这种完全独立的逐样本平均。若损失显式依赖<span style="background-color: #c0c0c0;">样本之间的相对关系</span>，情况就会复杂一些。例如排序学习里的 pairwise/listwise loss、度量学习中的 triplet loss，以及对比学习里的 InfoNCE，都会让一个样本的损失依赖同一个 batch 中的其他样本。此时虽然仍然可以按 batch 计算梯度，但它已经不是“每个样本各算各的、最后简单平均”那么干净的分解了。</p>
<p>工程上还常见另一种情况：目标函数可以写成“逐样本平均损失 + 正则项（Regularization）”。例如</p>
<span displaypfx="" class="mathjax-container">\[L(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell(\theta;x_i)+\lambda\Omega(\theta)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\Omega(\theta)\)</span> 可以是 <span displaypfx="inline-" class="mathjax-container">\(\|\theta\|_2^2\)</span> 这类参数惩罚项。前半部分仍然按样本分解，后半部分则是直接作用于参数本身，而不对应某一个单独样本。很多现代训练目标，本质上都是这两部分的组合。</p>
<p>几个常用训练量需要先分清：</p>
<ul>
<li>批大小（Batch Size）：一次更新使用多少个样本。</li>
<li>步（Step / Iteration）：一次参数更新。</li>
<li>轮（Epoch）：完整遍历一次训练集。</li>
</ul>
<p>若训练集大小为 <span displaypfx="inline-" class="mathjax-container">\(N\)</span>，batch size 为 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>，则每个 epoch 的步数约为 <span displaypfx="inline-" class="mathjax-container">\(\lceil N/B\rceil\)</span>。工程里经常会说“训练了多少 step”，因为真正发生参数变化的是 step，而不是 epoch 这个更粗的计数单位。</p>
<div class="blog_h4"><span class="graybg">批量梯度下降（BGD）</span></div>
<p>批量梯度下降（Batch Gradient Descent, BGD）每次都用<span style="background-color: #c0c0c0;">整个训练集</span>来计算一次精确梯度，再更新参数。这里的 batch 指的不是现代深度学习里常说的一个 mini-batch，而是“整批训练数据”。它的优点是方向最稳定、梯度方差最小；缺点是每一步都很贵，数据一大就几乎不可用。它更适合小数据集、凸优化问题，或教材里说明“梯度下降原理”时使用。</p>
<div class="blog_h4"><span class="graybg">随机梯度下降（SGD）</span></div>
<p>随机梯度下降（Stochastic Gradient Descent, SGD）每次只用一个样本的梯度做更新。它的方向噪声很大，看起来像“跌跌撞撞地下山”，但每一步极便宜、更新极频繁，因此在数据流式到来或样本极多时很有价值。</p>
<ul>
<li>优点：更新快、内存开销小、天然适合在线学习（Online Learning）与流式数据（Streaming Data）。</li>
<li>优点：梯度噪声有时反而是好事，更容易离开鞍点（Saddle Point）和较差的局部极小。</li>
<li>缺点：轨迹抖动大，若学习率控制不好，训练容易不稳定。</li>
</ul>
<p>它像店长根据每一位新顾客的反馈立刻调价：反应很快，但也容易被个别顾客带偏。</p>
<p>适用场景：当你需要<span style="background-color: #c0c0c0;">用最新样本尽快产生参数更新</span>时，SGD（batch size=1）是最直接的选择，典型包括：</p>
<ul>
<li>在线学习（Online Learning）/流式数据（Streaming Data）：样本持续到来，要求增量更新，而不是离线反复扫全量数据。</li>
<li>分布漂移（Distribution Shift）与非平稳（Non-stationary）环境：用户偏好、市场、策略对抗等持续变化，需要快速跟踪新分布。</li>
<li>低延迟更新需求：例如广告/推荐/风控的在线校准，需要“见到一条新反馈就更新一点”。</li>
<li>资源受限或样本极大：内存无法容纳大 batch 或无法频繁计算全量梯度时，用单样本更新换取更低的每步计算与存储成本。</li>
</ul>
<p>在现代深度学习里，若使用 GPU/TPU 训练，大多数时候会用小批量梯度下降来兼顾吞吐与稳定性；纯 SGD 更常出现在在线/增量训练与部分强化学习（Reinforcement Learning, RL）设置中。</p>
<div class="blog_h4"><span class="graybg">小批量梯度下降（Mini-batch SGD）</span></div>
<p>小批量梯度下降（Mini-batch SGD）是在 BGD 与 SGD 之间折中：每次用一个 batch 的平均梯度更新。它既能利用 GPU 的并行算力，又保留一定梯度噪声，因此现代深度学习几乎都在这个范式下训练。</p>
<p>batch 太小，梯度估计噪声会很大；batch 太大，虽然每步更稳定，但显存压力更高、更新频率更低，有时还会让优化和泛化都变钝。因此 batch size 不是“越大越好”，而是吞吐、稳定性、显存和泛化之间的折中。</p>
<div class="blog_h3"><span class="graybg">动量法（Momentum）</span></div>
<p>单纯的梯度下降在“峡谷形”损失面里很容易左右来回震荡：沿陡峭方向上下摆动，沿真正有用的谷底方向前进却很慢。动量法（Momentum）的直觉是：不要只看当前这一脚的斜率，而要把过去几步的方向累积成一种“惯性”。</p>
<span displaypfx="" class="mathjax-container">\[v_{t+1}=\beta v_t+(1-\beta)g_t,\quad \theta_{t+1}=\theta_t-\eta v_{t+1}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 是当前梯度， <span displaypfx="inline-" class="mathjax-container">\(v_t\)</span> 是累计出来的速度， <span displaypfx="inline-" class="mathjax-container">\(\beta\in[0,1)\)</span> 控制“记住过去多少信息”。 <span displaypfx="inline-" class="mathjax-container">\(\beta\)</span> 越大，方向越平滑、惯性越强；越小，则越接近普通梯度下降。类比来看，它像推一个有重量的小球下山：不会因为脚下的微小凸凹就频繁改道，而会沿长期更一致的下降方向滚动。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/momentum.jpg"><img class="alignnone size-full wp-image-41017" src="https://blog.gmem.cc/wp-content/uploads/2026/03/momentum.jpg" alt="momentum" width="100%" /></a></p>
<div class="blog_h3"><span class="graybg">AdaGrad / RMSProp</span></div>
<p>AdaGrad 与 RMSProp 属于自适应学习率（Adaptive Learning Rate）方法。它们的核心思想是：<span style="background-color: #c0c0c0;">不同参数的梯度尺度不同，不应该所有维度都用同一个固定步长</span>。历史上梯度很大的维度，后续步子要缩小；梯度稀疏或很小的维度，则可以走得更积极。</p>
<p>AdaGrad 累积历史平方梯度：</p>
<span displaypfx="" class="mathjax-container">\[s_{t+1}=s_t+g_t\odot g_t,\quad \theta_{t+1}=\theta_t-\eta\,\frac{g_t}{\sqrt{s_{t+1}}+\epsilon}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(g_t\odot g_t\)</span> 表示逐元素平方， <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 是数值稳定项。这样一来，历史上梯度一直很大的维度会被自动缩小学习率。AdaGrad 在稀疏特征（Sparse Features）任务里很有效，例如早期的文本与推荐场景；但它的问题是 <span displaypfx="inline-" class="mathjax-container">\(s_t\)</span> 只增不减，训练到后期学习率可能衰减得过头，参数几乎不再动。</p>
<p>RMSProp 用指数滑动平均替代“无限累积”，缓解这个问题：</p>
<span displaypfx="" class="mathjax-container">\[s_{t+1}=\rho s_t+(1-\rho)(g_t\odot g_t),\quad \theta_{t+1}=\theta_t-\eta\,\frac{g_t}{\sqrt{s_{t+1}}+\epsilon}\]</span>
<p>这样历史信息会逐步“遗忘”，使学习率缩放更关注近期梯度尺度。可以把 AdaGrad 理解成“终身记账”，而 RMSProp 更像“滚动记账”。</p>
<div class="blog_h3"><span class="graybg">Adam</span></div>
<p>Adam（Adaptive Moment Estimation）把动量法（Momentum）的一阶矩估计与 RMSProp 的二阶矩估计结合起来，因此它同时解决优化中的两个核心痛点：<span style="background-color: #c0c0c0;">方向感（往哪走）</span>与<span style="background-color: #c0c0c0;">节奏感（每步走多大）</span>。直觉上，可以把它理解为“带惯性的方向盘 + 自动变速箱”：方向由平均梯度决定，步长由梯度的尺度自动缩放。</p>
<div class="blog_h4"><span class="graybg">一阶矩：方向感（Momentum）</span></div>
<span displaypfx="" class="mathjax-container">\[m_{t+1}=\beta_1 m_t+(1-\beta_1)g_t\]</span>
<ul>
<li><span style="background-color: #c0c0c0;">索引约定</span>：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 次更新先在 <span displaypfx="inline-" class="mathjax-container">\(\theta_t\)</span> 处计算 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span>，再把它并入动量得到 <span displaypfx="inline-" class="mathjax-container">\(m_{t+1}\)</span>；下标 <span displaypfx="inline-" class="mathjax-container">\(t+1\)</span> 表示“更新后状态”，不是未来信息。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(m_{t+1}\)</span>：并入 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 后的一阶矩（动量）估计。</li>
<li>右边第一项 <span displaypfx="inline-" class="mathjax-container">\(\beta_1 m_t\)</span>：把历史动量按系数 <span displaypfx="inline-" class="mathjax-container">\(\beta_1\)</span> 保留下来，表示“历史方向对新方向的贡献”。</li>
<li>右边第二项 <span displaypfx="inline-" class="mathjax-container">\((1-\beta_1)g_t\)</span>：把当前梯度按系数 <span displaypfx="inline-" class="mathjax-container">\(1-\beta_1\)</span> 注入动量，表示“当前观测对新方向的贡献”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(g_t\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步的梯度，通常由当前 mini-batch 估计得到（<span displaypfx="inline-" class="mathjax-container">\(g_t=\nabla_\theta L(\theta_t)\)</span>）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(m_t\)</span>：一阶矩估计的滑动平均（动量项），可理解为“平均梯度方向”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\beta_1\in[0,1)\)</span>：动量衰减系数，越大表示记忆越长、方向越平滑。</li>
</ul>
<p>两项系数满足 <span displaypfx="inline-" class="mathjax-container">\(\beta_1+(1-\beta_1)=1\)</span>，因此这是指数滑动平均（Exponential Moving Average, EMA，越近发生的事情，参考价值越大；越久远的事情，参考价值越小）。当 <span displaypfx="inline-" class="mathjax-container">\(m_0=0\)</span> 时，可把它展开为：</p>
<span displaypfx="" class="mathjax-container">\[m_{t+1}=(1-\beta_1)\sum_{k=0}^{t}\beta_1^{t-k}g_k\]</span>
<p>上式说明：越久远的梯度 <span displaypfx="inline-" class="mathjax-container">\(g_k\)</span> 权重按 <span displaypfx="inline-" class="mathjax-container">\(\beta_1^{t-k}\)</span> 指数衰减；经验上可把有效记忆长度理解为 <span displaypfx="inline-" class="mathjax-container">\(O\!\left(\frac{1}{1-\beta_1}\right)\)</span>。因此 <span displaypfx="inline-" class="mathjax-container">\(\beta_1\)</span> 越大，平均窗口越长，方向越平滑但响应越慢；<span displaypfx="inline-" class="mathjax-container">\(\beta_1\)</span> 越小，平均窗口越短，方向更敏捷但更易受噪声影响。</p>
<div class="blog_h4"><span class="graybg">二阶矩：节奏感（RMSProp）</span></div>
<p>RMSProp（Root Mean Square Propagation）用梯度平方的指数滑动平均（Exponential Moving Average, EMA）来估计每个参数维度的“尺度”，再用该尺度对当前梯度做逐元素归一化，从而把更新步伐控制在更稳定的量级。</p>
<span displaypfx="" class="mathjax-container">\[v_{t+1}=\beta_2 v_t+(1-\beta_2)(g_t\odot g_t),\quad \tilde g_t=\frac{g_t}{\sqrt{v_{t+1}}+\epsilon}\]</span>
<ul>
<li><span style="background-color: #c0c0c0;">索引约定</span>：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 次更新先在 <span displaypfx="inline-" class="mathjax-container">\(\theta_t\)</span> 处计算 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span>，再用它更新得到 <span displaypfx="inline-" class="mathjax-container">\(v_{t+1}\)</span>；用 <span displaypfx="inline-" class="mathjax-container">\(v_{t+1}\)</span> 缩放 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 表示“用本次更新后的尺度估计做归一化”，不涉及未来信息。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(v_t\)</span>：上一轮更新结束后的二阶原点矩估计（EMA 状态，“更新前的尺度缓存”）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(v_{t+1}\)</span>：梯度的二阶原点矩（Second Raw Moment）估计，逐（梯度）维近似 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g^2]\)</span>，刻画“历史梯度大小”的典型尺度。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(g_t\odot g_t\)</span>：当前梯度的逐元素平方（<span displaypfx="inline-" class="mathjax-container">\(\odot\)</span> 为 Hadamard 乘积），只保留幅度信息。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sqrt{v_{t+1}}\)</span>：均方根（Root Mean Square, RMS）尺度；平方使量纲变为 <span displaypfx="inline-" class="mathjax-container">\(g^2\)</span>，开方把量纲还原到 <span displaypfx="inline-" class="mathjax-container">\(g\)</span>，从而可与 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 相除。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\tilde g_t\)</span>：按 RMS 尺度归一化后的梯度；分式表示逐元素相除（每个参数维度各自缩放）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\beta_2\in[0,1)\)</span>：衰减系数，控制尺度估计的记忆长度；越大表示对历史尺度更“长记忆”。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span>：数值稳定项，避免分母为 0 或过小。</li>
</ul>
<p>该缩放把“方向”和“尺度”解耦：方向仍由 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 给出；步长由 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{v_{t+1}}\)</span> 自适应调节。若某维长期梯度偏大，则 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{v_{t+1}}\)</span> 变大、该维更新被压小；若某维长期梯度偏小，则分母较小、该维相对步子更大。</p>
<p>若把 RMSProp 作为独立优化器使用，参数更新可写为 <span displaypfx="inline-" class="mathjax-container">\(\theta_{t+1}=\theta_t-\eta\,\tilde g_t\)</span>。在 Adam 中，同样的 RMS 缩放作用在一阶矩估计上：用 <span displaypfx="inline-" class="mathjax-container">\(\hat m_{t+1}\)</span> 替代 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 作为分子，并在下一节通过偏置修正得到 <span displaypfx="inline-" class="mathjax-container">\(\hat v_{t+1}\)</span> 作为分母。</p>
<div class="blog_h4"><span class="graybg">偏置修正：冷启动校正（Bias Correction）</span></div>
<p>因为 <span displaypfx="inline-" class="mathjax-container">\(m_0=v_0=0\)</span>，训练初期的滑动平均会系统性偏小（“没热起来”）。Adam 用偏置修正把它拉回合理尺度：</p>
<span displaypfx="" class="mathjax-container">\[\hat m_{t+1}=\frac{m_{t+1}}{1-\beta_1^{t+1}},\quad \hat v_{t+1}=\frac{v_{t+1}}{1-\beta_2^{t+1}}\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\hat m_{t+1},\hat v_{t+1}\)</span>：偏置修正后的估计，用来抵消初始化为 0 导致的早期偏小。</li>
<li>分母 <span displaypfx="inline-" class="mathjax-container">\(1-\beta^{t+1}\)</span>：校正“冷启动偏小”的缩放因子。由于初始化为 0，指数滑动平均在经历 <span displaypfx="inline-" class="mathjax-container">\(t+1\)</span> 次更新时只积累了约 <span displaypfx="inline-" class="mathjax-container">\(1-\beta^{t+1}\)</span> 的有效权重，因此会偏小；除以它相当于把估计值按同样比例放大回去。</li>
</ul>
<p>更严格地说：若假设 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g_t]=\mu\)</span> 近似稳定，则由递推可得 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[m_{t+1}]=(1-\beta_1^{t+1})\mu\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(\hat m_{t+1}=\frac{m_{t+1}}{1-\beta_1^{t+1}}\)</span> 是把它改成近似无偏估计。同理可得 <span displaypfx="inline-" class="mathjax-container">\(\hat v_{t+1}\)</span> 的修正。</p>
<div class="blog_h4"><span class="graybg">参数更新：方向 ÷ 尺度</span></div>
<span displaypfx="" class="mathjax-container">\[\theta_{t+1}=\theta_t-\eta\,\frac{\hat m_{t+1}}{\sqrt{\hat v_{t+1}}+\epsilon}\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\theta_t\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步参数（权重向量/张量）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\eta\)</span>：学习率（全局步长系数）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sqrt{\hat v_{t+1}}\)</span>：逐元素开方；整项 <span displaypfx="inline-" class="mathjax-container">\(\frac{\hat m_{t+1}}{\sqrt{\hat v_{t+1}}+\epsilon}\)</span> 表示逐元素相除。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span>：数值稳定项，避免除以 0 或极小数。</li>
</ul>
<p>分子给方向，分母给节奏。一个极端例子能看出为什么 Adam 用 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g^2]\)</span> 而不是方差：若某维梯度连续很多步都是 <span displaypfx="inline-" class="mathjax-container">\(g_t=10\)</span>，方差为 0 会导致除法不稳定；但 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[g^2]\approx 100\)</span> 给出稳定尺度，最终更新量级约为 <span displaypfx="inline-" class="mathjax-container">\(10/\sqrt{100}=1\)</span>（再加 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 保底）。</p>
<p>Adam 往往更易调参、早期收敛更快，因此在 Transformer、扩散模型和很多深度网络里是默认起点。但它并不是无条件最好：有些任务上，最终泛化仍可能不如精心调过的 SGD。实践中，配合权重衰减（Weight Decay）时，通常会使用 AdamW，把权重衰减从梯度自适应缩放里解耦出来，效果更稳。</p>
<div class="blog_h3"><span class="graybg">AdamW</span></div>
<p>AdamW（Adam with Decoupled Weight Decay）把权重衰减（Weight Decay）从 Adam 的自适应梯度缩放里<span style="background-color: #c0c0c0;">解耦</span>出来。原因是：在 Adam 这类自适应方法里，如果你把 L2 正则化写进损失（等价于把 <span displaypfx="inline-" class="mathjax-container">\(\lambda\theta\)</span> 加到梯度里），这个正则项也会被二阶矩 <span displaypfx="inline-" class="mathjax-container">\(\hat v_t\)</span> 的缩放影响，从而导致不同参数维度的“衰减强度”不再可控。</p>
<p>正则化本来应该是一个可控的、与梯度尺度无关的收缩力度；解耦后 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 的含义更稳定。</p>
<p>对比两种写法会更清楚：</p>
<ul>
<li>把 L2 正则化并入目标（常被口语化地称为“weight decay”，但在自适应优化器里并不等价）：先算 <span displaypfx="inline-" class="mathjax-container">\(g_t=\nabla_\theta L(\theta_t)+\lambda\theta_t\)</span>，再把这个 <span displaypfx="inline-" class="mathjax-container">\(g_t\)</span> 送入 Adam 的 <span displaypfx="inline-" class="mathjax-container">\(m_t,v_t\)</span> 与自适应步长。</li>
<li>AdamW（解耦权重衰减）：先算纯数据梯度 <span displaypfx="inline-" class="mathjax-container">\(g_t=\nabla_\theta L(\theta_t)\)</span> 并完成 Adam 的自适应更新，然后单独对参数做一次权重衰减。</li>
</ul>
<p>AdamW 的参数更新可写成：</p>
<span displaypfx="" class="mathjax-container">\[\theta_{t+1}=\theta_t-\eta\,\frac{\hat m_{t+1}}{\sqrt{\hat v_{t+1}}+\epsilon}-\eta\,\lambda\,\theta_t\]</span>
<ul>
<li>前半段 <span displaypfx="inline-" class="mathjax-container">\(-\eta\,\frac{\hat m}{\sqrt{\hat v}+\epsilon}\)</span>：Adam 的自适应梯度更新（由数据损失驱动）。</li>
<li>后半段 <span displaypfx="inline-" class="mathjax-container">\(-\eta\,\lambda\,\theta_t\)</span>：解耦的 weight decay（参数按比例收缩），其中 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 是衰减强度。</li>
</ul>
<p>其中第一项是 Adam 的自适应更新，第二项是独立的权重衰减（等价于每步把权重按比例拉向 0）。这种分离让 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 更像一个真正可解释的“收缩强度”，在 Transformer 等模型上通常更稳定、更好调。工程实现里常见做法是：对 bias 与归一化层参数（如 LayerNorm 的 <span displaypfx="inline-" class="mathjax-container">\(\gamma,\beta\)</span>）不做 weight decay，以免对尺度/偏置项造成不必要的收缩。</p>
<div class="blog_h3"><span class="graybg">Muon</span></div>
<p>Muon 是一种面向神经网络隐藏层（Hidden Layer）权重矩阵（Weight Matrix）的优化器。它与 Adam/AdamW 的根本差异在于：AdamW 会为每个参数维度单独估计更新尺度，并对梯度/更新量做逐元素（Element-wise）归一化；Muon 则把一个二维权重张量视为一个整体，直接利用矩阵结构来塑造更新方向。因此，Muon 更像一种<span style="background-color: #c0c0c0;">矩阵感知（Matrix-aware）的优化器</span>。</p>
<p>它的核心做法不是改变“沿梯度下降”这个大方向，而是改变“更新张量应该具有什么几何形状”。典型写法可以概括为：先对梯度做动量（Momentum）累积，再把这个矩阵更新做一次正交化（Orthogonalization）后处理，然后才真正更新参数：</p>
<span displaypfx="" class="mathjax-container">\[M_{t+1}=\beta M_t+(1-\beta)G_t\]</span>
<span displaypfx="" class="mathjax-container">\[\Delta W_t=\mathrm{Orth}(M_{t+1}),\quad W_{t+1}=W_t-\eta\,\Delta W_t\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(G_t\)</span> 是当前梯度矩阵， <span displaypfx="inline-" class="mathjax-container">\(M_t\)</span> 是动量缓冲， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Orth}(\cdot)\)</span> 表示把更新矩阵变换到更“接近正交（Orthogonal）”的形状。工程实现里，这一步通常用 Newton-Schulz 迭代（Newton-Schulz Iteration）高效近似完成，因此 Muon 常被概括为“对动量更新做正交化”。</p>
<ul>
<li>优势：对线性层/MLP/注意力里的二维权重矩阵，Muon 往往能给出更结构化的更新方向；在一些大模型训练设定下，它的训练效率与收敛速度优于 AdamW。</li>
<li>边界：Muon 不是全参数通吃的默认方案。它通常只用于隐藏层的二维参数；embedding、bias、归一化参数、输出层等非二维或语义不同的参数，实践中常继续交给 AdamW。</li>
<li>工程特征：它强调更新矩阵的谱结构（Spectral Structure），而不是单个坐标的独立缩放；因此超参数分组与参数类型划分比 AdamW 更重要。</li>
</ul>
<p>可以把 AdamW 与 Muon 的差别记成一句话：<span style="background-color: #c0c0c0;">AdamW 解决“每个参数该走多大步”，Muon 进一步关心“整个矩阵应该以什么形状移动”</span>。因此 Muon 更像是隐藏层矩阵优化的专用工具，而不是对所有参数一视同仁的通用默认项。</p>
<div class="blog_h3"><span class="graybg">学习率调度</span></div>
<p>学习率（Learning Rate）往往是最敏感的超参数之一。即使优化器相同，只要学习率设错，训练就可能完全失败。学习率调度（Learning Rate Scheduling）/退火（Annealing）的核心思想是：前期用相对大的步长快速下降，后期逐渐减小步长做精细收敛。</p>
<p>常见调度方式包括：</p>
<ul>
<li>Step decay：按 epoch 或 step 乘以固定因子衰减，简单直接。</li>
<li>Linear decay：线性下降到较小值或 0，常用于大模型训练后段。</li>
<li>Warmup + decay：先小步热身，再进入正常学习率，最后逐步衰减；对 Transformer 和大 batch 训练尤其常见。</li>
<li>余弦退火（Cosine Annealing）：将学习率从较大值逐步衰减到较小值，把优化从“高温探索”过渡到“低温收敛”，在训练后期降低梯度噪声（gradient noise）与过冲（overshoot）风险，使参数能在极小值附近稳定细化。余弦曲线在起点与终点的一阶导数为 0，避免阶梯式衰减的突变，因而衰减更平滑、后期更柔和。</li>
</ul>
<p>Warmup 为什么有用？因为训练刚开始时，参数还处在一个非常“生”的区域，梯度统计不稳定，若一上来就用很大学习率，模型容易发散。先用较小步长热身几百或几千步，再拉到目标学习率，通常更稳。</p>
<p>余弦退火的典型写法（从 <span displaypfx="inline-" class="mathjax-container">\(\eta_{\max}\)</span> 衰减到 <span displaypfx="inline-" class="mathjax-container">\(\eta_{\min}\)</span>）为：</p>
<span displaypfx="" class="mathjax-container">\[\eta(t)=\eta_{\min}+\frac{1}{2}(\eta_{\max}-\eta_{\min})\left(1+\cos\left(\pi\frac{t}{T}\right)\right)\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\eta(t)\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步使用的学习率。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\eta_{\max}\)</span> / <span displaypfx="inline-" class="mathjax-container">\(\eta_{\min}\)</span>：一个退火周期内的最大学习率与最小学习率。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(t\)</span>：当前步数（通常从 0 开始计），<span displaypfx="inline-" class="mathjax-container">\(T\)</span>：该周期的总步数。</li>
</ul>
<p>这个公式可以直接做两次代入来验证边界：当 <span displaypfx="inline-" class="mathjax-container">\(t=0\)</span> 时，<span displaypfx="inline-" class="mathjax-container">\(\cos(0)=1\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(\eta(0)=\eta_{\max}\)</span>；当 <span displaypfx="inline-" class="mathjax-container">\(t=T\)</span> 时，<span displaypfx="inline-" class="mathjax-container">\(\cos(\pi)=-1\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(\eta(T)=\eta_{\min}\)</span>。中间学习率按半个余弦周期平滑下降。</p>
<p>通过余弦（Cosine）提供了一个非常干净的<span style="background-color: #c0c0c0;">端点平滑（smooth endpoints）</span>性质：在起点和终点处变化率为 0。对上式求导可得</p>
<span displaypfx="" class="mathjax-container">\[\frac{d\eta}{dt}=-\frac{1}{2}(\eta_{\max}-\eta_{\min})\frac{\pi}{T}\sin\left(\pi\frac{t}{T}\right)\]</span>
<p>因此 <span displaypfx="inline-" class="mathjax-container">\(\sin(0)=\sin(\pi)=0\)</span>，学习率在周期开始与结束都不会出现突兀的“拐点”。相比之下，step decay 有不连续跳变，linear decay 在末端通常会突然到达下限并停止变化（出现不光滑的折点）。在非凸深度网络里，这种平滑退火往往更稳：前期保持较大步长便于探索，后期自然减小步长便于精细收敛。</p>
<p>工程上常见扩展是余弦重启（Cosine Annealing with Warm Restarts, SGDR）：把训练过程切成多个周期，每个周期把 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 重新从 0 开始计（并可逐周期拉长 <span displaypfx="inline-" class="mathjax-container">\(T\)</span>）。在标准 SGDR 中，<span style="background-color: #c0c0c0;">学习率在周期边界是不连续的</span>：它会在一个周期末端衰减到 <span displaypfx="inline-" class="mathjax-container">\(\eta_{\min}\)</span>，并在下一个周期起点从 <span displaypfx="inline-" class="mathjax-container">\(\eta_{\max}\)</span> 重新开始（参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 不会重置）。</p>
<p>这种“重启”的作用不是为了制造噪声，而是把优化过程从“后期小步精修”短暂切回“较大步长探索”，以应对非凸问题中的平台区（plateau）与次优盆地（suboptimal basin）。学习率拉高会增大每步更新幅度与梯度噪声（gradient noise）的有效影响，帮助轨迹跳出当前区域并探索新的吸引域；而如果当前区域确实是更稳健的平坦极小值（flat minimal），后续退火通常会把参数再次拉回并在附近更精细地收敛。工程上常见做法是把 <span displaypfx="inline-" class="mathjax-container">\(\eta_{\max}\)</span> 设在“不会破坏稳定性”的范围内；若重启瞬间仍担心不稳定，也可以在每次重启后加一个很短的 warmup，让学习率在少量步数内从较小值爬升到 <span displaypfx="inline-" class="mathjax-container">\(\eta_{\max}\)</span>。</p>
<p>没有一种调度策略对所有任务都最好。真正有效的做法是结合损失曲线、梯度稳定性、验证集表现和训练预算一起看：如果前期降不动，往往学习率偏小；如果剧烈震荡甚至发散，往往学习率偏大；如果后期长期卡在平台区，通常需要更细的衰减策略。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/sgdr.jpg"><img class="alignnone size-full wp-image-41059" src="https://blog.gmem.cc/wp-content/uploads/2026/03/sgdr.jpg" alt="sgdr" width="100%" /></a></p>
<div class="blog_h2"><span class="graybg">集成策略（Ensemble Learning）</span></div>
<p>集成学习（Ensemble Learning）的核心思想是：<span style="background-color: #c0c0c0;">不要只信一个模型，而是让多个模型共同投票或共同修正</span>。它背后的统计直觉是：不同模型的误差如果不完全一致，组合后往往比单个模型更稳。类比来看，这像让多个医生会诊：一个人可能看偏，但几个人的综合意见通常更可靠。</p>
<div class="blog_h3"><span class="graybg">Bagging</span></div>
<p>Bagging（Bootstrap Aggregating）的核心做法是对训练集进行多次自助采样（bootstrap sampling），即反复执行有放回采样（sampling with replacement）生成多个训练子集，并在这些子集上分别训练基模型，以降低方差（Variance）。由于每个模型见到的数据子集略有差异，学到的决策边界不会完全一致；最后对预测结果做平均或多数投票，可抵消一部分过拟合噪声。</p>
<p>例：如果单棵决策树很容易被训练集中的偶然样本带偏，那么训练 100 棵在不同 bootstrap 样本上的树，再投票，通常会比只用 1 棵树稳定得多。这也是随机森林的核心直觉。</p>
<div class="blog_h3"><span class="graybg">Boosting</span></div>
<p>Boosting 的思路与 Bagging 相反：它不是“并行训练很多彼此独立的模型”，而是“串行训练一串弱学习器（Weak Learners），让后一个模型专门修正前一个模型没做好的部分”。因此它更像“老师批改作业”：每一轮都盯着上轮最容易出错的题继续强化。</p>
<p>以加法模型视角看，Boosting 逐步构造</p>
<span displaypfx="" class="mathjax-container">\[F_M(x)=\sum_{m=1}^{M}\alpha_m h_m(x)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(h_m(x)\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 个弱学习器， <span displaypfx="inline-" class="mathjax-container">\(\alpha_m\)</span> 是它的权重。公式不是为了堆符号，而是在表达：最终模型是“很多个简单模型的加权和”，每一轮都往当前模型上加一小块“修正项”。</p>
<div class="blog_h3"><span class="graybg">Stacking</span></div>
<p>Stacking（堆叠集成）不只是平均多个模型输出，而是再训练一个元学习器（Meta-Learner）去学习“什么时候该信哪个模型”。例如一个基模型擅长处理稀疏文本特征，另一个擅长处理数值特征，Stacking 可以学会在不同样本上动态加权它们。</p>
<p>它像“专家会诊 + 总负责人”：若文本模型和表格模型各有专长，元模型就负责综合判断谁在当前病例上更可信。</p>
<div class="blog_h2"><span class="graybg">机器学习编程</span></div>
<p>机器学习编程（Machine Learning Programming）处理的核心问题是：当一个模型被写成代码并真正运行起来时，<span style="background-color: #c0c0c0;">谁负责组织训练流程，谁负责表达数学操作，谁又负责在具体硬件上把这些操作算快</span>。这三层通常分别对应编程框架（Framework）、算子（Operator）和内核（Kernel）。理解这三个层次，有助于把“模型公式”与“工程实现”连接起来。</p>
<p>它们不是彼此独立的三个名词，而是一条自上而下的执行链。研究者或工程师先在框架里定义模型结构与训练流程；框架再把模型拆成一系列算子，例如矩阵乘法、卷积、归一化、激活、softmax；每个算子最终还需要落到某个硬件相关的内核实现上，才能在 CPU、GPU、TPU 或其他加速器上真正执行。因此，<span style="background-color: #c0c0c0;">框架决定开发体验，算子决定计算图语义，内核决定实际运行效率</span>。</p>
<div class="blog_h3"><span class="graybg">框架（Framework）</span></div>
<p>框架（Framework）并不是单一层次的概念。在现代机器学习工程里，至少要区分两层：一层是基础框架（Foundational Framework），负责张量（Tensor）、自动求导（Automatic Differentiation）、算子调度与底层执行；另一层是高层框架（High-level Framework），负责把某一类模型、某一类训练范式或某一类工程流程封装成更直接的接口。前者提供“地基”，后者提供“脚手架”和“现成结构”。</p>
<p>因此，PyTorch、TensorFlow、JAX 这类系统更适合放在基础框架层；而 Transformers、DeepSpeed、ModelScope、Lightning 这类工具，则更适合放在高层框架层。它们之间不是替代关系，而是典型的上下层关系：<span style="background-color: #c0c0c0;">高层框架通常建立在基础框架之上，用更强的任务抽象、更少的模板代码和更完整的工程能力，把常见训练与推理流程直接组织起来</span>。</p>
<div class="blog_h3"><span class="graybg">常见框架简介</span></div>
<p>从工程使用频率看，开发者最常接触的通常不是“从零写张量系统”，而是先接触一组已经按职责分层的常见框架：高层框架负责组织训练与推理流程，基础框架负责真正执行张量计算，某些系统还进一步偏向执行优化与部署。把这些名字放回分层结构里理解，比孤立记忆框架名称更清楚。</p>
<div class="blog_h4"><span class="graybg">高层框架（High-level Framework）</span></div>
<p>高层框架的核心价值在于，它们不重新发明张量计算和自动求导，而是在基础框架之上增加更强的任务语义、训练编排、模型生态或分布式能力。很多工程里，开发者日常接触最多的其实是这一层。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">高层框架</td>
<td style="text-align: center;">主要依赖的基础框架</td>
<td style="text-align: center;">核心定位</td>
<td style="text-align: center;">替你封装了什么</td>
<td style="text-align: center;">最适合的场景</td>
<td style="text-align: center;">与基础框架的关系</td>
</tr>
</thead>
<tbody>
<tr>
<td>Transformers</td>
<td>以 PyTorch 为主，也支持 TensorFlow 与 JAX / Flax</td>
<td>预训练 Transformer 模型与任务头生态</td>
<td>模型定义、权重加载、tokenizer / processor、Trainer、pipeline、任务头</td>
<td>NLP、LLM、多模态模型的微调与推理</td>
<td>通常不是自己实现底层训练系统，而是调用底层框架完成张量计算与反向传播</td>
</tr>
<tr>
<td>DeepSpeed</td>
<td>主要建立在 PyTorch 之上</td>
<td>大模型训练与推理优化框架</td>
<td>ZeRO、参数分片、优化器状态管理、分布式训练编排、推理加速</td>
<td>超大模型训练、多卡 / 多机扩展、显存受限训练</td>
<td>本质上是对 PyTorch 训练过程的增强与重写，而不是替代 PyTorch</td>
</tr>
<tr>
<td>Unsloth</td>
<td>主要建立在 PyTorch 之上，并与 Transformers、PEFT、TRL 等 Hugging Face 生态深度协同</td>
<td>面向 LLM 微调与对齐的性能导向高层框架</td>
<td>快速加载与训练配方、QLoRA / DoRA / RL 配置、量化微调、长上下文训练、导出到 GGUF / Ollama / vLLM / Hugging Face</td>
<td>单卡或少卡进行 LLM 微调、偏好对齐、消费级显卡上的高性价比实验</td>
<td>它不替代 PyTorch 或 Transformers，而是在它们之上把“高效微调 + 导出部署”这条链路进一步封装并做性能优化</td>
</tr>
<tr>
<td>ModelScope</td>
<td>主要建立在 PyTorch 之上，也兼容其他学习框架与平台能力</td>
<td>模型社区 + SDK + 训练 / 推理工作流</td>
<td>模型获取、pipeline、训练入口、评测、部署衔接、领域模型集成</td>
<td>中文生态、多模态任务、快速调用开源模型并做微调</td>
<td>更像“模型平台层 + 高层开发框架”，下层训练仍要落到基础框架执行</td>
</tr>
<tr>
<td>PyTorch Lightning</td>
<td>PyTorch</td>
<td>训练流程组织框架</td>
<td>Trainer、设备放置、日志、checkpoint、验证循环、分布式训练模板</td>
<td>希望保留 PyTorch 灵活性，同时减少训练样板代码</td>
<td>把 PyTorch 代码组织得更规范，但底层模型与梯度仍然是 PyTorch</td>
</tr>
<tr>
<td>Accelerate</td>
<td>PyTorch</td>
<td>分布式训练与多设备执行抽象</td>
<td>多 GPU / 多机启动、混合精度、设备管理、统一训练脚本适配</td>
<td>想在尽量少改代码的前提下把 PyTorch 训练扩展到分布式环境</td>
<td>不取代 PyTorch 训练代码，而是让同一份 PyTorch 代码更容易跨设备运行</td>
</tr>
<tr>
<td>Keras 3</td>
<td>可运行在 TensorFlow、JAX、PyTorch 之上，推理还可对接 OpenVINO</td>
<td>高层模型开发接口</td>
<td>Layer / Model 抽象、训练接口、callback、分布式 API、生态组件</td>
<td>需要高层建模接口且希望在多后端之间切换</td>
<td>处于基础框架之上，强调统一建模接口，而不是直接取代底层后端</td>
</tr>
<tr>
<td>Sentence Transformers</td>
<td>主要建立在 Transformers 与 PyTorch 之上</td>
<td>Embedding 与 reranker 高层框架</td>
<td>文本向量化、相似度训练、检索 / rerank 训练器、评测工具</td>
<td>语义检索、向量召回、文本匹配、reranking</td>
<td>属于面向特定任务族的高层框架，下层依赖 Transformers 与 PyTorch</td>
</tr>
<tr>
<td>MMEngine / OpenMMLab</td>
<td>主要建立在 PyTorch 之上</td>
<td>通用训练引擎与视觉算法框架底座</td>
<td>Runner、Hook、Config、数据流、训练 / 验证 / 测试流程组织</td>
<td>检测、分割、姿态估计等视觉任务</td>
<td>用统一工程抽象组织 PyTorch 训练，尤其适合复杂视觉实验体系</td>
</tr>
</tbody>
</table>
<p>这一层最容易让人产生“它自己就能训练模型”的直觉，但从执行链条看，它们大多只是把训练过程组织得更高级。以 Transformers 为例，<span style="background-color: #c0c0c0;">Trainer 可以发起训练，但底层的张量、梯度、优化器、自动求导与设备执行，通常仍然由 PyTorch 负责</span>；Lightning、Accelerate、DeepSpeed 也是同样的逻辑，只是它们封装的侧重点不同。</p>
<div class="blog_h4"><span class="graybg">Unsloth</span></div>
<p>Unsloth 最适合放在高层框架这一层理解。它并不重新定义 Tensor、自动求导（Autograd）或底层计算图，而是在 PyTorch、Transformers、PEFT、TRL 这一整套既有生态之上，把大语言模型（Large Language Model, LLM）的高频训练流程重新组织成更偏性能导向的开发体验。它解决的核心问题不是“怎样从零实现神经网络”，而是<span style="background-color: #c0c0c0;">怎样在尽可能小的显存和尽可能少的工程样板下，把 LLM 微调、对齐、导出与部署这条链路跑通</span>。</p>
<p>从开发者视角看，Unsloth 的典型工作流通常仍然是“加载 Hugging Face 模型 → 挂接 PEFT 适配器 → 用监督微调（SFT）或强化学习（RL）训练 → 导出到下游推理栈”。它的价值在于把这条链上几个最痛的环节一起压平：第一，量化微调（如 LoRA / QLoRA）与长上下文训练更容易直接落地；第二，针对 GRPO 等对齐训练给出更直接的入口；第三，把训练后的模型或适配器导出到 GGUF、Ollama、vLLM、SGLang、Hugging Face 等下游环境的路径做得更短。换言之，Unsloth 不是另起炉灶，而是把已有生态串得更紧，并对其中最贵的显存、最长的上下文和最复杂的导出环节做专项优化。</p>
<p>它与其他高层框架的边界也需要分清。Transformers 的优势在于模型家族与任务头生态最通用；Accelerate 更像多设备执行抽象；DeepSpeed 更偏分布式训练、参数分片与大规模集群优化；Unsloth 则把重心压在“单机到小规模多卡场景下，如何更快、更省显存地完成 LLM 微调与对齐”。因此，它尤其适合消费级 GPU、本地实验、小团队快速验证、Notebook 驱动的训练流程，以及以 LoRA / QLoRA / RL 为主的大模型增量训练。若任务是超大规模集群预训练或需要复杂的跨机并行策略，DeepSpeed / Megatron 一类系统通常仍然更中心；若任务是通用模型调用与标准微调，Transformers 仍然是最基础的入口。</p>
<p>工程上可以把 Unsloth 看成“面向 LLM 微调的高性能工作台”。它一端连接 Hugging Face 模型与适配器生态，另一端连接本地推理、GGUF、Ollama、vLLM、SGLang 等部署路径，中间则用一套更偏性能调优的训练封装把它们粘起来。对初学者，它降低的是上手门槛：少改几处配置就能跑通量化微调或 RL；对熟悉生态的工程师，它降低的是试验成本：同样的显存预算下，能尝试更长上下文、更复杂的对齐流程，或更快地把训练结果导出到不同推理栈。它的本质依然是高层工程抽象，而不是底层深度学习框架的替代品。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">比较维度</td>
<td style="text-align: center;">Transformers</td>
<td style="text-align: center;">DeepSpeed</td>
<td style="text-align: center;">Unsloth</td>
</tr>
</thead>
<tbody>
<tr>
<td>核心目标</td>
<td>统一模型与任务接口</td>
<td>放大训练规模与显存效率</td>
<td>压低 LLM 微调 / 对齐门槛并提升单机效率</td>
</tr>
<tr>
<td>最擅长的问题</td>
<td>模型加载、任务头、Trainer、pipeline</td>
<td>ZeRO、分布式并行、大模型集群训练</td>
<td>QLoRA、GRPO、长上下文、快速导出部署</td>
</tr>
<tr>
<td>典型使用者</td>
<td>几乎所有 NLP / LLM 开发者</td>
<td>大模型平台与多机训练团队</td>
<td>本地实验者、个人开发者、小团队 LLM 微调工程师</td>
</tr>
<tr>
<td>与 PyTorch 的关系</td>
<td>调用其张量与训练能力</td>
<td>增强其训练与并行系统</td>
<td>在其之上重组 LLM 微调与导出流程</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">基础框架（Foundational Framework）</span></div>
<p>基础框架直接定义张量运算、计算图、自动求导、优化器、算子调度与设备执行能力。它们离硬件更近，也离“神经网络真正怎样被算出来”更近。高层框架能否存在，首先取决于这一层是否提供了足够稳定和强大的底座。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">基础框架</td>
<td style="text-align: center;">核心抽象</td>
<td style="text-align: center;">主要优势</td>
<td style="text-align: center;">最适合的场景</td>
<td style="text-align: center;">典型局限</td>
</tr>
</thead>
<tbody>
<tr>
<td>PyTorch</td>
<td>Tensor、<pre class="crayon-plain-tag">nn.Module</pre>、autograd、动态图（Dynamic Computation Graph）</td>
<td>灵活、直观、研究生态最强，训练与调试体验优秀</td>
<td>研究、论文复现、大模型训练、需要自定义训练逻辑的任务</td>
<td>如果完全手写训练循环，工程样板代码较多，部署链路常需额外工具配合</td>
</tr>
<tr>
<td>TensorFlow</td>
<td>Tensor、Layer / Model、自动求导、图执行与编译</td>
<td>训练与部署体系完整，服务化、端侧与工业链路成熟</td>
<td>企业级生产环境、需要完整训练到部署闭环的场景</td>
<td>研究阶段的编码与调试直观性通常不如 PyTorch</td>
</tr>
<tr>
<td>JAX</td>
<td>数组（Array）+ 函数变换 + XLA 编译</td>
<td>编译优化强，函数式表达清晰，适合大规模并行数值计算</td>
<td>需要强编译能力、自定义并行策略、科研数值实验的任务</td>
<td>函数式编程习惯要求更高，工程生态相对更偏高级用户</td>
</tr>
<tr>
<td>PaddlePaddle</td>
<td>Tensor、动态图 / 静态图、训练与产业工具链</td>
<td>中文生态与产业落地支持强，训练推理工具链完整</td>
<td>产业应用、教育场景、中文任务与本土化生态</td>
<td>国际社区规模与通用论文实现数量通常少于 PyTorch</td>
</tr>
</tbody>
</table>
<p>如果把机器学习工程比作建楼，那么基础框架提供的是钢筋、水泥、电路和承重结构。高层框架之所以能让开发速度显著提升，正是因为底层这些张量、梯度和算子能力已经由基础框架稳定提供。</p>
<div class="blog_h4"><span class="graybg">执行与部署系统（Execution and Deployment Stack）</span></div>
<p>除了高层框架和基础框架，还存在一类经常与“框架”混称、但职责不同的系统：执行与部署系统。它们的核心目标不是让用户更方便地写模型，而是让已经定义好的模型在特定硬件上更快、更省、更稳定地运行。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">系统</td>
<td style="text-align: center;">主要定位</td>
<td style="text-align: center;">最常见作用</td>
<td style="text-align: center;">典型场景</td>
<td style="text-align: center;">与前两层的关系</td>
</tr>
</thead>
<tbody>
<tr>
<td>ONNX Runtime</td>
<td>跨框架推理执行系统</td>
<td>加载 ONNX 图，做图优化、算子调度与多后端执行</td>
<td>统一部署、跨框架导出后的推理执行</td>
<td>位于模型定义之后，更接近运行时（Runtime）而不是训练框架</td>
</tr>
<tr>
<td>TensorRT</td>
<td>NVIDIA GPU 推理优化系统</td>
<td>图优化、层融合、量化与 kernel 自动选择</td>
<td>低延迟在线推理、高吞吐批量服务</td>
<td>通常接收上层框架导出的模型，再做更深的硬件侧优化</td>
</tr>
<tr>
<td>OpenVINO</td>
<td>Intel 硬件推理栈</td>
<td>模型转换、图优化、Intel CPU / iGPU / VPU 推理</td>
<td>Intel 服务器与边缘设备部署</td>
<td>更接近部署后端，而不是通用训练框架</td>
</tr>
<tr>
<td>TVM</td>
<td>深度学习编译栈</td>
<td>自动调优、代码生成、异构硬件适配</td>
<td>边缘部署、自定义芯片、性能工程</td>
<td>站在算子和内核之间，为不同硬件生成更优执行实现</td>
</tr>
</tbody>
</table>
<p>因此，讨论“框架”时最好先分清是哪一层：<span style="background-color: #c0c0c0;">高层框架负责把任务和流程组织起来，基础框架负责把张量与梯度真正算出来，执行与部署系统负责把已经定义好的模型在目标硬件上跑到更优</span>。这三层一旦混在一起，很多看似相近的名词就会失去边界。</p>
<div class="blog_h3"><span class="graybg">算子（Operator）</span></div>
<p>算子（Operator）是计算图（Computation Graph）的基本运算单元。它定义一个明确的数学变换：输入什么张量（Tensor）、输出什么张量、张量的形状（Shape）和数据类型（Data Type）如何变化，以及反向传播时梯度如何计算。框架层写出的模块、层、网络块，最终都会被拆解成一串更细粒度的算子。</p>
<p>从工程实现上看，算子这一层负责表达“数学语义”。例如一个线性层（Linear Layer）会被拆成 MatMul 与 Bias Add；一个自注意力（Self-Attention）模块会被拆成 Q/K/V 投影、MatMul、Scale、Mask、Softmax、再一次 MatMul、Dropout、LayerNorm 等。高层模块能否被编译优化，本质上取决于这些算子能否被识别、融合与高效执行。</p>
<p>常用算子可以分成四大类：线性代数与张量形状类、神经网络前向计算类、序列与索引操作类、训练与优化类。下面的表格按这一方式展开。</p>
<div class="blog_h4"><span class="graybg">线性代数与张量形状类</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">算子</td>
<td style="text-align: center;">核心作用</td>
<td style="text-align: center;">常见输入 / 输出</td>
<td style="text-align: center;">典型出现位置</td>
<td style="text-align: center;">实现与使用要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>MatMul / GEMM</td>
<td>矩阵乘法，完成线性投影与特征混合</td>
<td>二维或更高维张量，输出新的线性组合</td>
<td>全连接层、注意力投影、MLP</td>
<td>最核心的高密度算子之一，常直接决定训练吞吐</td>
</tr>
<tr>
<td>Batch MatMul</td>
<td>批量矩阵乘法，多个矩阵对并行相乘</td>
<td>三维及以上张量</td>
<td>多头注意力（Multi-Head Attention）</td>
<td>常与转置、缩放、mask 连用</td>
</tr>
<tr>
<td>Add / Bias Add</td>
<td>逐元素加法</td>
<td>两个可广播张量</td>
<td>残差连接、偏置项、特征融合</td>
<td>常被融合到前后算子中减少访存</td>
</tr>
<tr>
<td>Sub / Mul / Div</td>
<td>逐元素减法、乘法、除法</td>
<td>逐元素张量运算</td>
<td>归一化、门控、缩放</td>
<td>广播规则必须和张量形状匹配</td>
</tr>
<tr>
<td>Scale</td>
<td>用常数或向量对张量缩放</td>
<td>输入张量与标量 / 向量</td>
<td>attention 中的 <span displaypfx="inline-" class="mathjax-container">\(1/\sqrt{d_k}\)</span> 缩放</td>
<td>经常被编译器与前后算子融合</td>
</tr>
<tr>
<td>Transpose / Permute</td>
<td>重排维度顺序</td>
<td>输入张量到相同元素、不同布局的张量</td>
<td>NCHW / NHWC 转换，多头维度重排</td>
<td>逻辑上不改值，但常改变内存访问模式</td>
</tr>
<tr>
<td>Reshape / View</td>
<td>改变张量形状而不改变元素总数</td>
<td>同样的数据，不同 shape</td>
<td>展平、分头、合并头、batch 展开</td>
<td>若内存不连续，可能触发额外复制</td>
</tr>
<tr>
<td>Expand / BroadcastTo</td>
<td>按广播规则扩展维度</td>
<td>低维张量扩展为高维张量</td>
<td>偏置广播、mask 扩展</td>
<td>逻辑扩展不一定真实复制数据</td>
</tr>
<tr>
<td>Squeeze / Unsqueeze</td>
<td>删除或插入长度为 1 的维度</td>
<td>维度数变化，数据值不变</td>
<td>batch / channel 维调整</td>
<td>常用于接口对齐和算子拼接</td>
</tr>
<tr>
<td>Concat / Stack</td>
<td>拼接多个张量</td>
<td>多个同类型张量合并为一个</td>
<td>多特征合并、多分支网络</td>
<td>Concat 沿已有维度拼接，Stack 会新增维度</td>
</tr>
<tr>
<td>Split / Chunk</td>
<td>将一个张量拆成多个子张量</td>
<td>一个张量拆成若干块</td>
<td>Q/K/V 切分、多分支路径</td>
<td>与 Concat、Stack 常成对出现</td>
</tr>
<tr>
<td>ReduceSum / ReduceMean / ReduceMax</td>
<td>沿某些维度做聚合</td>
<td>高维张量压缩成低维张量</td>
<td>池化、loss 聚合、统计量计算</td>
<td>归约（Reduction）通常对并行实现要求较高</td>
</tr>
<tr>
<td>EinSum</td>
<td>用爱因斯坦求和规则表达复合张量运算</td>
<td>多个张量到一个张量</td>
<td>复杂线性代数、注意力原型实现</td>
<td>表达力强，但实际性能往往依赖后端是否能分解优化</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">神经网络前向计算类</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">算子</td>
<td style="text-align: center;">核心作用</td>
<td style="text-align: center;">常见输入 / 输出</td>
<td style="text-align: center;">典型出现位置</td>
<td style="text-align: center;">实现与使用要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>Convolution</td>
<td>局部感受野加权求和</td>
<td>特征图与卷积核，输出新的特征图</td>
<td>CNN、视觉 backbone、语音模型</td>
<td>步幅、填充、分组卷积会显著影响性能与感受野</td>
</tr>
<tr>
<td>Depthwise / Group Convolution</td>
<td>按通道组或单通道卷积</td>
<td>特征图到特征图</td>
<td>MobileNet、轻量视觉网络</td>
<td>减少计算量，但对 kernel 实现要求更高</td>
</tr>
<tr>
<td>Pooling（Max / Avg）</td>
<td>局部下采样与聚合</td>
<td>特征图压缩为空间更小的特征图</td>
<td>CNN、时序聚合</td>
<td>降低分辨率与计算量，也带来信息损失</td>
</tr>
<tr>
<td>Adaptive Pooling</td>
<td>把输入压到固定输出尺寸</td>
<td>任意空间尺寸到固定尺寸</td>
<td>视觉分类头、全局池化</td>
<td>方便不同输入尺寸统一到下游全连接层</td>
</tr>
<tr>
<td>ReLU</td>
<td>负值截断为 0 的激活函数</td>
<td>逐元素非线性变换</td>
<td>MLP、CNN、分类头</td>
<td>实现简单，稀疏性强</td>
</tr>
<tr>
<td>GELU</td>
<td>平滑激活，保留小负值的连续变化</td>
<td>逐元素非线性变换</td>
<td>Transformer、LLM MLP</td>
<td>现代语言模型最常见的激活之一</td>
</tr>
<tr>
<td>SiLU / Swish</td>
<td>输入与 sigmoid 门控的乘积</td>
<td>逐元素非线性变换</td>
<td>高性能视觉与语言模型</td>
<td>平滑、效果稳定，常见于新型 backbone</td>
</tr>
<tr>
<td>Sigmoid</td>
<td>把实数映射到 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span></td>
<td>逐元素概率化</td>
<td>门控单元、二分类输出、多标签任务</td>
<td>饱和区梯度小，深层网络中通常不作主激活</td>
</tr>
<tr>
<td>Tanh</td>
<td>把实数映射到 <span displaypfx="inline-" class="mathjax-container">\((-1,1)\)</span></td>
<td>逐元素非线性变换</td>
<td>早期 RNN、门控结构</td>
<td>零中心，但同样存在饱和问题</td>
</tr>
<tr>
<td>Softmax</td>
<td>把一组分数归一化为概率分布</td>
<td>类别分数到概率</td>
<td>多分类头、attention 权重</td>
<td>常与交叉熵和 mask 配合，数值稳定性关键</td>
</tr>
<tr>
<td>LayerNorm</td>
<td>对单个样本的最后若干维做归一化</td>
<td>输入张量到同 shape 张量</td>
<td>Transformer、LLM</td>
<td>不依赖 batch 统计量，适合变长序列</td>
</tr>
<tr>
<td>BatchNorm</td>
<td>利用 batch 统计量做归一化</td>
<td>输入张量到同 shape 张量</td>
<td>CNN、视觉任务</td>
<td>训练与推理行为不同，小 batch 时效果可能下降</td>
</tr>
<tr>
<td>RMSNorm</td>
<td>基于均方根做归一化</td>
<td>输入张量到同 shape 张量</td>
<td>许多现代大语言模型</td>
<td>比 LayerNorm 更简洁，计算更轻</td>
</tr>
<tr>
<td>Attention / SDPA</td>
<td>基于相似度对值向量加权聚合</td>
<td>Q、K、V 到上下文表示</td>
<td>Transformer、跨模态模型</td>
<td>高层看是算子族，底层常映射到 FlashAttention 等 kernel</td>
</tr>
<tr>
<td>Embedding Lookup</td>
<td>根据离散索引查表取向量</td>
<td>token id 到 embedding 向量</td>
<td>NLP、推荐系统、类别特征</td>
<td>本质是参数矩阵的索引读取，不是普通 MatMul</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">序列与索引操作类</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">算子</td>
<td style="text-align: center;">核心作用</td>
<td style="text-align: center;">常见输入 / 输出</td>
<td style="text-align: center;">典型出现位置</td>
<td style="text-align: center;">实现与使用要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>Gather</td>
<td>按给定索引抽取元素或切片</td>
<td>源张量与索引张量</td>
<td>embedding、beam search、采样</td>
<td>访问模式离散，容易受内存带宽限制</td>
</tr>
<tr>
<td>Scatter / ScatterAdd</td>
<td>按索引写回或累加</td>
<td>目标张量、索引、更新值</td>
<td>图神经网络、稀疏更新</td>
<td>并发写冲突和原子操作代价常是性能瓶颈</td>
</tr>
<tr>
<td>Index Select</td>
<td>按某一维选取指定位置</td>
<td>张量与一维索引</td>
<td>子序列抽取、类别筛选</td>
<td>语义上比通用 gather 更窄，但常更清晰</td>
</tr>
<tr>
<td>Slice / Narrow</td>
<td>截取连续区间</td>
<td>大张量切出子张量</td>
<td>窗口注意力、局部特征抽取</td>
<td>若数据连续，可几乎零开销视图化</td>
</tr>
<tr>
<td>Mask Fill / Select</td>
<td>按布尔掩码选择或填充值</td>
<td>张量与 mask</td>
<td>attention mask、padding 屏蔽</td>
<td>对变长序列与非法位置处理非常关键</td>
</tr>
<tr>
<td>Where</td>
<td>按条件在两个值之间选择</td>
<td>条件张量与候选张量</td>
<td>条件计算、loss 屏蔽、数值裁剪</td>
<td>本质是逐元素条件分支</td>
</tr>
<tr>
<td>Argmax / Argmin</td>
<td>返回最大 / 最小值所在索引</td>
<td>张量到索引</td>
<td>分类预测、贪心解码</td>
<td>输出是位置而非概率，通常不可导</td>
</tr>
<tr>
<td>TopK</td>
<td>返回前 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个值及其索引</td>
<td>张量到值与索引</td>
<td>检索、beam search、采样截断</td>
<td>常与排序、候选筛选结合</td>
</tr>
<tr>
<td>Sort / Argsort</td>
<td>排序并返回顺序</td>
<td>张量到排序结果</td>
<td>排序损失、候选重排</td>
<td>复杂度高，尽量只在必要路径中使用</td>
</tr>
<tr>
<td>Pad</td>
<td>在边界补零或补指定值</td>
<td>原张量到更大张量</td>
<td>卷积前处理、batch 对齐、序列补齐</td>
<td>padding 策略会影响有效计算比例</td>
</tr>
<tr>
<td>Pack / Unpack Sequence</td>
<td>压缩或还原变长序列表示</td>
<td>变长序列与紧凑表示之间转换</td>
<td>RNN、语音与时序模型</td>
<td>用于减少 padding 带来的无效计算</td>
</tr>
<tr>
<td>Position Encoding Add</td>
<td>注入位置信息</td>
<td>token 表示与位置编码</td>
<td>Transformer、序列模型</td>
<td>绝对位置、相对位置、RoPE 在实现形式上不同</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">训练与优化类</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">算子</td>
<td style="text-align: center;">核心作用</td>
<td style="text-align: center;">常见输入 / 输出</td>
<td style="text-align: center;">典型出现位置</td>
<td style="text-align: center;">实现与使用要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>Dropout</td>
<td>训练时随机失活部分单元</td>
<td>输入张量到稀疏化后的张量</td>
<td>MLP、attention、分类头</td>
<td>训练和推理行为不同，推理阶段通常关闭</td>
</tr>
<tr>
<td>Cross-Entropy Loss</td>
<td>衡量预测分布与真实类别的差距</td>
<td>logits 与标签到标量损失</td>
<td>分类、语言模型、token 分类</td>
<td>实现中常与 log-softmax 融合提高稳定性</td>
</tr>
<tr>
<td>NLL Loss</td>
<td>负对数似然损失</td>
<td>对数概率与标签到损失</td>
<td>分类任务、序列建模</td>
<td>通常接在 log-softmax 之后</td>
</tr>
<tr>
<td>MSE Loss</td>
<td>均方误差</td>
<td>预测值与目标值到损失</td>
<td>回归、蒸馏、表示对齐</td>
<td>对异常值较敏感</td>
</tr>
<tr>
<td>L1 / SmoothL1 Loss</td>
<td>绝对误差或平滑绝对误差</td>
<td>预测值与目标值到损失</td>
<td>目标检测、鲁棒回归</td>
<td>比 MSE 对异常值更稳</td>
</tr>
<tr>
<td>KL Divergence</td>
<td>衡量两个分布之间的差异</td>
<td>两个概率分布到标量</td>
<td>知识蒸馏、VAE、分布对齐</td>
<td>输入通常需要是概率或对数概率</td>
</tr>
<tr>
<td>Backward / Gradient</td>
<td>沿计算图反向传播梯度</td>
<td>损失到各参数梯度</td>
<td>所有训练流程</td>
<td>框架自动求导的核心能力就体现在这里</td>
</tr>
<tr>
<td>Gradient Clip</td>
<td>限制梯度范数或幅值</td>
<td>梯度到裁剪后梯度</td>
<td>RNN、大模型训练</td>
<td>控制梯度爆炸，提升训练稳定性</td>
</tr>
<tr>
<td>Optimizer Step（SGD / Adam / AdamW）</td>
<td>根据梯度更新参数</td>
<td>参数、梯度、状态到新参数</td>
<td>每一步训练迭代</td>
<td>常被实现为 fused optimizer kernel 以减少开销</td>
</tr>
<tr>
<td>Weight Decay</td>
<td>对参数施加正则化收缩</td>
<td>参数到受约束更新</td>
<td>分类、语言模型、视觉模型</td>
<td>现代实现常与 AdamW 解耦</td>
</tr>
<tr>
<td>AllReduce</td>
<td>跨设备聚合梯度或统计量</td>
<td>多卡张量到同步后的张量</td>
<td>数据并行训练</td>
<td>严格说更接近通信算子，但在训练图中极常见</td>
</tr>
<tr>
<td>AllGather / ReduceScatter</td>
<td>跨设备收集或切分张量</td>
<td>多设备张量通信</td>
<td>张量并行、序列并行、ZeRO</td>
<td>大模型分布式训练不可缺少</td>
</tr>
</tbody>
</table>
<p>因此，算子层是连接“模型定义”和“底层执行”的语义中枢。看懂模型实际调用了哪些算子，基本就等于看懂了它在做哪些数学步骤，以及这些步骤能否被进一步融合和加速。</p>
<div class="blog_h3"><span class="graybg">内核（Kernel）</span></div>
<p>内核（Kernel）是算子的底层实现。它规定了某个算子如何映射到具体硬件：线程如何组织、数据如何分块、是否使用共享内存（Shared Memory）、是否调用向量化指令、是否走 Tensor Core、是否把多个小算子融合成一次执行。若说算子定义的是“做什么”，那么内核定义的就是“怎样在这台机器上把它做快”。</p>
<p>从性能工程角度看，内核层回答的是：<span style="background-color: #c0c0c0;">同一个数学算子，针对不同硬件、数据形状、精度格式和访存模式，哪种实现最优</span>。这也是为什么表面上同样是 MatMul、LayerNorm 或 Attention，不同框架和后端的速度会相差很大。</p>
<p>常见内核或内核族可以按下表理解：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">内核 / 内核族</td>
<td style="text-align: center;">主要服务的算子</td>
<td style="text-align: center;">典型硬件 / 后端</td>
<td style="text-align: center;">核心优化手段</td>
<td style="text-align: center;">最典型的收益</td>
<td style="text-align: center;">常见场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>cuBLAS GEMM kernel</td>
<td>MatMul、Linear、Batch MatMul</td>
<td>NVIDIA GPU</td>
<td>tile blocking、Tensor Core、流水线、寄存器复用</td>
<td>把矩阵乘法做到接近硬件峰值吞吐</td>
<td>全连接层、attention 投影、MLP</td>
</tr>
<tr>
<td>cuDNN Convolution kernel</td>
<td>Convolution、Pooling、部分归一化与激活</td>
<td>NVIDIA GPU</td>
<td>direct convolution、im2col + GEMM、Winograd、FFT 自动选择</td>
<td>按输入形状自动切换最优卷积路径</td>
<td>CNN、视觉 backbone、语音前端</td>
</tr>
<tr>
<td>oneDNN / MKL kernel</td>
<td>MatMul、Convolution、Normalization</td>
<td>x86 CPU</td>
<td>SIMD 向量化、cache blocking、线程并行</td>
<td>提升 CPU 推理与训练效率</td>
<td>服务器 CPU 推理、无 GPU 环境</td>
</tr>
<tr>
<td>NCCL communication kernel</td>
<td>AllReduce、AllGather、ReduceScatter、Broadcast</td>
<td>NVIDIA 多 GPU</td>
<td>环形通信（Ring）、树形通信（Tree）、链路拓扑优化</td>
<td>降低多卡同步开销</td>
<td>数据并行、张量并行、大模型训练</td>
</tr>
<tr>
<td>FlashAttention kernel</td>
<td>Scaled Dot-Product Attention</td>
<td>NVIDIA GPU 及其他支持相应实现的加速器</td>
<td>分块、在线 softmax、kernel fusion、减少 HBM 访存</td>
<td>把 attention 从显存瓶颈拉回到更接近计算瓶颈</td>
<td>Transformer、LLM、长序列建模</td>
</tr>
<tr>
<td>Fused LayerNorm / RMSNorm kernel</td>
<td>LayerNorm、RMSNorm、Bias Add、Residual Add</td>
<td>GPU / CPU 后端</td>
<td>多步逐元素运算融合为一次访存</td>
<td>显著降低 memory-bound 算子的开销</td>
<td>Transformer block、LLM 推理</td>
</tr>
<tr>
<td>Fused MLP kernel</td>
<td>Bias Add、GELU / SiLU、Dropout、Residual</td>
<td>GPU</td>
<td>把连续逐元素算子合并，减少中间张量写回</td>
<td>减少 kernel launch 次数与显存读写</td>
<td>MLP block、前馈网络</td>
</tr>
<tr>
<td>Triton custom kernel</td>
<td>任意自定义算子或 fused operator</td>
<td>GPU</td>
<td>开发者手写 tile、访存布局与并行策略</td>
<td>在通用库缺少最优实现时获得定制性能</td>
<td>大模型训练、研究型性能优化、自定义融合</td>
</tr>
<tr>
<td>TensorRT generated kernel</td>
<td>部署图中的卷积、MatMul、激活、归一化、量化路径</td>
<td>NVIDIA GPU</td>
<td>图优化、层融合、低精度选择、kernel autotuning</td>
<td>显著降低推理时延并提升吞吐</td>
<td>在线推理服务、边缘推理</td>
</tr>
<tr>
<td>XLA generated kernel</td>
<td>JAX / TensorFlow 图中的可融合算子子图</td>
<td>GPU、TPU 等</td>
<td>图级融合、静态形状分析、编译生成目标代码</td>
<td>让一串算子整体下沉为更大的执行单元</td>
<td>JAX 训练、TPU 训练、编译型执行场景</td>
</tr>
<tr>
<td>TVM generated kernel</td>
<td>自定义张量表达式对应的算子</td>
<td>CPU、GPU、边缘加速器</td>
<td>自动调度、自动搜索、代码生成</td>
<td>跨异构硬件获得针对性实现</td>
<td>端侧部署、自定义芯片适配</td>
</tr>
<tr>
<td>PagedAttention / KV-cache kernel</td>
<td>增量解码中的 attention 与缓存访问</td>
<td>LLM 推理后端</td>
<td>分页管理 KV cache、优化随机访问和 batch 合并</td>
<td>提升长上下文与多请求并发推理效率</td>
<td>大语言模型在线推理</td>
</tr>
</tbody>
</table>
<p>理解内核时最重要的一点是：<span style="background-color: #c0c0c0;">一个算子并不对应唯一实现</span>。同样是卷积，可以选 direct convolution、Winograd 或 FFT；同样是 attention，可以选普通分步实现、FlashAttention 或推理场景下的 paged attention。真正的差异往往不在数学定义，而在访存方式、融合策略、并行粒度和硬件利用率上。</p>
<p>因此，内核并不是建模阶段最先暴露给用户的对象，却往往决定模型最终的吞吐、时延、显存占用、能耗和成本。模型结构决定“上限在哪里”，内核质量决定“这个上限能兑现多少”。</p>
<div class="blog_h3"><span class="graybg">三者关系</span></div>
<p>把三者串起来看，一个卷积层或注意力层的执行路径通常是这样的：开发者先在 PyTorch 或 JAX 中写出一个模块；框架把它拆成若干算子，例如 MatMul、Softmax、LayerNorm 或 Convolution；运行时再为每个算子选择对应硬件上的 kernel，例如 cuBLAS 的 GEMM kernel、cuDNN 的 convolution kernel，或 Triton 写成的自定义 fused kernel。整个训练与推理过程由框架负责调度，算子负责表达数学语义，内核负责把语义变成高性能机器代码。</p>
<p>可以用一个具体例子来理解。若在 PyTorch 中定义一个卷积层，那么：</p>
<ol>
<li>PyTorch 作为框架负责接收模块定义、组织张量、记录梯度关系并调度执行。</li>
<li>卷积（Convolution）作为算子表示“输入特征图与卷积核做局部加权求和”这一数学操作。</li>
<li>底层可能调用 cuDNN 提供的卷积 kernel，在 GPU 上以高度优化的方式完成真正计算。</li>
</ol>
<p>同样地，在 Transformer 中写一层自注意力时，框架会组织前向与反向图；MatMul、Softmax、Mask、LayerNorm 等作为算子组成计算链；而 FlashAttention、fused LayerNorm、paged attention 这类高性能实现，则属于内核或 kernel-level optimization 的范畴。</p>
<p>因此，这三个层次的关系可以概括为：<span style="background-color: #c0c0c0;">框架管理整个建模与执行流程，算子定义模型中的数学步骤，内核负责把这些步骤在具体硬件上高效落地</span>。从研究到工程落地的能力鸿沟，往往正体现在能否同时理解这三层。</p>
<div class="blog_h1"><span class="graybg">经典机器学习</span></div>
<div class="blog_h2"><span class="graybg">选型指南</span></div>
<p>经典机器学习的模型选择，本质上是在<span style="background-color: #c0c0c0;">任务形式、数据规模、特征形态、可解释性要求、训练与推断成本</span>之间做匹配。只要先判断“有没有标签”“输出是什么类型”“样本量有多大”“特征是不是稀疏或非线性”“是否需要概率或规则解释”，大多数任务都可以迅速缩小到少数几个候选模型。</p>
<p>本章中的模型并不是按“先进程度”排序，而是按建模假设来区分。线性模型假设边界或关系接近线性；树模型更擅长表格数据中的非线性交互；概率模型更强调分布解释；近邻模型依赖局部相似性；聚类与降维模型关注无监督结构；HMM（Hidden Markov Model, HMM）和条件随机场（Conditional Random Field, CRF）则处理序列标签之间存在依赖的结构化预测问题。</p>
<p>下面的表格不是概览式罗列，而是直接服务于选型。每一行都回答五个问题：<span style="background-color: #c0c0c0;">什么情况下优先选它、它最依赖什么数据条件、它能解决什么核心诉求、为什么它在该场景里合适、以及什么情况下应当换模型</span>。在大多数工程场景里，先按这些表格缩小范围，再进入具体模型细节，效率最高。</p>
<div class="blog_h3"><span class="graybg">任务类型：分类</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>逻辑回归</td>
<td>需要稳定强基线、需要概率输出、需要解释每个特征如何影响类别</td>
<td>特征可以线性分离到一定程度；高维稀疏特征尤其合适，如 one-hot、词袋、统计特征</td>
<td>训练快、概率自然、权重可解释，适合作为第一版可上线模型</td>
<td>类别边界高度非线性、特征交互复杂且没有显式特征工程时</td>
</tr>
<tr>
<td>支持向量机（SVM）</td>
<td>样本中小规模、类别边界较清晰、希望用最大间隔提升泛化稳健性</td>
<td>特征需要做尺度统一；核 SVM 更适合中小规模，不适合超大样本</td>
<td>对边界样本建模强，在线性不可分但结构仍较规整时往往优于纯线性模型</td>
<td>数据量很大、需要快速训练与部署、或者必须输出天然概率时</td>
</tr>
<tr>
<td>决策树</td>
<td>需要把模型直接解释成规则路径，或业务天然是“按阈值分流”的形式</td>
<td>表格特征、混合数值与类别特征都可；不强依赖标准化</td>
<td>规则可视化最直接，便于和业务规则、审计规则对齐</td>
<td>追求最稳泛化性能时；单树通常方差高，容易过拟合</td>
</tr>
<tr>
<td>随机森林</td>
<td>表格分类需要稳健基线、希望少调参、担心单棵树过拟合</td>
<td>适合中等规模表格数据；对噪声、缺失和特征尺度通常更宽容</td>
<td>比单树稳定，通常先于复杂 boosting 模型给出可靠结果</td>
<td>追求表格任务的极致精度，或需要很小的模型体积时</td>
</tr>
<tr>
<td>梯度提升树（GBDT）</td>
<td>表格分类精度优先，特征中存在明显非线性与交互效应</td>
<td>适合结构化特征，不要求线性关系；通常需要一定调参</td>
<td>能逐轮修正前一轮错误，在中等规模表格任务上常是强力基线</td>
<td>需要极快训练、极少调参，或数据已经极大到串行 boosting 成本明显偏高时</td>
</tr>
<tr>
<td>XGBoost</td>
<td>工业表格分类、竞赛任务、缺失值与正则化处理都很重要</td>
<td>适合中大规模结构化数据；对特征工程和超参数较敏感，但工程支持成熟</td>
<td>精度高、鲁棒、缺失值处理成熟，是很多表格分类任务的首选之一</td>
<td>极端大规模、极度强调训练速度与内存效率时，LightGBM 往往更优先</td>
</tr>
<tr>
<td>LightGBM</td>
<td>大规模表格分类、高维稀疏特征、需要更快训练与更低内存消耗</td>
<td>适合样本量大、特征维度高的结构化任务；对类别特征和稀疏特征较友好</td>
<td>训练快、工程效率高，在工业 CTR、风控、推荐特征场景很常见</td>
<td>数据量较小且噪声较大时；叶子生长策略若不控制，容易过拟合</td>
</tr>
<tr>
<td>朴素贝叶斯</td>
<td>需要极快文本分类基线、小样本启动、希望先验证特征是否有判别力</td>
<td>最适合词袋、词频、计数类高维稀疏特征；默认接受条件独立近似</td>
<td>训练和推断都极快，在垃圾邮件、主题粗分类等任务上常有效</td>
<td>强依赖复杂特征相关性、类别边界非线性明显、或需要高精度概率校准时</td>
</tr>
<tr>
<td>K 近邻（KNN）</td>
<td>样本规模小、相似样本应有相似标签、希望不做显式训练</td>
<td>距离度量必须有意义；所有特征应标准化，且维度不能过高</td>
<td>局部模式直观，适合做小数据原型验证或相似样本检索式分类</td>
<td>高维稀疏特征、大规模数据、低延迟在线推断场景</td>
</tr>
<tr>
<td>线性判别分析（LDA）</td>
<td>有监督分类同时希望压缩特征维度，且类别统计结构较稳定</td>
<td>更适合每类近似高斯、类内协方差可估计的情况；样本数不能太少</td>
<td>把“降维”和“分类判别”结合起来，适合先压缩再分类的流程</td>
<td>类别分布非常复杂、强非高斯、强非线性时</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">任务类型：回归</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>线性回归</td>
<td>需要连续值预测、希望建立可解释、可审计、可稳定复现的基线</td>
<td>目标与特征大致满足线性关系，或经过变换后接近线性</td>
<td>系数解释直观，便于分析“哪个因素把目标推高或拉低”</td>
<td>目标关系明显呈现复杂分段、交互或强非线性时</td>
</tr>
<tr>
<td>Lasso（L1 正则化）</td>
<td>特征很多、怀疑只有少数特征真正有效、希望模型自动做变量筛选</td>
<td>高维特征尤其适合；允许部分权重被压到 0</td>
<td>回归同时完成特征选择，适合构建更稀疏、更简洁的模型</td>
<td>大量强相关特征共同起作用时；它可能只保留其中部分特征</td>
</tr>
<tr>
<td>岭回归 / L2 正则化</td>
<td>特征共线性明显、担心普通线性回归权重不稳定</td>
<td>适合多个相关特征共同解释目标，而不希望稀疏淘汰其中一部分</td>
<td>通过收缩权重降低方差，使模型在相关特征场景下更稳定</td>
<td>首要目标是做特征筛选、希望很多系数直接变成 0 时</td>
</tr>
<tr>
<td>Elastic Net（L1 + L2 正则化）</td>
<td>既想做特征选择，又不希望在相关特征组上过于不稳定</td>
<td>高维、相关特征并存的回归任务最常见</td>
<td>综合 Lasso 与岭回归的优点，在稀疏性和稳定性之间折中</td>
<td>特征数量不多、模型目标非常简单时；其调参成本高于纯 L1 或 L2</td>
</tr>
<tr>
<td>决策树</td>
<td>目标值与输入关系近似分段函数，或业务逻辑天然围绕阈值展开</td>
<td>表格特征、混合类型特征都可；不需要严格线性假设</td>
<td>能学出“满足什么条件时预测值跳到哪个区间”的规则</td>
<td>希望预测曲线平滑、稳定，或希望泛化误差尽可能低时</td>
</tr>
<tr>
<td>随机森林</td>
<td>希望回归稳健、抗噪声、少调参，先拿到可靠效果</td>
<td>表格回归场景最常见；对特征尺度和局部异常较稳</td>
<td>综合多个树模型平均结果，通常比单树更不容易过拟合</td>
<td>要求外推能力强，或希望模型极度轻量、延迟极低时</td>
</tr>
<tr>
<td>梯度提升树（GBDT）</td>
<td>表格回归精度优先，目标和特征之间存在复杂非线性</td>
<td>适合异构表格特征；对异常值和长尾目标通常也较有韧性</td>
<td>在房价、评分、收益、风险等典型表格回归中常是强基线</td>
<td>算力非常紧、需要极低训练成本时</td>
</tr>
<tr>
<td>XGBoost / LightGBM</td>
<td>工业级表格回归、大规模特征、希望兼顾精度与工程效率</td>
<td>适合结构化数据；XGBoost 更稳健成熟，LightGBM 更强调速度与规模</td>
<td>常作为表格回归默认候选，能直接处理大量非线性和特征交互</td>
<td>数据关系本来就简单线性、并且强依赖系数解释时</td>
</tr>
<tr>
<td>K 近邻（KNN）</td>
<td>局部相似样本的目标值应当接近，希望用邻域平均直接预测</td>
<td>小数据、低维、有意义的距离度量是前提</td>
<td>局部平滑性强时实现简单有效，适合作为相似样本回归基线</td>
<td>高维、大样本、分布稀疏或在线延迟敏感时</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">任务类型：聚类</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>K-Means</td>
<td>需要快速把样本分成若干组，并且预期每组都围绕某个均值中心展开</td>
<td>簇大致是球形或凸形；欧氏距离有意义；需要先给定 <span displaypfx="inline-" class="mathjax-container">\(K\)</span></td>
<td>速度快、实现简单，适合作为无监督分群第一选择</td>
<td>簇形状复杂、密度差异大、离群点很多或无法预先估计簇数时</td>
</tr>
<tr>
<td>层次聚类</td>
<td>不仅想得到分组结果，还想知道各组是如何逐层合并成层级结构的</td>
<td>更适合中小规模数据；需要能接受 <span displaypfx="inline-" class="mathjax-container">\(O(N^2)\)</span> 级别距离矩阵成本</td>
<td>能输出树状图，适合做群体结构分析与多粒度解释</td>
<td>数据量很大、只关心最终聚类标签、不关心层级关系时</td>
</tr>
<tr>
<td>DBSCAN</td>
<td>希望识别任意形状簇，并把稀疏孤立点单独作为噪声剔除</td>
<td>密度尺度相对统一；距离度量有意义；参数 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 邻域半径和最小点数可估计</td>
<td>不需要预设簇数，能自然处理非凸簇和离群点</td>
<td>不同区域密度差异很大时；单一密度阈值难兼顾所有簇</td>
</tr>
<tr>
<td>HDBSCAN</td>
<td>簇的密度明显不一致，希望保留 DBSCAN 的密度思想但更自适应</td>
<td>数据存在多密度结构；仍需合理距离度量</td>
<td>比 DBSCAN 更能处理“有的簇很密、有的簇较松”的真实数据</td>
<td>只需要一个快速、简单、可复现的基础分群结果时</td>
</tr>
<tr>
<td>高斯混合模型（GMM）</td>
<td>希望得到软聚类结果，或者认为每个簇更像椭球形概率团块</td>
<td>连续特征较适合；默认每个簇可近似为一个高斯成分</td>
<td>不仅给簇标签，还给每个样本属于各簇的概率</td>
<td>簇形状非常复杂、非高斯、多峰结构难用少量高斯逼近时</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">任务类型：降维与可视化</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>主成分分析（PCA）</td>
<td>希望做线性降维、压缩冗余特征、去噪或为下游模型降成本</td>
<td>主要结构能由少数线性主方向解释；不依赖标签</td>
<td>保留最大方差方向，是最稳健、最常用的无监督降维起点</td>
<td>真正关心的是类别判别性而不是总体方差，或数据结构强非线性时</td>
</tr>
<tr>
<td>线性判别分析（LDA）</td>
<td>有标签并希望把不同类别拉开后再做分类或可视化</td>
<td>类别标签可靠；类内散度与类间散度都可稳定估计</td>
<td>直接围绕“可分性”找投影，比 PCA 更贴近分类目标</td>
<td>没有标签、类别边界强非线性、或类别数太少导致可降维空间有限时</td>
</tr>
<tr>
<td>t-SNE</td>
<td>想把高维嵌入压到二维或三维，只为看局部邻域是否形成簇</td>
<td>更适合可视化，不适合直接做可逆特征压缩</td>
<td>局部邻域展示能力强，适合分析表征是否把相似样本聚到一起</td>
<td>需要把降维结果直接送入生产模型，或需要保留严格全局距离关系时</td>
</tr>
<tr>
<td>UMAP</td>
<td>希望做更快的大规模可视化，兼顾局部结构与部分全局连通性</td>
<td>适合高维嵌入、文本向量、图表示等复杂表征</td>
<td>通常比 t-SNE 更快，也更容易保留大体流形结构</td>
<td>需要线性可解释主方向，或需要结果完全稳定可重复到坐标级别时</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">任务类型：异常检测</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>孤立森林（Isolation Forest）</td>
<td>通用表格异常检测，希望先得到一个稳健、扩展性好的异常分数</td>
<td>适合中大规模数据；不要求明确概率分布形式</td>
<td>通过随机切分隔离少数样本，通常是异常检测第一基线</td>
<td>异常定义依赖非常精细的局部密度差异时</td>
</tr>
<tr>
<td>局部异常因子（LOF）</td>
<td>异常并不是全局离群，而是“在本地邻域里显得稀疏”</td>
<td>距离度量必须合理；样本规模不宜过大</td>
<td>能发现那些整体位置不极端、但局部密度明显偏低的点</td>
<td>高维距离失真严重、或需要高吞吐在线检测时</td>
</tr>
<tr>
<td>单类支持向量机（One-Class SVM）</td>
<td>只有正常样本，目标是学习正常数据的封闭边界</td>
<td>特征需标准化；更适合中小规模；核方法对边界形状有帮助</td>
<td>适合“正常类定义清楚、异常类没有稳定样本”的场景</td>
<td>数据量很大、特征维度很高、参数难以稳定选择时</td>
</tr>
<tr>
<td>高斯混合模型（GMM）</td>
<td>希望把异常定义为低概率区域，并明确得到似然分数</td>
<td>连续数据更合适；分布能用若干高斯混合近似</td>
<td>异常判定有明确概率语义，适合风险评分和阈值分析</td>
<td>数据分布非常复杂、非高斯、或异常并不等价于低密度时</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">任务类型：序列标注与结构化预测</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>隐马尔可夫模型（HMM）</td>
<td>序列较短、转移规律明显、希望在较小数据和较强先验下完成基础序列建模</td>
<td>状态转移与观测发射假设大致成立；问题适合生成式描述</td>
<td>结构清晰、推断高效，适合作为经典序列建模入门和小规模基线</td>
<td>标签强依赖全局上下文、特征复杂、需要大量判别式特征时</td>
</tr>
<tr>
<td>条件随机场（CRF）</td>
<td>输出不是单点分类而是整条标签序列，并且相邻标签的合法性非常关键</td>
<td>一维链式序列最合适；上游特征或表示需要至少能提供较强局部证据</td>
<td>通过整体解码约束标签转移，使最终标签序列更一致、更符合任务结构</td>
<td>长距离语义主要由强表征模型决定、标签约束作用很弱，或任务更适合 span 建模时</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">任务类型：密度估计与概率建模</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">优先选择条件</td>
<td style="text-align: center;">数据与特征前提</td>
<td style="text-align: center;">最主要价值</td>
<td style="text-align: center;">不建议优先使用的情况</td>
</tr>
</thead>
<tbody>
<tr>
<td>朴素贝叶斯</td>
<td>希望快速得到后验概率分类器，并接受条件独立近似</td>
<td>高维稀疏离散特征最合适，如文本词频、词出现计数</td>
<td>概率形式直接、估计简单，适合快速建模和在线系统</td>
<td>特征依赖结构复杂、需要精细表达联合分布时</td>
</tr>
<tr>
<td>高斯混合模型（GMM）</td>
<td>希望显式建模连续数据分布，或需要软聚类与密度估计统一完成</td>
<td>连续数据、多峰分布、可近似为若干高斯成分</td>
<td>每个样本都能得到各成分责任概率，解释和阈值分析都较自然</td>
<td>分布极端复杂、重尾严重、或高斯成分数难合理确定时</td>
</tr>
<tr>
<td>隐马尔可夫模型（HMM）</td>
<td>不仅要建模观测分布，还要建模隐藏状态在时间上的转移机制</td>
<td>观测是序列，且状态依赖主要体现在相邻时刻</td>
<td>把“序列生成机制”和“时序转移规律”统一到一个概率模型里</td>
<td>长程依赖很强、局部马尔可夫假设明显不成立时</td>
</tr>
</tbody>
</table>
<p>若只是要一个工程上可执行的默认起点，可以进一步压缩成如下规则：表格分类与回归优先从随机森林、GBDT、XGBoost、LightGBM 和线性模型里选；文本高维稀疏分类优先看逻辑回归和朴素贝叶斯；无监督分群先看 K-Means，再根据簇形状和噪声情况转向 DBSCAN、HDBSCAN 或 GMM；需要可视化时先区分是要线性压缩还是只要展示结构，再在 PCA、LDA、t-SNE、UMAP 中选择；涉及标签序列依赖时再进入 HMM 与 CRF。</p>
<div class="blog_h2"><span class="graybg">线性模型</span></div>
<p>线性模型（Linear Models）的核心不是“世界一定是线性的”，而是先用一个可解释、可优化、常常足够强的基线去刻画输入与输出的关系。很多复杂模型也可以看作“在线性读出层之前先做更强的特征变换”。</p>
<div class="blog_h3"><span class="graybg">线性回归</span></div>
<p>线性回归（Linear Regression）在回归任务中建模“输入特征的加权求和如何产生连续输出”。它的价值在于：<span style="background-color: #c0c0c0;">可解释</span>（权重直接对应特征影响）与<span style="background-color: #c0c0c0;">可优化</span>（凸问题，训练稳定）。</p>
<div class="blog_h4"><span class="graybg">模型与符号</span></div>
<p>给定训练集 <span displaypfx="inline-" class="mathjax-container">\(\{(\mathbf{x}_i,y_i)\}_{i=1}^{N}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i\in\mathbb{R}^d\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本的特征向量， <span displaypfx="inline-" class="mathjax-container">\(y_i\in\mathbb{R}\)</span> 是对应标签。线性回归假设单样本预测为：</p>
<span displaypfx="" class="mathjax-container">\[\hat y_i=\mathbf{w}^\top \mathbf{x}_i+b\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\hat y_i\)</span>：对 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 的预测。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\in\mathbb{R}^d\)</span>：权重向量（每个维度对应一个特征）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(b\in\mathbb{R}\)</span>：偏置（Bias），用于整体平移。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top \mathbf{x}_i\)</span>：内积（Dot Product），表示“按特征加权求和”。</li>
</ul>
<p>把全部样本写成矩阵形式。令设计矩阵（Design Matrix）<span displaypfx="inline-" class="mathjax-container">\(\mathbf{X}\in\mathbb{R}^{N\times d}\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i^\top\)</span>，标签向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\in\mathbb{R}^{N}\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量为 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span>，全 1 向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}\in\mathbb{R}^{N}\)</span>。则：</p>
<span displaypfx="" class="mathjax-container">\[\hat{\mathbf{y}}=\mathbf{X}\mathbf{w}+b\mathbf{1}\]</span>
<p>直觉类比：它像“按因素打分再加总”。例如房价预测里，面积、地段评分、楼龄都可作为特征；权重正负决定影响方向，绝对值大小决定影响强弱。</p>
<div class="blog_h4"><span class="graybg">目标函数（最小二乘）</span></div>
<p>最常见的训练目标是最小化均方误差（Mean Squared Error, MSE）的总和（或平均）：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ J_{\text{EN}}(\mathbf{w},b)=\frac{1}{N}\|\mathbf{y}-\mathbf{X}\mathbf{w}-b\mathbf{1}\|_2^2+\lambda_1\|\mathbf{w}\|_1+\lambda_2\|\mathbf{w}\|_2^2\]</span>
<p>这个目标可以拆成三部分理解：第一项 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{N}\|\mathbf{y}-\mathbf{X}\mathbf{w}-b\mathbf{1}\|_2^2\)</span> 是数据拟合误差，其中 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}-\mathbf{X}\mathbf{w}-b\mathbf{1}\)</span> 是所有样本的残差向量；第二项 <span displaypfx="inline-" class="mathjax-container">\(\lambda_1\|\mathbf{w}\|_1\)</span> 倾向把一部分权重直接压到 0；第三项 <span displaypfx="inline-" class="mathjax-container">\(\lambda_2\|\mathbf{w}\|_2^2\)</span> 倾向把权重整体缩小得更平滑。这里 <span displaypfx="inline-" class="mathjax-container">\(N\)</span> 是样本数， <span displaypfx="inline-" class="mathjax-container">\(\lambda_1,\lambda_2\)</span> 是正则化强度。若两者都取 0，就退化为普通最小二乘。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(J(\mathbf{w},b)\)</span>：目标函数（Objective）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\|\cdot\|_2\)</span>：二范数（Euclidean Norm），向量的长度。</li>
</ul>
<p>平方项会对大残差施加更大惩罚，因此训练会优先修正“偏得很离谱”的样本。并且在高斯噪声假设下，最小二乘等价于最大似然估计（Maximum Likelihood Estimation, MLE）导出的解。</p>
<div class="blog_h4"><span class="graybg">解析解（正规方程）</span></div>
<p>把偏置吸收到特征中更方便：定义增广特征 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{x}}_i=[\mathbf{x}_i;1]\in\mathbb{R}^{d+1}\)</span>，增广参数 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{w}}=[\mathbf{w};b]\in\mathbb{R}^{d+1}\)</span>，增广矩阵 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{X}}=[\mathbf{X},\mathbf{1}]\in\mathbb{R}^{N\times(d+1)}\)</span>。则 <span displaypfx="inline-" class="mathjax-container">\(\hat{\mathbf{y}}=\tilde{\mathbf{X}}\tilde{\mathbf{w}}\)</span>，目标为 <span displaypfx="inline-" class="mathjax-container">\(\min_{\tilde{\mathbf{w}}}\|\mathbf{y}-\tilde{\mathbf{X}}\tilde{\mathbf{w}}\|_2^2\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{X}}^\top\tilde{\mathbf{X}}\)</span> 可逆，则正规方程（Normal Equation）给出闭式解：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\mathbf{w}}^*=\left(\tilde{\mathbf{X}}^\top\tilde{\mathbf{X}}\right)^{-1}\tilde{\mathbf{X}}^\top\mathbf{y}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{w}}^*\)</span> 表示最优增广参数，前 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 维对应原权重 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span>，最后一维对应偏置 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>。 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{X}}^\top\tilde{\mathbf{X}}\)</span> 汇总了特征之间的相关结构， <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{X}}^\top\mathbf{y}\)</span> 汇总了特征与标签之间的相关程度，因此这个闭式解本质上是在“用整体相关关系一次性解出最优线性系数”。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{X}}^\top\tilde{\mathbf{X}}\in\mathbb{R}^{(d+1)\times(d+1)}\)</span>：Gram 矩阵，度量特征之间的相关性。</li>
<li><span displaypfx="inline-" class="mathjax-container">\((\cdot)^{-1}\)</span>：矩阵逆；不可逆时通常用伪逆（Pseudo-inverse）或加正则化处理。</li>
</ul>
<p>工程实现中，直接求逆并不推荐；更稳定的做法是解线性方程组或用 QR/SVD 分解。</p>
<div class="blog_h4"><span class="graybg">实例：把公式算一遍</span></div>
<p>用一维数据拟合 <span displaypfx="inline-" class="mathjax-container">\(y\approx wx+b\)</span>。给定三点 <span displaypfx="inline-" class="mathjax-container">\((x,y)\in\{(0,1),(1,3),(2,5)\}\)</span>。构造增广矩阵与标签：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\mathbf{X}}=\begin{bmatrix}0 &amp; 1\\ 1 &amp; 1\\ 2 &amp; 1\end{bmatrix},\quad \mathbf{y}=\begin{bmatrix}1\\ 3\\ 5\end{bmatrix}\]</span>
<p>这个增广矩阵的每一行对应一个样本；第一列是原始特征 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，第二列固定为 1，用来把偏置 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 并入矩阵乘法。标签向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span> 则把三个样本的真实输出按顺序堆叠起来。</p>
<p>计算：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\mathbf{X}}^\top\tilde{\mathbf{X}}=\begin{bmatrix}5 &amp; 3\\ 3 &amp; 3\end{bmatrix},\quad \tilde{\mathbf{X}}^\top\mathbf{y}=\begin{bmatrix}13\\ 9\end{bmatrix}\]</span>
<p>左边的矩阵可以看成所有样本在“特征与偏置”两个方向上的二阶统计量： <span displaypfx="inline-" class="mathjax-container">\(5\)</span> 来自 <span displaypfx="inline-" class="mathjax-container">\(0^2+1^2+2^2\)</span>， <span displaypfx="inline-" class="mathjax-container">\(3\)</span> 来自 <span displaypfx="inline-" class="mathjax-container">\(0+1+2\)</span>，右边向量 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\mathbf{X}}^\top\mathbf{y}\)</span> 则分别对应 <span displaypfx="inline-" class="mathjax-container">\(\sum_i x_i y_i\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\sum_i y_i\)</span>。这正是正规方程所需要的汇总量。</p>
<p>解线性方程 <span displaypfx="inline-" class="mathjax-container">\(\left(\tilde{\mathbf{X}}^\top\tilde{\mathbf{X}}\right)\tilde{\mathbf{w}}=\tilde{\mathbf{X}}^\top\mathbf{y}\)</span>，即：</p>
<span displaypfx="" class="mathjax-container">\[\begin{cases}5w+3b=13\\ 3w+3b=9\end{cases}\Rightarrow w=2,\ b=1\]</span>
<p>这组方程的未知量只有两个：斜率 <span displaypfx="inline-" class="mathjax-container">\(w\)</span> 和偏置 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>。解出 <span displaypfx="inline-" class="mathjax-container">\(w=2\)</span> 表示特征每增加 1，预测值增加 2；解出 <span displaypfx="inline-" class="mathjax-container">\(b=1\)</span> 表示当 <span displaypfx="inline-" class="mathjax-container">\(x=0\)</span> 时，模型基线输出为 1。</p>
<p>因此预测为 <span displaypfx="inline-" class="mathjax-container">\(\hat y=2x+1\)</span>，恰好穿过三点。这个例子展示了：正规方程把“最小化平方误差”的优化问题，转换成一个线性方程组。</p>
<div class="blog_h4"><span class="graybg">适用场景</span></div>
<ul>
<li>需要可解释的特征贡献（权重）与可校验的线性关系。</li>
<li>表格数据（Tabular）中，关系接近线性或可通过特征变换/交互项线性化。</li>
<li>作为强基线：先用线性模型定位数据问题、特征质量与噪声水平，再决定是否需要更复杂模型。</li>
<li>高维稀疏特征（如 one-hot、文本 bag-of-words）下，配合正则化可获得稳定解。</li>
</ul>
<p>当特征很多、共线性（Collinearity）强或数据量相对不足时，普通最小二乘（Ordinary Least Squares, OLS）会出现高方差：训练集拟合很好、验证集误差上升。正则化（Regularization）通过惩罚参数规模，把“拟合训练误差”与“控制模型复杂度”写进同一个目标函数。</p>
<div class="blog_h3"><span class="graybg">Lasso（L1 正则化）</span></div>
<p>Lasso 把参数惩罚写成一范数（<span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> Norm）：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ J_{\text{lasso}}(\mathbf{w},b)=\frac{1}{N}\left\|\mathbf{y}-\mathbf{X}\mathbf{w}-b\mathbf{1}\right\|_2^2+\lambda\|\mathbf{w}\|_1\]</span>
<p>这个式子里，前半部分仍然是拟合误差，衡量预测和真实标签之间差多少；后半部分 <span displaypfx="inline-" class="mathjax-container">\(\lambda\|\mathbf{w}\|_1\)</span> 是稀疏惩罚，其中 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|_1=\sum_j |w_j|\)</span> 会优先把不重要的权重压成 0。于是 Lasso 不只是“把权重变小”，而是经常顺带完成特征选择。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda\ge 0\)</span>：正则化强度（Regularization Strength）。越大表示越强的收缩。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|_1=\sum_{j=1}^{d}|w_j|\)</span>：一范数，鼓励稀疏（Sparsity）。</li>
<li>通常不惩罚偏置 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>；否则会把整体均值也一起往 0 拉。</li>
<li>Lasso 由于 <span displaypfx="inline-" class="mathjax-container">\(|\cdot|\)</span> 在 0 点不可导，一般没有像岭回归那样简洁的闭式解；常用坐标下降（Coordinate Descent）、近端梯度（Proximal Gradient）或 LARS 等算法求解。</li>
</ul>
<p>Lasso 的关键作用不是均匀缩小全部权重，而是让一部分权重被压到精确 0。它更像给参数设置了一个阈值：弱相关、贡献不足以抵消惩罚的特征，会被直接剔除。因此 Lasso 同时完成复杂度控制与特征选择（Feature Selection）。</p>
<p>几何上，在相同训练误差轮廓线下， <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 约束对应菱形/正八面体；这些尖角更容易与最优点相交在坐标轴上，因此常出现某些 <span displaypfx="inline-" class="mathjax-container">\(w_j=0\)</span> 的解。</p>
<ul>
<li>适合高维稀疏特征、希望自动筛特征的场景，例如广告、推荐、文本 one-hot 特征。</li>
<li>当特征高度相关时，Lasso 往往只保留其中少数几个，因此解的稳定性通常弱于岭回归。</li>
</ul>
<div class="blog_h3"><span class="graybg">岭回归 / L2 正则化</span></div>
<p>岭回归把参数惩罚写成二范数平方（<span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> Norm Squared）：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ J_{\text{ridge}}(\mathbf{w},b)=\frac{1}{N}\left\|\mathbf{y}-\mathbf{X}\mathbf{w}-b\mathbf{1}\right\|_2^2+\lambda\|\mathbf{w}\|_2^2\]</span>
<p>岭回归的结构与普通线性回归相同，只是在误差项之外额外加入了 <span displaypfx="inline-" class="mathjax-container">\(\lambda\|\mathbf{w}\|_2^2\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|_2^2=\sum_j w_j^2\)</span> 会连续惩罚过大的权重，因此它更擅长缓解共线性和过拟合，而不是像 Lasso 那样主动做稀疏选择。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda\ge 0\)</span>：正则化强度。越大表示越强的收缩。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|_2^2=\sum_{j=1}^{d}w_j^2\)</span>：二范数平方，连续惩罚大权重。</li>
<li>通常不惩罚偏置 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>；否则会把整体均值也一起往 0 拉。</li>
</ul>
<p>从梯度角度看，惩罚项 <span displaypfx="inline-" class="mathjax-container">\(\lambda\|\mathbf{w}\|_2^2\)</span> 对单个参数 <span displaypfx="inline-" class="mathjax-container">\(w_j\)</span> 的梯度贡献是 <span displaypfx="inline-" class="mathjax-container">\(2\lambda w_j\)</span>。这就是 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 正则化在深度学习中被称为权重衰减（Weight Decay）的原因：它持续把权重按比例拉回 0。若取 <span displaypfx="inline-" class="mathjax-container">\(\lambda=1\)</span>，当 <span displaypfx="inline-" class="mathjax-container">\(w_j=10\)</span> 时，梯度为 <span displaypfx="inline-" class="mathjax-container">\(20\)</span>；优化器会施加强烈收缩，把这个大权重猛拉回去。当 <span displaypfx="inline-" class="mathjax-container">\(w_j=0.1\)</span> 时，梯度只有 <span displaypfx="inline-" class="mathjax-container">\(0.2\)</span>；往 0 拉的力量就很弱。也就是说，参数一旦变大， <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 惩罚立刻增强；参数已经很小时，它几乎不再干预。</p>
<p>岭回归仍是凸二次问题，并有闭式解（忽略/已中心化处理 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 的情况）：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{w}^*=\left(\mathbf{X}^\top\mathbf{X}+N\lambda\mathbf{I}\right)^{-1}\mathbf{X}^\top\mathbf{y}\]</span>
<p>与普通最小二乘相比，这里多出来的 <span displaypfx="inline-" class="mathjax-container">\(N\lambda\mathbf{I}\)</span> 是沿对角线加入的一项稳定化修正。 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{I}\)</span> 是单位矩阵，它不会改变特征之间的相对结构，但会让矩阵更容易求逆，因此在特征高度相关时更稳定。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{I}\in\mathbb{R}^{d\times d}\)</span>：单位矩阵（Identity Matrix）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{X}^\top\mathbf{X}+N\lambda\mathbf{I}\)</span>：对角线上加了 <span displaypfx="inline-" class="mathjax-container">\(N\lambda\)</span> 的稳定项，缓解共线性导致的病态（Ill-conditioning）。</li>
</ul>
<p>用一个最小可算的例子说明 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 如何改变解。考虑无偏置的一维回归 <span displaypfx="inline-" class="mathjax-container">\(\hat y=wx\)</span>，目标为：</p>
<span displaypfx="" class="mathjax-container">\[J(w)=\sum_{i=1}^{N}(y_i-wx_i)^2+\lambda w^2\]</span>
<p>这是单变量岭回归的简化形式。前一项把所有样本的平方误差加总，后一项 <span displaypfx="inline-" class="mathjax-container">\(\lambda w^2\)</span> 惩罚过大的斜率。它清楚展示了岭回归的核心：既要求拟合数据，又限制参数不要长得太大。</p>
<p>对 <span displaypfx="inline-" class="mathjax-container">\(w\)</span> 求导并令其为 0 得：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\mathrm{d}J}{\mathrm{d}w}=-2\sum_{i=1}^{N}x_i(y_i-wx_i)+2\lambda w=0\Rightarrow w^*=\frac{\sum_{i=1}^{N}x_i y_i}{\sum_{i=1}^{N}x_i^2+\lambda}\]</span>
<p>这个结果说明岭回归解和普通最小二乘解非常接近，只是分母里多了一个 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span>。它会把估计值往 0 方向收缩： <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 越大，分母越大，得到的 <span displaypfx="inline-" class="mathjax-container">\(w^*\)</span> 就越保守。</p>
<p>可见 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 直接进入分母，把 <span displaypfx="inline-" class="mathjax-container">\(w^*\)</span> 持续拉向 0。岭回归不会像 Lasso 那样大量制造精确 0，而是把所有权重更平滑地压小，因此更像“把所有旋钮都往小一点拧”，让模型整体更保守、更稳健。</p>
<ul>
<li>适合特征高度相关、希望保留全部特征但降低方差的场景；常见于经济学、医学和一般表格数据。</li>
<li>当既希望稀疏，又希望在强相关特征间保持稳定时，Elastic Net（<span displaypfx="inline-" class="mathjax-container">\(L_1+L_2\)</span>）通常比纯 Lasso 更稳。</li>
</ul>
<div class="blog_h3"><span class="graybg">Elastic Net（L1 + L2 正则化）</span></div>
<p>Elastic Net 把 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 惩罚合并到同一个目标中：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ J_{\text{EN}}(\mathbf{w},b)=\frac{1}{N}\|\mathbf{y}-\mathbf{X}\mathbf{w}-b\mathbf{1}\|_2^2+\lambda_1\|\mathbf{w}\|_1+\lambda_2\|\mathbf{w}\|_2^2\]</span>
<p>它同时保留两类效应： <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 项负责产生稀疏性（Sparsity），把一部分弱特征压到 0； <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 项负责平滑收缩，在特征高度相关时提高解的稳定性。因此 Elastic Net 通常用于“既希望自动做特征选择，又不希望在强相关特征之间选得过于激进”的场景。</p>
<ul>
<li>当 <span displaypfx="inline-" class="mathjax-container">\(\lambda_2=0\)</span> 时，退化为 Lasso。</li>
<li>当 <span displaypfx="inline-" class="mathjax-container">\(\lambda_1=0\)</span> 时，退化为岭回归（Ridge Regression）。</li>
<li>当特征高度相关且维度很高时，Elastic Net 往往比纯 Lasso 更稳，也比纯岭回归更稀疏。</li>
</ul>
<div class="blog_h3"><span class="graybg">逻辑回归</span></div>
<p>逻辑回归（Logistic Regression）是二分类的标准基线：它先用线性函数产生打分，再把该打分通过 sigmoid（Logistic Function）映射到 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span>，从而得到条件概率模型。</p>
<div class="blog_h4"><span class="graybg">模型、概率与 logit</span></div>
<p>对二分类，令标签随机变量（Random Variable）<span displaypfx="inline-" class="mathjax-container">\(Y\in\{0,1\}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(1\)</span> 表示正类， <span displaypfx="inline-" class="mathjax-container">\(0\)</span> 表示负类。给定输入 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 后，先定义线性打分：</p>
<span displaypfx="" class="mathjax-container">\[z=\mathbf{w}^\top\mathbf{x}+b\]</span>
<p>再定义 sigmoid 函数：</p>
<span displaypfx="" class="mathjax-container">\[\sigma(z)=\frac{1}{1+e^{-z}}\]</span>
<p>于是，在给定输入 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 的条件下，标签取正类的条件概率写成：</p>
<span displaypfx="" class="mathjax-container">\[p(Y=1\mid \mathbf{x})=\sigma(z)=\sigma(\mathbf{w}^\top\mathbf{x}+b)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mid\)</span> 表示“在给定……条件下”；左侧 <span displaypfx="inline-" class="mathjax-container">\(Y=1\)</span> 表示事件“标签随机变量取值为正类 1”。因此这个式子表示：在输入 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 已知时，事件 <span displaypfx="inline-" class="mathjax-container">\(Y=1\)</span> 发生的概率。</p>
<p>相应地，负类概率为：</p>
<span displaypfx="" class="mathjax-container">\[p(Y=0\mid \mathbf{x})=1-\sigma(z)\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\in\mathbb{R}^d\)</span>：特征向量。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\in\mathbb{R}^d\)</span>：权重向量（Weight Vector）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(b\in\mathbb{R}\)</span>：偏置（Bias）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(z\in\mathbb{R}\)</span>：线性打分；它也是 logit（对数几率，log-odds）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sigma(\cdot)\)</span>：把任意实数映射到 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span> 的非线性函数。</li>
</ul>
<p>logit 的含义来自恒等式：</p>
<span displaypfx="" class="mathjax-container">\[\log\frac{p(Y=1\mid \mathbf{x})}{1-p(Y=1\mid \mathbf{x})}=z=\mathbf{w}^\top\mathbf{x}+b\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\frac{p(Y=1\mid \mathbf{x})}{1-p(Y=1\mid \mathbf{x})}\)</span> 称为几率（Odds），表示“正类概率与负类概率之比”；再对它取对数，就得到 logit（对数几率，log-odds）：<span displaypfx="inline-" class="mathjax-container">\(\log\frac{p}{1-p}\)</span>。因此上式的含义不是再引入一个无关的新量，而是说明：逻辑回归把<span style="background-color: #c0c0c0;">正类概率的对数几率</span>建模为输入特征的线性函数。</p>
<p>换言之，在二分类逻辑回归里， <span displaypfx="inline-" class="mathjax-container">\(z=\mathbf{w}^\top\mathbf{x}+b\)</span> 就是线性部分的原始输出值，而这个原始输出值恰好等于 logit。它本身不是概率，可以取任意实数；经过 sigmoid 之后才变成 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span> 内的概率。多分类情形中，softmax 之前那一组线性输出通常统称为 logits。</p>
<p>因此 <span displaypfx="inline-" class="mathjax-container">\(w_j\)</span> 可以被解释为：特征 <span displaypfx="inline-" class="mathjax-container">\(x_j\)</span> 增加一个单位，会把对数几率增加 <span displaypfx="inline-" class="mathjax-container">\(w_j\)</span>（在其他特征不变时）。这就是逻辑回归的核心可解释性。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/logistic.jpg"><img class="alignnone size-full wp-image-41171" src="https://blog.gmem.cc/wp-content/uploads/2026/03/logistic.jpg" alt="logistic" width="1216" height="874" /></a></p>
<div class="blog_h4"><span class="graybg">训练目标（NLL / Cross-Entropy）</span></div>
<p>给定训练集 <span displaypfx="inline-" class="mathjax-container">\(\{(\mathbf{x}_i,y_i)\}_{i=1}^{N}\)</span>，记 <span displaypfx="inline-" class="mathjax-container">\(p_i=\sigma(\mathbf{w}^\top\mathbf{x}_i+b)\)</span>。逻辑回归通过最大化似然（Likelihood）训练；等价地，它最小化负对数似然（Negative Log-Likelihood, NLL），也即二分类交叉熵（Binary Cross-Entropy）：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ L(\mathbf{w},b)=-\sum_{i=1}^{N}\left(y_i\log p_i+(1-y_i)\log(1-p_i)\right)\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\log p_i\)</span>：当 <span displaypfx="inline-" class="mathjax-container">\(y_i=1\)</span> 时，鼓励 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span> 变大。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\log(1-p_i)\)</span>：当 <span displaypfx="inline-" class="mathjax-container">\(y_i=0\)</span> 时，鼓励 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span> 变小。</li>
</ul>
<p>加入 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 正则化时，常见形式为 <span displaypfx="inline-" class="mathjax-container">\(L(\mathbf{w},b)+\lambda\|\mathbf{w}\|_2^2\)</span>（通常不惩罚 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>）。该目标是凸的，因此不存在“坏局部最优”的训练不稳定问题。</p>
<div class="blog_h4"><span class="graybg">梯度：公式如何驱动参数更新</span></div>
<p>把样本堆叠成矩阵 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{X}\)</span>、标签向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{y}\)</span>，预测概率向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{p}\)</span>（第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量为 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span>）。则无正则项时梯度为：</p>
<span displaypfx="" class="mathjax-container">\[\nabla_{\mathbf{w}}L=\mathbf{X}^\top(\mathbf{p}-\mathbf{y}),\quad \frac{\partial L}{\partial b}=\mathbf{1}^\top(\mathbf{p}-\mathbf{y})\]</span>
<p>这两个梯度式子都围绕同一个误差向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{p}-\mathbf{y}\)</span> 展开：它表示“模型给出的正类概率”和“真实标签”之间的偏差。 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{X}^\top(\mathbf{p}-\mathbf{y})\)</span> 表示把这种偏差按特征方向汇总起来，从而告诉每个权重该往哪个方向改； <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}^\top(\mathbf{p}-\mathbf{y})\)</span> 则把所有偏差直接相加，用来更新偏置。</p>
<p>这两个式子揭示了训练机制：如果某个样本真实标签是 1 但模型给出小概率（<span displaypfx="inline-" class="mathjax-container">\(p_i-y_i&lt;0\)</span>），则梯度会推动 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 朝着增加该样本 logit 的方向更新；反之亦然。</p>
<div class="blog_h4"><span class="graybg">实例：单样本算概率、算损失、算一次梯度</span></div>
<p>考虑一维特征 <span displaypfx="inline-" class="mathjax-container">\(x=2\)</span>，参数 <span displaypfx="inline-" class="mathjax-container">\(w=1,b=-1\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(z=wx+b=1\)</span>，概率：</p>
<span displaypfx="" class="mathjax-container">\[p=\sigma(1)=\frac{1}{1+e^{-1}}\approx 0.731\]</span>
<p>若真实标签 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span>，该样本的负对数似然为：</p>
<span displaypfx="" class="mathjax-container">\[\ell=-\log p\approx 0.313\]</span>
<p>该样本对 <span displaypfx="inline-" class="mathjax-container">\(w\)</span> 的梯度为（单样本形式）：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \ell}{\partial w}=(p-y)x=(0.731-1)\cdot 2\approx -0.538\]</span>
<p>用学习率 <span displaypfx="inline-" class="mathjax-container">\(\eta\)</span> 做一次梯度下降： <span displaypfx="inline-" class="mathjax-container">\(w\leftarrow w-\eta\frac{\partial\ell}{\partial w}\)</span>。由于梯度为负，更新会把 <span displaypfx="inline-" class="mathjax-container">\(w\)</span> 增大，从而增大 <span displaypfx="inline-" class="mathjax-container">\(z\)</span>、提升 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>，使模型更倾向把该样本判为正类。</p>
<div class="blog_h4"><span class="graybg">适用场景</span></div>
<ul>
<li>二分类且需要概率输出/可校准阈值（例如欺诈检测、流失预测、医学风险评分）。</li>
<li>高维稀疏特征（如 one-hot、文本 bag-of-words），逻辑回归常是强基线。</li>
<li>对可解释性、训练稳定性要求高的工程场景（可用权重做审计/特征诊断）。</li>
<li>当决策边界高度非线性且特征工程不足时，需要树模型或神经网络补上非线性。</li>
</ul>
<div class="blog_h3"><span class="graybg">支持向量机（SVM）</span></div>
<p>支持向量机（Support Vector Machine, SVM）不是“随便找一条能分开两类样本的直线/超平面”，而是要找那条对两类样本都留出最大安全缓冲区的边界。这个缓冲区叫间隔（Margin）：边界离两类样本都越远，模型对噪声、标注扰动与局部数据波动通常越稳。</p>
<p>从数学形式看，SVM 最终会落到二次规划（Quadratic Programming, QP）。所谓二次规划，就是：<span style="background-color: #c0c0c0;">目标函数是变量的二次函数，约束是线性等式或线性不等式</span>。SVM 的目标是最小化 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\|\mathbf{w}\|_2^2\)</span>，约束则是每个样本都必须被放到正确一侧并留出至少 1 的函数间隔，因此它正好属于这一类优化问题。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/svm.jpg"><img class="alignnone size-full wp-image-41179" src="https://blog.gmem.cc/wp-content/uploads/2026/03/svm.jpg" alt="svm" width="1408" height="768" /></a></p>
<p>从结构上看，SVM 把三件事连在一起：</p>
<ol>
<li>几何：先定义“什么叫分得开”，以及“什么叫分得最稳”。</li>
<li>优化：把“最稳的边界”写成一个可求解的凸二次规划。</li>
<li>对偶：把问题改写成只依赖样本内积的形式，从而自然导出核技巧（Kernel Trick）。</li>
</ol>
<div class="blog_h4"><span class="graybg">从几何直觉到“最大间隔”</span></div>
<p>对二分类任务，设标签 <span displaypfx="inline-" class="mathjax-container">\(y_i\in\{-1,+1\}\)</span>。线性分类器先计算一个打分函数（Score Function）：</p>
<span displaypfx="" class="mathjax-container">\[f(\mathbf{x})=\mathbf{w}^\top\mathbf{x}+b\]</span>
<p>这里最好先把“函数写法”和“几何边界写法”区分清楚。像 <span displaypfx="inline-" class="mathjax-container">\(y=x\)</span> 这样的形式，强调的是“给定自变量（Independent Variable） <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，应变量（Dependent Variable） <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 如何变化”；但对分类器来说，更关键的问题不是“谁依赖谁”，而是<span style="background-color: #c0c0c0;">哪些点恰好落在边界上，以及点位于边界哪一侧</span>。因此同一条直线在几何里通常改写成隐式形式（Implicit Form） <span displaypfx="inline-" class="mathjax-container">\(x-y=0\)</span>。</p>
<p>一旦写成 <span displaypfx="inline-" class="mathjax-container">\(x-y=0\)</span>，就能直接看成二维超平面方程 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=0\)</span>：令 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}=(x,y)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}=(1,-1)^\top\)</span>、<span displaypfx="inline-" class="mathjax-container">\(b=0\)</span>，便有 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=x-y\)</span>。此时直线方向向量（Direction Vector）可以取 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{v}=(1,1)^\top\)</span>，因为沿这条线移动时 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 同时增加；而 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{v}=1\cdot 1+(-1)\cdot 1=0\)</span>，说明 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 与直线切向方向正交，所以它正是这条直线的法向量（Normal Vector）。更一般地，对任意边界 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=0\)</span>，系数向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 都垂直于边界，因此天然决定“正侧、负侧”和距离的度量方向。</p>
<p>再用它的符号做判别：</p>
<span displaypfx="" class="mathjax-container">\[\hat y=\mathrm{sign}(f(\mathbf{x}))=\mathrm{sign}(\mathbf{w}^\top\mathbf{x}+b)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 是超平面的法向量（Normal Vector）。沿 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 方向移动， <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})\)</span> 会增大；沿反方向移动， <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})\)</span> 会减小。因此：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})=0\)</span>：点就在分类超平面上。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})&gt;0\)</span>：点落在法向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 指向的那一侧。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x})&lt;0\)</span>：点落在另一侧。</li>
</ul>
<p>超平面把空间分成正半空间（Positive Half-space）与负半空间（Negative Half-space）；分数的正负号直接给出样本位于哪一侧。</p>
<p>令 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}=\{\mathbf{x}:\mathbf{w}^\top\mathbf{x}+b=0\}\)</span> 表示超平面，令单位法向量（Unit Normal Vector）为 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{n}=\mathbf{w}/\|\mathbf{w}\|_2\)</span>。对样本 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i\)</span>，记 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_{\Pi,i}\)</span> 为它在超平面 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}\)</span> 上的正交投影点（Orthogonal Projection），因此 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}_{\Pi,i}+b=0\)</span>，且 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i-\mathbf{x}_{\Pi,i}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 平行。点 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i\)</span> 到超平面的带符号距离（Signed Distance）定义为该位移在单位法向量方向上的投影长度：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{dist}_{\pm}(\mathbf{x}_i,\mathcal{H})=\mathbf{n}^\top(\mathbf{x}_i-\mathbf{x}_{\Pi,i})=\frac{\mathbf{w}^\top\mathbf{x}_i+b}{\|\mathbf{w}\|_2}\]</span>
<p>分子 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}_i+b\)</span> 衡量点在法向量方向上偏离超平面的代数量；分母 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|_2\)</span> 对法向量做归一化，去掉“同一个超平面可写成不同倍数方程”的尺度影响；符号保留样本位于超平面哪一侧的信息。</p>
<p>带符号距离区分了超平面的两侧，但正类样本与负类样本的正确侧相反。将标签 <span displaypfx="inline-" class="mathjax-container">\(y_i\in\{-1,+1\}\)</span> 乘入后，得到几何间隔（Geometric Margin）：</p>
<span displaypfx="" class="mathjax-container">\[\gamma_i=\frac{y_i(\mathbf{w}^\top\mathbf{x}_i+b)}{\|\mathbf{w}\|_2}\]</span>
<p>几何间隔是<span style="background-color: #c0c0c0;">用标签修正后的带符号距离</span>。因此：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\gamma_i&gt;0\)</span>：样本在正确一侧，被正确分类。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\gamma_i=0\)</span>：样本正好压在边界上。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\gamma_i&lt;0\)</span>：样本落到错误一侧，被误分类。</li>
</ul>
<p>SVM 的目标是让所有训练样本中最小的那个 <span displaypfx="inline-" class="mathjax-container">\(\gamma_i\)</span> 尽可能大，即最大化“最坏样本到边界的正确方向距离”：</p>
<span displaypfx="" class="mathjax-container">\[\gamma=\min_i \gamma_i\]</span>
<p>这就是最大间隔（Maximum Margin）的含义：不是让“平均样本”离边界远，而是让最危险、最靠近边界的样本也尽量安全。</p>
<div class="blog_h4"><span class="graybg">硬间隔 SVM（Hard-margin SVM）原始问题</span></div>
<p>先看线性可分（Linearly Separable）的情形。所谓线性可分，就是存在某个 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{w},b)\)</span>，使每个样本都在与自己标签一致的一侧。这件事可以统一写成：</p>
<span displaypfx="" class="mathjax-container">\[y_i(\mathbf{w}^\top\mathbf{x}_i+b)&gt;0,\quad \forall i\]</span>
<p>为什么这里是 <span displaypfx="inline-" class="mathjax-container">\(&gt;0\)</span>？因为它等价于“符号一致”：当 <span displaypfx="inline-" class="mathjax-container">\(y_i=+1\)</span> 时，要求 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}_i+b&gt;0\)</span>，即正类样本必须落在正半空间；当 <span displaypfx="inline-" class="mathjax-container">\(y_i=-1\)</span> 时，要求 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}_i+b&lt;0\)</span>，即负类样本必须落在负半空间。若只等于 0，则样本恰好压在分类边界上，不属于严格可分，因为此时它没有任何安全间隔，符号判别也处在临界点。</p>
<p>不过， <span displaypfx="inline-" class="mathjax-container">\(y_i(\mathbf{w}^\top\mathbf{x}_i+b)\)</span> 还不是几何距离，因为把 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{w},b)\)</span> 同时乘以任意正常数 <span displaypfx="inline-" class="mathjax-container">\(t&gt;0\)</span>，超平面 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=0\)</span> 完全不变，但这个量会整体乘上 <span displaypfx="inline-" class="mathjax-container">\(t\)</span>。因此它只是一个未归一化的间隔量，通常称为函数间隔（Functional Margin）。</p>
<p>为了消掉这个缩放自由度，SVM 采用一个标准定标：强制所有样本的最小函数间隔等于 1，也就是要求</p>
<span displaypfx="" class="mathjax-container">\[y_i(\mathbf{w}^\top\mathbf{x}_i+b)\ge 1,\quad \forall i\]</span>
<p>这条约束把两类样本同时编码进来：当 <span displaypfx="inline-" class="mathjax-container">\(y_i=+1\)</span> 时，它变成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}_i+b\ge 1\)</span>；当 <span displaypfx="inline-" class="mathjax-container">\(y_i=-1\)</span> 时，它变成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}_i+b\le -1\)</span>。于是分类边界两侧又出现两条平行的“间隔边界”（Margin Boundaries）：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{w}^\top\mathbf{x}+b=+1,\quad \mathbf{w}^\top\mathbf{x}+b=-1\]</span>
<p>因为平行超平面 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=c_1\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top\mathbf{x}+b=c_2\)</span> 之间的距离是 <span displaypfx="inline-" class="mathjax-container">\(|c_1-c_2|/\|\mathbf{w}\|_2\)</span>，所以这两条间隔边界之间的宽度为</p>
<span displaypfx="" class="mathjax-container">\[\frac{|(+1)-(-1)|}{\|\mathbf{w}\|_2}=\frac{2}{\|\mathbf{w}\|_2}\]</span>
<p>在这个定标下，离分类边界最近的样本几何间隔恰好是</p>
<span displaypfx="" class="mathjax-container">\[\gamma=\min_i \frac{y_i(\mathbf{w}^\top\mathbf{x}_i+b)}{\|\mathbf{w}\|_2}=\frac{1}{\|\mathbf{w}\|_2}\]</span>
<p>因此，最大化几何间隔就等价于最小化 <span displaypfx="inline-" class="mathjax-container">\(\|\mathbf{w}\|_2\)</span>；为了得到标准的凸二次目标，通常写成：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ \frac{1}{2}\|\mathbf{w}\|_2^2\quad \text{s.t.}\quad y_i(\mathbf{w}^\top \mathbf{x}_i+b)\ge 1\]</span>
<p>这就是硬间隔 SVM 的原始问题（Primal Problem）。现在“二次规划”这个术语也具体了：目标函数 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\|\mathbf{w}\|_2^2\)</span> 是关于参数的凸二次函数，而约束 <span displaypfx="inline-" class="mathjax-container">\(y_i(\mathbf{w}^\top \mathbf{x}_i+b)\ge 1\)</span> 对 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{w},b)\)</span> 是线性的，所以这是一个凸二次规划，并且可以求到全局最优解。</p>
<div class="blog_h4"><span class="graybg">拉格朗日函数与 KKT：为什么只剩少数“支持向量”</span></div>
<p>这一段只回答一个问题：训练集中明明有很多样本，为什么最后真正决定分类边界的，往往只有少数几个点。这个结论的严格来源是 KKT 条件，但它的直观含义并不抽象：<span style="background-color: #c0c0c0;">只有那些真正把最大间隔边界“卡住”的样本，才会在最优解里留下非零权重</span>。</p>
<p>硬间隔 SVM 的原始问题是：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\boldsymbol{w},b}\frac{1}{2}\|\boldsymbol{w}\|_2^2\quad \text{s.t.}\quad y_i(\boldsymbol{w}^\top \boldsymbol{x}_i+b)\ge 1,\ \forall i\]</span>
<p>这里目标函数 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\|\boldsymbol{w}\|_2^2\)</span> 想把边界做得尽量“简单”，也就是让法向量尽量短，从而把间隔做大；而每个训练样本都在提出自己的硬约束：它不仅要被分对，还必须距离边界至少有 1 个单位的函数间隔。把这两股力量写到同一个式子里，就得到拉格朗日函数（Lagrangian）：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(\boldsymbol{w},b,\boldsymbol{\alpha})=\frac{1}{2}\|\boldsymbol{w}\|_2^2-\sum_{i=1}^{N}\alpha_i\big(y_i(\boldsymbol{w}^\top \boldsymbol{x}_i+b)-1\big),\quad \alpha_i\ge 0\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本对应的拉格朗日乘子（Lagrange Multiplier）。在 SVM 里，可以把它直接理解为“第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本对当前分类边界施加了多大压力”。压力越大，说明这个样本越在真正影响边界的位置；压力为 0，说明这个样本虽然在训练集里，但最优边界并不需要它来支撑。</p>
<p>这个问题的读法可以想成一个“边界往外推、样本往回顶”的平衡过程：</p>
<ul>
<li><span style="background-color: #c0c0c0;">模型一侧</span> 想让 <span displaypfx="inline-" class="mathjax-container">\(\|\boldsymbol{w}\|_2\)</span> 尽量小，从而把间隔尽量做大。</li>
<li><span style="background-color: #c0c0c0;">约束一侧</span> 则由每个样本通过 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 施加压力：谁越接近边界、越可能破坏间隔，谁就越值得保留权重。</li>
</ul>
<p>于是，离边界很远的样本会发生什么，就变得非常直观。若某个样本满足</p>
<span displaypfx="" class="mathjax-container">\[y_i(\boldsymbol{w}^\top\boldsymbol{x}_i+b)&gt;1\]</span>
<p>说明它不仅分对了，而且还有额外安全余量。这个点对“边界能否继续外推”没有形成真正阻碍，因此最优时它对应的 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 会被压到 0。相反，若某个样本恰好满足</p>
<span displaypfx="" class="mathjax-container">\[y_i(\boldsymbol{w}^\top\boldsymbol{x}_i+b)=1\]</span>
<p>它就正贴在间隔边界上，是“再往外推一点就会出问题”的临界点。这样的样本才有资格在最优解中保留非零权重。</p>
<p>KKT 条件（Karush–Kuhn–Tucker Conditions）把这个直觉写成严格公式。第一条来自驻点条件（Stationarity）：</p>
<span displaypfx="" class="mathjax-container">\[\nabla_{\boldsymbol{w}}\mathcal{L}=0\Rightarrow \boldsymbol{w}=\sum_{i=1}^{N}\alpha_i y_i \boldsymbol{x}_i\]</span>
<p>这条式子的含义非常重要。它说明最终的法向量 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}\)</span> 并不是由全部样本平均决定的，而是由训练样本的加权和决定的。这里：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个训练样本；</li>
<li><span displaypfx="inline-" class="mathjax-container">\(y_i\alpha_i\)</span> 是它的“带符号权重”；</li>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i=0\)</span>，该样本就完全不会出现在 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}\)</span> 的表达式里。</li>
</ul>
<p>第二条关键条件是互补松弛（Complementary Slackness）：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_i\big(y_i(\boldsymbol{w}^\top \boldsymbol{x}_i+b)-1\big)=0\]</span>
<p>这条式子可以直接逐项阅读：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本对边界施加的压力；</li>
<li><span displaypfx="inline-" class="mathjax-container">\(y_i(\boldsymbol{w}^\top \boldsymbol{x}_i+b)-1\)</span> 是该样本相对于间隔边界的“松弛量”；当它大于 0 时，说明样本在安全区里；当它等于 0 时，说明样本正好贴边。</li>
</ul>
<p>由于这两个量的乘积必须等于 0，所以只可能出现两种情况：</p>
<ul>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(y_i(\boldsymbol{w}^\top \boldsymbol{x}_i+b)-1&gt;0\)</span>，说明该样本有安全余量，那么必须有 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i=0\)</span>。</li>
<li>若 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i&gt;0\)</span>，说明该样本仍在对边界施压，那么必须有 <span displaypfx="inline-" class="mathjax-container">\(y_i(\boldsymbol{w}^\top \boldsymbol{x}_i+b)=1\)</span>。</li>
</ul>
<p>这就是支持向量（Support Vector）的严格定义来源：<span style="background-color: #c0c0c0;">在硬间隔 SVM 中，只有恰好贴在间隔边界上的样本，才可能对应非零 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span>；这些样本共同决定最终超平面的位置</span>。其余样本虽然被正确分类，但因为离边界还有余量，所以在最优解里不再起作用。</p>
<p>如果继续沿着这个结论往下看，就会发现 SVM 的稀疏性完全不是偶然现象，而是 KKT 的直接产物。训练集里可以有大量“安全样本”，但最优边界只需要被少数临界样本支撑起来。名字“支持向量”说的正是这件事：这些点不是普通数据点，而是真正在几何上把边界撑住的点。</p>
<div class="blog_h4"><span class="graybg">对偶问题（Dual）：把“求边界”改写成“给样本分权重”</span></div>
<p>前面已经看到：KKT 让 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 变成了“谁在真正顶住边界”的刻度。但如果这里只停在 KKT，还是会留下一个疑问：<span style="background-color: #c0c0c0;">对偶问题到底是怎么从原始问题里长出来的</span>？关键动作只有一步：先把 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 固定住，把拉格朗日函数当成关于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 的函数来最小化；然后再回过头，只对 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 做最大化。</p>
<p>也就是说，原始问题里我们直接求“哪条边界最好”：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ \frac{1}{2}\|\mathbf{w}\|_2^2\quad \text{s.t.}\quad y_i(\mathbf{w}^\top\mathbf{x}_i+b)\ge 1\]</span>
<p>而引入拉格朗日乘子之后，可以先看下面这个函数：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}(\mathbf{w},b,\alpha)=\frac{1}{2}\|\mathbf{w}\|_2^2-\sum_{i=1}^{N}\alpha_i\Big(y_i(\mathbf{w}^\top \mathbf{x}_i+b)-1\Big),\quad \alpha_i\ge 0\]</span>
<p>对固定的 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span>，它就是一个关于 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 的凸函数。于是我们先做内层最小化：</p>
<span displaypfx="" class="mathjax-container">\[g(\alpha)=\min_{\mathbf{w},b}\ \mathcal{L}(\mathbf{w},b,\alpha)\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(g(\alpha)\)</span> 就叫对偶函数（Dual Function）。它表示：<span style="background-color: #c0c0c0;">假设每个样本的施压强度已经给定，边界那一侧最好的回应会是什么</span>。</p>
<p>这一步的好处是， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 可以被显式消掉。对 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 求驻点条件，有：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial \mathbf{w}}=0\Rightarrow \mathbf{w}=\sum_{i=1}^{N}\alpha_i y_i\mathbf{x}_i\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial b}=0\Rightarrow \sum_{i=1}^{N}\alpha_i y_i=0\]</span>
<p>这两条式子非常关键：第一条说明法向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 完全由训练样本线性组合出来；第二条说明正负两类样本的“总施压”必须平衡。把它们代回去，原来依赖 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 的问题就变成只依赖 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 的问题：</p>
<span displaypfx="" class="mathjax-container">\[\max_{\alpha}\ \sum_{i=1}^{N}\alpha_i-\frac{1}{2}\sum_{i=1}^{N}\sum_{j=1}^{N}\alpha_i\alpha_j y_i y_j\,\mathbf{x}_i^\top\mathbf{x}_j\]</span>
<span displaypfx="" class="mathjax-container">\[\text{s.t.}\quad \alpha_i\ge 0,\quad \sum_{i=1}^{N}\alpha_i y_i=0\]</span>
<p>这就是 SVM 的对偶问题（Dual Problem）。现在可以看出它为什么叫“对偶”：它没有再直接问“<span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 应该是多少”，而是改问“每个样本应该分到多大的权重 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span>，才能共同把最优边界顶出来”。</p>
<p>这样改写有两个直接收益。第一，支持向量为什么稀疏会变得一眼可见：若某个样本最终 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i^*=0\)</span>，它就自动从 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}=\sum_i \alpha_i y_i\mathbf{x}_i\)</span> 里消失。第二，对偶目标里出现的样本方式只剩内积 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i^\top\mathbf{x}_j\)</span>，这正是后面引入核函数（Kernel）的入口。</p>
<p>把最优权重 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i^*\)</span> 求出来后，分类器可以直接写成：</p>
<span displaypfx="" class="mathjax-container">\[f(\mathbf{x})=\mathrm{sign}\left(\sum_{i=1}^{N}\alpha_i^* y_i\,\mathbf{x}_i^\top \mathbf{x}+b^*\right)\]</span>
<p>这个式子的含义很具体：新样本 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 会与训练样本做内积（Inner Product），也就是计算相似度；每个训练样本按自己的类别符号 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 和影响系数 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i^*\)</span> 投一票；最后把这些票加总，再加上偏置 <span displaypfx="inline-" class="mathjax-container">\(b^*\)</span>，看结果落在哪一侧。</p>
<p>由于绝大多数样本的 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i^*=0\)</span>，真正参与这次投票的通常只有支持向量。因此 SVM 的预测阶段常带有一个很强的稀疏性（Sparsity）：<span style="background-color: #c0c0c0;">不是所有训练样本都在持续发声，真正起作用的只是边界附近那一小部分点</span>。</p>
<p>偏置 <span displaypfx="inline-" class="mathjax-container">\(b^*\)</span> 可以用任一支持向量恢复。若 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_k\)</span> 是一个支持向量，则它满足 <span displaypfx="inline-" class="mathjax-container">\(y_k(\mathbf{w}^{*\top}\mathbf{x}_k+b^*)=1\)</span>，因此</p>
<span displaypfx="" class="mathjax-container">\[b^*=y_k-\mathbf{w}^{*\top}\mathbf{x}_k\]</span>
<div class="blog_h4"><span class="graybg">核函数（Kernel）：把“线性边界”搬到更合适的空间</span></div>
<p>对偶问题真正重要的地方，不只是“把变量从 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w},b\)</span> 换成了 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span>”，而是它把 SVM 的全部数据依赖压缩成了样本之间的内积（Inner Product）：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{x}_i^\top\mathbf{x}_j\]</span>
<p>这一步非常关键，因为它说明：<span style="background-color: #c0c0c0;">SVM 在对偶形式里并不需要直接看到样本坐标本身，它只需要知道样本彼此有多相似</span>。只要这种“相似度”还能写成某个空间里的内积，SVM 的训练与预测公式就都可以照搬。</p>
<p>于是核技巧（Kernel Trick）的引入就变得自然了。设有一个特征映射（Feature Map）<span displaypfx="inline-" class="mathjax-container">\(\phi(\mathbf{x})\)</span>，它把原始样本送到更高维、甚至无限维的特征空间（Feature Space）。如果我们真的显式去算这个高维向量，代价往往很大；但对偶形式只关心内积，因此只要能直接计算</p>
<span displaypfx="" class="mathjax-container">\[K(\mathbf{x}_i,\mathbf{x}_j)=\phi(\mathbf{x}_i)^\top\phi(\mathbf{x}_j)\]</span>
<p>就等价于“隐式地”在特征空间里做线性 SVM，而不必真的把 <span displaypfx="inline-" class="mathjax-container">\(\phi(\mathbf{x})\)</span> 写出来。这个直接在原空间里计算特征空间内积的技巧，就叫核函数（Kernel Function）或核技巧（Kernel Trick）。</p>
<p>这也解释了为什么 kernel 是从 dual 里长出来的，而不是额外拼上去的：若你还停留在原始问题里，眼前看到的仍是显式参数 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span>；而一旦进入对偶形式，表达式里只剩 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}_i^\top\mathbf{x}_j\)</span>，把它替换成别的“合法相似度”就成了最自然的一步。</p>
<p>于是决策函数从</p>
<span displaypfx="" class="mathjax-container">\[f(\mathbf{x})=\mathrm{sign}\left(\sum_{i=1}^{N}\alpha_i^* y_i\,\mathbf{x}_i^\top \mathbf{x}+b^*\right)\]</span>
<p>变成</p>
<span displaypfx="" class="mathjax-container">\[f(\mathbf{x})=\mathrm{sign}\left(\sum_{i=1}^{N}\alpha_i^* y_i\,K(\mathbf{x}_i,\mathbf{x})+b^*\right)\]</span>
<p>要点是：<span style="background-color: #c0c0c0;">特征空间里仍然是线性超平面；只是映回原空间后，边界看起来变成了弯的</span>。因此 kernel 不是“把线性 SVM 换成非线性模型”，而是“先把数据换到更容易线性可分的表示里，再继续做线性 SVM”。</p>
<p>从直觉上看，核函数本质上是在重新定义“两个样本像不像”。线性核（Linear Kernel）比较原始方向是否一致；多项式核（Polynomial Kernel）强调特征之间的组合关系；RBF / Gaussian 核更强调局部邻近性，因此很容易形成局部、弯曲的决策边界。</p>
<ul>
<li>线性核（Linear Kernel）：<span displaypfx="inline-" class="mathjax-container">\(K(\mathbf{x},\mathbf{z})=\mathbf{x}^\top\mathbf{z}\)</span>。</li>
<li>多项式核（Polynomial Kernel）：<span displaypfx="inline-" class="mathjax-container">\(K(\mathbf{x},\mathbf{z})=(\mathbf{x}^\top\mathbf{z}+c)^d\)</span>。</li>
<li>RBF / Gaussian 核：<span displaypfx="inline-" class="mathjax-container">\(K(\mathbf{x},\mathbf{z})=\exp(-\gamma\|\mathbf{x}-\mathbf{z}\|_2^2)\)</span>。</li>
</ul>
<p>一个典型例子是“同心圆”二分类：在二维平面里，内圈和外圈无法用一条直线分开；但如果映射到包含半径平方等特征的空间，类别就可能被一个超平面分开。核方法的价值就在这里：<span style="background-color: #c0c0c0;">原空间里看到的是弯曲边界，特征空间里做的仍然是线性分类</span>。</p>
<div class="blog_h4"><span class="graybg">软间隔 SVM（Soft-margin SVM）：允许少量违约，但要付代价</span></div>
<p>现实数据通常含噪声、离群点（Outlier）或类别重叠。若仍然要求“所有点都必须在间隔之外”，硬间隔 SVM 很可能根本无解，或者被少数异常点强行拉歪。软间隔 SVM（Soft-margin SVM）因此引入松弛变量（Slack Variable）<span displaypfx="inline-" class="mathjax-container">\(\xi_i\ge 0\)</span>，允许个别样本违反间隔约束：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b,\xi}\ \frac{1}{2}\|\mathbf{w}\|_2^2+C\sum_{i=1}^{N}\xi_i\quad \text{s.t.}\quad y_i(\mathbf{w}^\top \mathbf{x}_i+b)\ge 1-\xi_i,\ \xi_i\ge 0\]</span>
<p>这个式子只表达一件事：边界仍然希望尽量大，但违约要交罚款，罚款强度由 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 决定。对单个样本， <span displaypfx="inline-" class="mathjax-container">\(\xi_i\)</span> 的含义可以直接按大小来读：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\xi_i=0\)</span>：样本分类正确，且在间隔边界上或之外。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(0&lt;\xi_i\le 1\)</span>：样本仍在正确一侧，但已经挤进了间隔内部。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\xi_i&gt;1\)</span>：样本跨过了分类边界，已经被误分。</li>
</ul>
<p><span displaypfx="inline-" class="mathjax-container">\(C\)</span> 控制的是“对违约有多敏感”：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(C\)</span> 大：更重视把训练集分对，边界更硬，对噪声也更敏感。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(C\)</span> 小：更重视大间隔和整体稳定性，允许少量训练误差。</li>
</ul>
<p>软间隔下，支持向量的范围也更宽：不仅贴着间隔边界的点重要，落在间隔内部甚至被误分的点也会直接影响最优解。对应到对偶变量，常见情形是 <span displaypfx="inline-" class="mathjax-container">\(0&lt;\alpha_i&lt;C\)</span> 的点贴在间隔上，而 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i=C\)</span> 的点往往是间隔内样本或误分类样本。</p>
<p>把松弛变量消去后，软间隔 SVM 还可以写成更常见的合页损失（Hinge Loss）形式：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\mathbf{w},b}\ \frac{1}{2}\|\mathbf{w}\|_2^2+C\sum_{i=1}^{N}\max\big(0,\ 1-y_i(\mathbf{w}^\top \mathbf{x}_i+b)\big)\]</span>
<p>第一项限制模型复杂度，第二项惩罚分类违约。SVM 因此可以被看作“<span style="background-color: #c0c0c0;">大间隔 + 违约惩罚</span>”的组合，而支持向量则是这两股力量平衡后仍然留在最前线的样本。</p>
<div class="blog_h2"><span class="graybg">树模型与集成方法</span></div>
<p>这一类方法处理的核心问题是：当输入与输出之间存在明显非线性、阈值效应与高阶特征交互时，线性模型往往表达力不足，但工程上仍然希望模型具备较强可解释性、对表格数据友好、并且训练稳定。树模型通过递归切分（Recursive Partitioning）把输入空间划成若干局部区域；集成方法则进一步通过 Bagging 或 Boosting 提升泛化能力与精度。</p>
<div class="blog_h3"><span class="graybg">决策树</span></div>
<p>决策树（Decision Tree）把预测过程写成一串逐层切分的规则：内部节点负责提问，边负责根据答案分流，叶节点负责输出最终结果。它的优势不在于公式复杂，而在于模型结构与业务规则天然同构：每一条从根到叶的路径，都对应一条可读的判断链。</p>
<div class="blog_h4"><span class="graybg">决策树、分类树、回归树的关系</span></div>
<p>决策树是总称；分类树（Classification Tree）与回归树（Regression Tree）是它在两类监督学习任务上的具体形式。三者共享同一种树结构，但目标变量、切分准则与叶子输出不同。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">概念</td>
<td style="text-align: center;">预测目标</td>
<td style="text-align: center;">叶子输出</td>
<td style="text-align: center;">常用切分准则</td>
</tr>
</thead>
<tbody>
<tr>
<td>决策树（Decision Tree）</td>
<td>树模型的总称</td>
<td>取决于具体任务</td>
<td>取决于具体任务</td>
</tr>
<tr>
<td>分类树（Classification Tree）</td>
<td>离散标签（如“流失/不流失”）</td>
<td>类别或类别概率</td>
<td>基尼不纯度（Gini）、熵（Entropy）、信息增益（Information Gain）</td>
</tr>
<tr>
<td>回归树（Regression Tree）</td>
<td>连续数值（如“价格”“时长”）</td>
<td>一个数值常数</td>
<td>平方误差（Squared Error）、MSE / SSE 下降</td>
</tr>
</tbody>
</table>
<p>因此不要把“决策树”和“分类树”当成并列概念。更准确的表述是：<span style="background-color: #c0c0c0;">分类树与回归树都是决策树；前者预测类别，后者预测数值</span>。</p>
<div class="blog_h4"><span class="graybg">结构与统一公式</span></div>
<p>设某个节点上落入的样本集合为 <span displaypfx="inline-" class="mathjax-container">\(S\)</span>，大小为 <span displaypfx="inline-" class="mathjax-container">\(|S|\)</span>。对某个候选切分（Split）<span displaypfx="inline-" class="mathjax-container">\(\phi\)</span>，例如“第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个特征 <span displaypfx="inline-" class="mathjax-container">\(x_j\le t\)</span>”，样本会被分成左右两个子节点：</p>
<span displaypfx="" class="mathjax-container">\[S_L(\phi)=\{(\mathbf{x}_i,y_i)\in S:\ x_{i,j}\le t\},\quad S_R(\phi)=S\setminus S_L(\phi)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(S_L\)</span> 是满足切分条件的样本集合，<span displaypfx="inline-" class="mathjax-container">\(S_R\)</span> 是剩余样本集合。树在每个节点都会尝试多个候选切分 <span displaypfx="inline-" class="mathjax-container">\(\phi\)</span>，并保留收益最大的那个。</p>
<p>为了避免分别记忆分类树与回归树的训练目标，可以先写成统一形式。设 <span displaypfx="inline-" class="mathjax-container">\(I(S)\)</span> 表示“节点 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 当前有多乱”或“在该节点上预测误差有多大”，则一次切分的收益可统一写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Gain}(S,\phi)=I(S)-\frac{|S_L(\phi)|}{|S|}I\!\left(S_L(\phi)\right)-\frac{|S_R(\phi)|}{|S|}I\!\left(S_R(\phi)\right)\]</span>
<p>这条式子的含义非常直接：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(I(S)\)</span>：切分前，这个节点有多混乱。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(I(S_L),I(S_R)\)</span>：切分后，左右子节点各自还有多混乱。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\frac{|S_L|}{|S|},\frac{|S_R|}{|S|}\)</span>：左右子节点占父节点样本的比例。要乘这个比例，是因为大子节点对总体误差的影响更大，小子节点不能和大子节点拥有同样权重。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{Gain}(S,\phi)\)</span> 越大，说明这次切分越有效。</li>
</ul>
<p>分类树与回归树的差别，正体现在 <span displaypfx="inline-" class="mathjax-container">\(I(S)\)</span> 具体取什么。</p>
<div class="blog_h4"><span class="graybg">决策树：一个整体例子</span></div>
<p>在贷款审批（Loan Approval）场景中，决策树可以直接写成规则链。根节点先按负债收入比（Debt-to-Income Ratio）切分；若负债收入比过高，再看是否有逾期记录；若负债收入比正常，再看信用评分（Credit Score）与近 6 个月收入稳定性。最终某个叶节点可能对应“直接通过”，另一个叶节点对应“人工复核”，再另一个叶节点对应“拒绝”。这就是决策树最重要的工程价值：它不只是给出一个分数，还给出一条可追溯的判断路径。</p>
<div class="blog_h4"><span class="graybg">分类树：目标、公式与含义</span></div>
<p>分类树的目标是让每个叶节点里的标签尽量单一。若一个节点里几乎都是同一类样本，这个节点就“纯”；若各类样本混在一起，这个节点就“不纯”。</p>
<p>设类别集合为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{K}\)</span>，类别 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 在节点 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 中的占比记为：</p>
<span displaypfx="" class="mathjax-container">\[p_k(S)=\frac{\text{节点 }S\text{ 中标签为 }k\text{ 的样本数}}{|S|}\]</span>
<p>常用的不纯度（Impurity）有两种。</p>
<p>基尼不纯度（Gini Impurity）：</p>
<span displaypfx="" class="mathjax-container">\[G(S)=1-\sum_{k\in\mathcal{K}}p_k(S)^2\]</span>
<p>它可以读成“随机抽两个样本时，标签不一致的倾向有多强”。若节点里全是同一类，则某个 <span displaypfx="inline-" class="mathjax-container">\(p_k=1\)</span>、其余为 0，此时 <span displaypfx="inline-" class="mathjax-container">\(G(S)=0\)</span>，说明节点已经纯净；若二分类里两类各占一半，则 <span displaypfx="inline-" class="mathjax-container">\(G(S)=1-(0.5^2+0.5^2)=0.5\)</span>，说明混杂程度较高。</p>
<p>熵（Entropy）：</p>
<span displaypfx="" class="mathjax-container">\[H(S)=-\sum_{k\in\mathcal{K}}p_k(S)\log p_k(S)\]</span>
<p>熵衡量的是“不确定性”。若节点里全是同一类，则不需要再猜，熵为 0；若各类比例接近，说明不确定性高，熵也更大。</p>
<p>信息增益（Information Gain）就是“切分前的不确定性”减去“切分后的加权不确定性”：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{IG}(S,\phi)=H(S)-\frac{|S_L(\phi)|}{|S|}H\!\left(S_L(\phi)\right)-\frac{|S_R(\phi)|}{|S|}H\!\left(S_R(\phi)\right)\]</span>
<p>这条公式的每一部分都对应一个明确动作：</p>
<ul>
<li>第一项 <span displaypfx="inline-" class="mathjax-container">\(H(S)\)</span> 是切分前的混乱程度。</li>
<li>后两项是切分后左右子节点各自的混乱程度，并按样本占比加权求和。</li>
<li>两者相减，就是这次切分让节点“变纯”了多少。</li>
</ul>
<p>有些实现使用基尼下降而不是信息增益，本质上是同一件事：<span style="background-color: #c0c0c0;">选择那个能让子节点更纯、让标签更集中的切分</span>。</p>
<div class="blog_h4"><span class="graybg">分类树：实际例子——用户流失预警</span></div>
<p>设任务是预测“一个用户未来 30 天是否流失”。标签只有两类：流失 / 未流失，因此这是典型分类树问题。候选特征可以包括：最近 7 天登录次数、最近 30 天是否投诉、是否还有未使用优惠券、最近一次下单距今天数。</p>
<p>假设根节点先尝试切分“最近 7 天登录次数 &lt; 2”。这次切分后，左子节点里的用户大多已经很久不活跃，且流失比例显著升高；右子节点里的用户则活跃度更高、留存率更好。此时无论用基尼还是熵计算，左右节点的加权不纯度都会明显低于父节点，因此这会成为一个高质量切分。</p>
<p>继续往下，左子节点还可以再按“最近 30 天是否投诉”切分：低活跃且有投诉的用户，叶节点里可能出现“82% 最终流失”；低活跃但无投诉的用户，叶节点里可能是“61% 流失”。此时叶子不只给出类别，还可给出经验概率。工程上，这样的输出就能直接用于运营动作：高风险叶子推召回优惠券，中风险叶子推客服回访，低风险叶子不干预。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/descision-tree-1.jpg"><img class="alignnone size-full wp-image-41221" src="https://blog.gmem.cc/wp-content/uploads/2026/03/descision-tree-1.jpg" alt="descision-tree-1" width="1024" height="559" /></a></p>
<div class="blog_h4"><span class="graybg">回归树：目标、公式与含义</span></div>
<p>回归树处理的是连续数值目标，例如价格、时长、销量、能耗。它不追求“类别更纯”，而追求“同一个叶节点里的数值尽量接近”。</p>
<p>若某个叶节点 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 最终只输出一个常数 <span displaypfx="inline-" class="mathjax-container">\(c\)</span>，那么在平方误差（Squared Error）下，最优输出不是中位数，而是均值：</p>
<span displaypfx="" class="mathjax-container">\[c^*(S)=\arg\min_c\sum_{(\mathbf{x}_i,y_i)\in S}(y_i-c)^2=\frac{1}{|S|}\sum_{(\mathbf{x}_i,y_i)\in S}y_i\]</span>
<p>之所以是均值，是因为平方误差会把所有偏差向两边拉平，而均值正是使平方偏差和最小的那个常数。</p>
<p>在这个叶节点上，最小平方误差和（Sum of Squared Errors, SSE）为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{SSE}(S)=\sum_{(\mathbf{x}_i,y_i)\in S}\left(y_i-c^*(S)\right)^2\]</span>
<p>它表示节点里所有样本值围绕叶子预测值 <span displaypfx="inline-" class="mathjax-container">\(c^*(S)\)</span> 的总波动。SSE 越大，说明这个节点里的样本值越分散，单用一个常数代表它们的效果越差。</p>
<p>因此一次切分的目标是让切分后的总误差尽量小，也可写成误差下降尽量大：</p>
<span displaypfx="" class="mathjax-container">\[\Delta(S,\phi)=\mathrm{SSE}(S)-\mathrm{SSE}\!\left(S_L(\phi)\right)-\mathrm{SSE}\!\left(S_R(\phi)\right)\]</span>
<p>若更喜欢看平均误差，也可以把它写成 MSE（Mean Squared Error）形式：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{MSE}(S)=\frac{1}{|S|}\mathrm{SSE}(S)\]</span>
<p>等价地，也可以最小化切分后的加权平均误差：<span displaypfx="" class="mathjax-container">\[\frac{|S_L|}{|S|}\mathrm{MSE}(S_L)+\frac{|S_R|}{|S|}\mathrm{MSE}(S_R)\]</span></p>
<p>两种写法完全等价：SSE 强调总误差，MSE 强调平均误差；本质都是寻找让子节点内部数值更集中的切分。</p>
<div class="blog_h4"><span class="graybg">回归树：实际例子——外卖配送时长预测</span></div>
<p>设任务是预测一笔订单从接单到送达需要多少分钟。这是连续数值目标，因此属于回归树。候选特征可以包括：配送距离、是否下雨、是否晚高峰、商家出餐速度、骑手当前手中订单数。</p>
<p>根节点可能先按“配送距离是否大于 3 公里”切分。因为近距离订单与远距离订单的时长分布差异很大，这一步通常能显著降低节点内部方差。对远距离子节点，再按“是否下雨”切分；下雨天路况更慢、波动更大。对近距离子节点，则可能按“是否处于午晚高峰”切分。</p>
<p>假设某个叶节点对应“距离 &gt; 3 公里、下雨、晚高峰”这类订单，这个叶节点里的训练样本平均送达时长是 47 分钟，那么该叶子的预测值就是 47。另一个叶节点若对应“距离 &lt; 2 公里、不下雨、非高峰”，其平均时长可能只有 18 分钟。回归树的预测逻辑不是拟合一条全局直线，而是把不同业务情境分段，再在每段内给出一个局部平均值。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/decision-tree-2.png"><img class="alignnone size-full wp-image-41227" src="https://blog.gmem.cc/wp-content/uploads/2026/03/decision-tree-2.png" alt="decision-tree-2" width="1469" height="1035" /></a></p>
<div class="blog_h4"><span class="graybg">适用场景</span></div>
<ul>
<li>需要把模型输出翻译成可审计规则，例如风控、审批、运营分层、客服分流。</li>
<li>数据以表格（Tabular）为主，且存在显著非线性、阈值效应或特征交互。</li>
<li>希望同时处理连续特征与离散特征，并保留较强可解释性。</li>
<li>注意：单棵树方差高、容易过拟合，通常需要限制最大深度、最小叶子样本数或配合集成方法。</li>
</ul>
<div class="blog_h3"><span class="graybg">随机森林</span></div>
<p>随机森林（Random Forest）是 Bagging（Bootstrap Aggregating）在树模型上的经典实现：用 bootstrap 采样生成多份训练子集，训练多棵通常偏差较低、但对训练数据扰动高度敏感（高方差）的决策树，再把它们的输出聚合，以显著降低整体方差并提升鲁棒性。</p>
<p>这里的“高方差（High Variance）”指模型的估计方差（Estimator Variance）或预测方差（Prediction Variance）：如果训练集稍有变化，单棵树学到的分裂结构与最终预测就可能明显变化。随机森林利用多棵树的平均/投票，把这种由数据扰动带来的波动相互抵消。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/random-forest.jpg"><img class="alignnone size-full wp-image-41231" src="https://blog.gmem.cc/wp-content/uploads/2026/03/random-forest.jpg" alt="random-forest" width="1287" height="816" /></a></p>
<div class="blog_h4"><span class="graybg">算法与符号</span></div>
<p>给定训练集 <span displaypfx="inline-" class="mathjax-container">\(D=\{(\mathbf{x}_i,y_i)\}_{i=1}^{N}\)</span>。对 <span displaypfx="inline-" class="mathjax-container">\(m=1,\dots,M\)</span>：</p>
<ol>
<li>bootstrap 采样：从 <span displaypfx="inline-" class="mathjax-container">\(D\)</span> 有放回采样 <span displaypfx="inline-" class="mathjax-container">\(N\)</span> 次得到 <span displaypfx="inline-" class="mathjax-container">\(D_m\)</span>。所谓“有放回（Sampling with Replacement）”，是指每次抽到一个样本后，都先把它放回原数据集，再进行下一次抽样；因此同一个样本可能被重复抽中，而有些样本在这一轮里一次也没有被抽到。</li>
<li>训练一棵树 <span displaypfx="inline-" class="mathjax-container">\(T_m\)</span>：每个节点分裂时，只在随机选取的 <span displaypfx="inline-" class="mathjax-container">\(d'\)</span> 个特征上搜索最优切分（特征子采样，feature subsampling）。</li>
</ol>
<p>预测时，回归取平均，分类取多数投票：</p>
<span displaypfx="" class="mathjax-container">\[\hat y_{\text{reg}}(\mathbf{x})=\frac{1}{M}\sum_{m=1}^{M}T_m(\mathbf{x}),\quad \hat y_{\text{clf}}(\mathbf{x})=\mathrm{mode}\left(\{T_m(\mathbf{x})\}_{m=1}^{M}\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(T_m(\mathbf{x})\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 棵树对样本 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 的预测。回归任务把所有树的输出做平均，以减少波动；分类任务取众数 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{mode}(\cdot)\)</span>，也就是票数最多的类别。随机森林的稳定性正来自这种“多棵树共同决定”的聚合机制。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(M\)</span>：树的数量。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(d'\)</span>：每次分裂考虑的特征数（常见经验：分类用 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{d}\)</span>，回归用 <span displaypfx="inline-" class="mathjax-container">\(d/3\)</span>）。</li>
</ul>
<div class="blog_h4"><span class="graybg">为什么有效：方差下降与“去相关”</span></div>
<p>若单棵树预测的方差为 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span>，不同树之间的相关系数近似为 <span displaypfx="inline-" class="mathjax-container">\(\rho\)</span>（<span displaypfx="inline-" class="mathjax-container">\(0\le\rho\le 1\)</span>），则平均后的方差近似为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Var}\!\left(\frac{1}{M}\sum_{m=1}^{M}T_m(\mathbf{x})\right)\approx \sigma^2\left(\rho+\frac{1-\rho}{M}\right)\]</span>
<p>这个近似式子把随机森林为什么有效说得很清楚： <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 是单棵树自身的预测波动， <span displaypfx="inline-" class="mathjax-container">\(\rho\)</span> 是树与树之间的相关性， <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 是树数。树越多， <span displaypfx="inline-" class="mathjax-container">\(\frac{1-\rho}{M}\)</span> 越小；树之间越不相似， <span displaypfx="inline-" class="mathjax-container">\(\rho\)</span> 越低，最终平均后的波动就越小。</p>
<p>因此随机森林有两条主线：</p>
<ul>
<li>增加 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 降低 <span displaypfx="inline-" class="mathjax-container">\((1-\rho)/M\)</span> 项。</li>
<li>通过 bootstrap + 特征子采样降低相关性 <span displaypfx="inline-" class="mathjax-container">\(\rho\)</span>，让集成真正“互补”。</li>
</ul>
<div class="blog_h4"><span class="graybg">OOB：不用额外验证集的误差估计</span></div>
<p>bootstrap 采样会重复抽到某些样本。对固定样本 <span displaypfx="inline-" class="mathjax-container">\(i\)</span>，一次采样中没被抽到的概率为 <span displaypfx="inline-" class="mathjax-container">\((1-\frac{1}{N})^N\approx e^{-1}\approx 0.368\)</span>。因此每棵树大约有 36.8% 的样本是袋外（Out-of-Bag, OOB）样本，可用它们评估该树对未见数据的表现，并对全森林给出近似验证误差。</p>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\((1-\frac{1}{N})\)</span> 表示“一次抽样没抽到某个固定样本”的概率，连续做 <span displaypfx="inline-" class="mathjax-container">\(N\)</span> 次后得到 <span displaypfx="inline-" class="mathjax-container">\((1-\frac{1}{N})^N\)</span>。当 <span displaypfx="inline-" class="mathjax-container">\(N\)</span> 足够大时，它逼近 <span displaypfx="inline-" class="mathjax-container">\(e^{-1}\)</span>，也就是约 36.8%。这就是为什么随机森林天然拥有一批“没参与这棵树训练”的 OOB 样本。</p>
<div class="blog_h4"><span class="graybg">适用场景</span></div>
<ul>
<li>表格数据（Tabular）的强默认基线：非线性、特征交互、缺失值与尺度不一致都较鲁棒。</li>
<li>对超参数不敏感、训练稳定；可用特征重要性（Feature Importance）做解释与特征筛查。</li>
<li>当需要极致精度时，GBDT 家族往往更强；当需要更快推理/更小模型时，线性/浅层模型更合适。</li>
</ul>
<div class="blog_h3"><span class="graybg">梯度提升树（GBDT）</span></div>
<p>梯度提升树（Gradient Boosting Decision Tree, GBDT）是一类按序叠加回归树的加法模型（Additive Model）。模型从简单的初始预测 <span displaypfx="inline-" class="mathjax-container">\(F_0\)</span> 出发，在每一轮加入一棵新树，用于修正当前模型尚未拟合好的部分，从而逐步降低训练集上的经验风险（Empirical Risk）。</p>
<p>每一轮新增的树都对应一个修正函数（Correction Function）。它不直接重新学习标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>，而是拟合当前模型输出与目标之间尚未被解释的差异。多轮修正连续叠加后，模型会从粗糙预测逐步逼近目标函数。</p>
<p>一个直观比喻是：GBDT 像一组按顺序接手的阅卷老师。第一位老师先给出一个粗略分数，后面的每一位老师都不重做整张卷子，只专门检查前面模型错得最明显的地方，并在这些地方补上修正意见。树一棵接一棵叠加后，最终预测会越来越接近真实值。</p>
<p>这里每棵小树都不是独立完成任务的“大模型”，而是一个局部纠错器。它关心的是当前模型还没解释好的误差：哪些样本被高估了，哪些被低估了，以及这些误差集中出现在哪些特征区域。“梯度”对应当前损失下降最快的修正方向，“提升”则表示把这些小修正持续累加成一个更强的整体模型。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/gbdt.jpg"><img class="alignnone size-full wp-image-41247" src="https://blog.gmem.cc/wp-content/uploads/2026/03/gbdt.jpg" alt="gbdt" width="1408" height="768" /></a></p>
<div class="blog_h4"><span class="graybg">目标：最小化经验风险</span></div>
<p>GBDT 背后的目标非常直接：寻找一个函数 <span displaypfx="inline-" class="mathjax-container">\(F\)</span>，使训练集上的总损失最小：</p>
<span displaypfx="" class="mathjax-container">\[ \min_F\ \sum_{i=1}^{N}\ell\big(y_i,F(\mathbf{x}_i)\big) \]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\ell(y,F(\mathbf{x}))\)</span> 是单样本损失函数，平方损失、对数损失等都可以放进来。难点在于：函数 <span displaypfx="inline-" class="mathjax-container">\(F\)</span> 不是一个普通标量参数，而是一个复杂的预测函数；如果一次性同时优化所有树的结构和叶子输出，组合空间过大，几乎不可直接求解。</p>
<p>因此 GBDT 采用前向分步加法（Forward Stagewise Additive Modeling）：不一次求整个 <span displaypfx="inline-" class="mathjax-container">\(F\)</span>，而是把它写成逐步累加的形式，只在第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮新增一个修正函数 <span displaypfx="inline-" class="mathjax-container">\(f_m\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[ F_M(\mathbf{x})=F_0(\mathbf{x})+\nu\sum_{m=1}^{M}f_m(\mathbf{x}) \]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(F_M(\mathbf{x})\)</span> 表示：模型经过总共 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 轮提升后，对输入样本 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 输出的最终预测值。下标 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 表示“已经累计做了 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 次修正”，不是幂次，也不是某一棵单独的树。GBDT 的最终模型是许多轮小修正叠加后的总结果。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span>：一个输入样本的特征向量。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(F_0(\mathbf{x})\)</span>：初始模型对样本 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 的预测，常取常数 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 使 <span displaypfx="inline-" class="mathjax-container">\(\sum_i \ell(y_i,c)\)</span> 最小。它可以理解为模型在还没有长出任何树之前给出的第一版粗略判断。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f_m(\mathbf{x})\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮新增的回归树在样本 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 上给出的修正值，负责弥补当前模型尚未拟合好的部分。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sum_{m=1}^{M}f_m(\mathbf{x})\)</span>：把前 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 轮所有修正树的输出加起来，得到总修正量。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\nu\in(0,1]\)</span>：学习率（Shrinkage），控制每一步修正只走多大。它的作用不是改变方向，而是缩小步长，使训练更稳定、泛化更好。</li>
</ul>
<p>这个公式也可以按“底稿 + 反复批改”来理解： <span displaypfx="inline-" class="mathjax-container">\(F_0(\mathbf{x})\)</span> 是第一版预测，后面的每个 <span displaypfx="inline-" class="mathjax-container">\(f_m(\mathbf{x})\)</span> 都是在已有结果上补一小笔修正，最终的 <span displaypfx="inline-" class="mathjax-container">\(F_M(\mathbf{x})\)</span> 就是经历 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 次修正后的版本。</p>
<span displaypfx="" class="mathjax-container">\[ f_m=\arg\min_f\sum_{i=1}^{N}\ell\big(y_i,F_{m-1}(\mathbf{x}_i)+f(\mathbf{x}_i)\big) \]</span>
<p>这条式子表示：在第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮，模型要寻找一个新的修正函数 <span displaypfx="inline-" class="mathjax-container">\(f_m\)</span>，使它加到旧模型 <span displaypfx="inline-" class="mathjax-container">\(F_{m-1}\)</span> 上之后，训练集的总损失尽可能小。这里的 <span displaypfx="inline-" class="mathjax-container">\(\arg\min_f\)</span> 可以读作“在所有候选函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 里，找出那个能让目标最小的函数”。因此，求出来的不是一个数，而是当前这一轮最合适的新树。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(F_{m-1}(\mathbf{x}_i)\)</span>：前 <span displaypfx="inline-" class="mathjax-container">\(m-1\)</span> 轮模型在第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本上的当前预测值。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x}_i)\)</span>：候选新树在第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本上的修正值。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(F_{m-1}(\mathbf{x}_i)+f(\mathbf{x}_i)\)</span>：把新树加进去之后，这个样本的新预测值。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\ell\big(y_i,F_{m-1}(\mathbf{x}_i)+f(\mathbf{x}_i)\big)\)</span>：该样本在新预测下的损失。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sum_{i=1}^{N}\ell(\cdot)\)</span>：把所有样本的损失加总，得到这一轮希望尽量压低的整体目标。</li>
</ul>
<p>这一轮之所以写成 <span displaypfx="inline-" class="mathjax-container">\(F_{m-1}+f\)</span>，而不是重新求一个全新的 <span displaypfx="inline-" class="mathjax-container">\(F_m\)</span>，原因在于 GBDT 采用的是逐步修正策略：旧模型已经学到的部分先保留，新树只负责补上当前还没有拟合好的误差。这样每一轮只解决一个更小的局部问题，计算上更可行，也更符合“不断纠错”的直觉。</p>
<p>第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮只优化新增的修正函数 <span displaypfx="inline-" class="mathjax-container">\(f_m\)</span>，而把已有模型 <span displaypfx="inline-" class="mathjax-container">\(F_{m-1}\)</span> 视为固定量。这样做把原本难以整体求解的函数优化问题，拆成了一系列可逐步求解的局部优化问题。</p>
<div class="blog_h4"><span class="graybg">负梯度的来源</span></div>
<p>上面的子问题仍然不容易直接做，因为“最佳新树”本身还是一个复杂的函数搜索问题。GBDT 的关键近似是：在当前模型 <span displaypfx="inline-" class="mathjax-container">\(F_{m-1}\)</span> 附近，对损失做一阶展开，只看局部下降方向。</p>
<p>对单个样本 <span displaypfx="inline-" class="mathjax-container">\(i\)</span>，把新增修正记为 <span displaypfx="inline-" class="mathjax-container">\(f(\mathbf{x}_i)\)</span>，则有：</p>
<span displaypfx="" class="mathjax-container">\[ \ell\big(y_i,F_{m-1}(\mathbf{x}_i)+f(\mathbf{x}_i)\big) \approx \ell\big(y_i,F_{m-1}(\mathbf{x}_i)\big) + \frac{\partial \ell(y_i,F(\mathbf{x}_i))}{\partial F(\mathbf{x}_i)}\Big|_{F=F_{m-1}} f(\mathbf{x}_i) \]</span>
<p>一阶展开表明，新增函数 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 的最优局部方向由损失对模型输出的负梯度决定。因此第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮的伪残差（Pseudo-residual）定义为：</p>
<span displaypfx="" class="mathjax-container">\[ r_i^{(m)}=-\left.\frac{\partial \ell\left(y_i,F(\mathbf{x}_i)\right)}{\partial F(\mathbf{x}_i)}\right|_{F=F_{m-1}} \]</span>
<p>接下来的动作就自然了：不用树直接拟合标签 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span>，而是用一棵回归树去拟合这些伪残差 <span displaypfx="inline-" class="mathjax-container">\(r_i^{(m)}\)</span>。这棵树学到的，就是“当前模型在输入空间不同区域里，应该往哪个方向、修正多大”的分段常数近似。</p>
<p>模型更新写成：</p>
<span displaypfx="" class="mathjax-container">\[ F_m(\mathbf{x})=F_{m-1}(\mathbf{x})+\nu f_m(\mathbf{x}) \]</span>
<p>“梯度提升”这一名称对应的正是上述更新方式：优化对象不是有限维参数向量，而是预测函数本身；每一轮更新都沿着损失在函数空间中的负梯度方向加入一个新的修正函数。</p>
<div class="blog_h4"><span class="graybg">平方损失下的残差形式</span></div>
<p>若损失取平方损失</p>
<span displaypfx="" class="mathjax-container">\[ \ell(y,F)=\frac{1}{2}(y-F)^2 \]</span>
<p>则对模型输出求导：</p>
<span displaypfx="" class="mathjax-container">\[ \frac{\partial \ell(y,F)}{\partial F}=F-y \]</span>
<p>因此第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮的负梯度为：</p>
<span displaypfx="" class="mathjax-container">\[ r_i^{(m)}=-\left(F_{m-1}(\mathbf{x}_i)-y_i\right)=y_i-F_{m-1}(\mathbf{x}_i) \]</span>
<p>这正是当前预测与真实值之间的残差（Residual）。所以在回归任务里，人们常把 GBDT 说成“不断拟合残差”；这并不是另一套经验规则，而是平方损失下负梯度公式的直接结果。</p>
<div class="blog_h4"><span class="graybg">树作为局部修正器</span></div>
<p>因为树天然产生分段常数（Piecewise Constant）的局部修正。若某一轮中，一棵树把样本空间切成若干区域 <span displaypfx="inline-" class="mathjax-container">\(R_1,\dots,R_J\)</span>，那么它输出的是：</p>
<span displaypfx="" class="mathjax-container">\[ f_m(\mathbf{x})=\sum_{j=1}^{J}\gamma_j\mathbf{1}(\mathbf{x}\in R_j) \]</span>
<p>这意味着：模型不是对整个输入空间统一加一个修正，而是在每个局部区域里分别加一个常数修正 <span displaypfx="inline-" class="mathjax-container">\(\gamma_j\)</span>。这正适合处理表格数据里的阈值效应、非线性和特征交互。</p>
<div class="blog_h4"><span class="graybg">叶子输出的解析解</span></div>
<p>在平方损失下，若某个叶子覆盖的样本集合为 <span displaypfx="inline-" class="mathjax-container">\(S\)</span>，并用常数 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 拟合这一叶内的残差 <span displaypfx="inline-" class="mathjax-container">\(r_i\)</span>，则该叶子的局部目标是：</p>
<span displaypfx="" class="mathjax-container">\[ \min_{\gamma}\sum_{i\in S}(r_i-\gamma)^2 \]</span>
<p>对 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 求导并令其为 0：</p>
<span displaypfx="" class="mathjax-container">\[ \frac{d}{d\gamma}\sum_{i\in S}(r_i-\gamma)^2=-2\sum_{i\in S}(r_i-\gamma)=0 \]</span>
<span displaypfx="" class="mathjax-container">\[ \sum_{i\in S}r_i-|S|\gamma=0 \]</span>
<span displaypfx="" class="mathjax-container">\[ \gamma^*=\frac{1}{|S|}\sum_{i\in S}r_i \]</span>
<p>因此叶子输出之所以是均值，不是经验设定，而是平方损失下这个局部最小二乘问题的解析解。每一轮加入一棵树，本质上是在每个局部区域里补上一段“平均残差修正”。</p>
<div class="blog_h4"><span class="graybg">训练流程</span></div>
<ol>
<li>初始化 <span displaypfx="inline-" class="mathjax-container">\(F_0\)</span>，通常取使总体损失最小的常数模型。</li>
<li>对第 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 轮，计算所有样本的伪残差 <span displaypfx="inline-" class="mathjax-container">\(r_i^{(m)}\)</span>。</li>
<li>训练一棵回归树 <span displaypfx="inline-" class="mathjax-container">\(f_m\)</span> 去拟合 <span displaypfx="inline-" class="mathjax-container">\((\mathbf{x}_i,r_i^{(m)})\)</span>。</li>
<li>把这棵树按学习率 <span displaypfx="inline-" class="mathjax-container">\(\nu\)</span> 缩小后加到当前模型上。</li>
<li>重复多轮，直到验证集误差不再下降，或达到预设树数。</li>
</ol>
<p><span style="background-color: #c0c0c0;">GBDT 的本质，是把复杂的函数优化问题拆成许多轮局部修正，并在每一轮用一棵小树逼近当前损失下降最快的方向。</span></p>
<div class="blog_h4"><span class="graybg">适用场景</span></div>
<ul>
<li>结构化表格任务中的强模型：广告、推荐、风控、运营排序、传统特征工程场景。</li>
<li>对特征尺度、非线性、缺失值较鲁棒；能自动学习高阶交互。</li>
<li>注意：对超参数更敏感（树深、学习率、树数、采样比例）；需要用验证集早停（Early Stopping）防止过拟合。</li>
</ul>
<div class="blog_h3"><span class="graybg">XGBoost</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>XGBoost（Extreme Gradient Boosting）是在梯度提升树（Gradient Boosting Decision Tree, GBDT）基础上的工程化强化版本。它关注的是：在保持高表达能力的同时，让树的生长过程更稳定、目标函数更明确、复杂度控制更系统。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>XGBoost 仍然采用加法模型（Additive Model）：当前模型由多棵树的输出求和得到，第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 轮只学习一棵新树 <span displaypfx="inline-" class="mathjax-container">\(f_t\)</span> 作为修正项。它的关键强化点有两条：第一，把新增树的学习写成显式的带正则优化问题；第二，对该目标做二阶近似，同时利用梯度（Gradient）和海森（Hessian）信息计算叶子输出与分裂增益。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>设第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 轮预测为 <span displaypfx="inline-" class="mathjax-container">\(\tilde y_i^{(t)}=\tilde y_i^{(t-1)}+f_t(\boldsymbol{x}_i)\)</span>。XGBoost 在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 轮优化的目标可写为：</p>
<span displaypfx="" class="mathjax-container">\[\text{Obj}^{(t)}=\sum_{i=1}^{N} \ell(y_i,\tilde y_i^{(t)})+\Omega(f_t)+\text{const}\]</span>
<p>在这个目标里， <span displaypfx="inline-" class="mathjax-container">\(\ell(y_i,\tilde y_i^{(t)})\)</span> 衡量第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个样本在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 轮更新后的预测误差， <span displaypfx="inline-" class="mathjax-container">\(\Omega(f_t)\)</span> 惩罚新树本身的复杂度， <span displaypfx="inline-" class="mathjax-container">\(\text{const}\)</span> 则表示与当前轮新树无关的常数项。也就是说，XGBoost 每一轮都在平衡“把误差降下去”和“不要把树长得太复杂”。</p>
<p>其中正则项通常取：</p>
<span displaypfx="" class="mathjax-container">\[\Omega(f)=\gamma T+\frac{\lambda}{2}\sum_{j=1}^{T}w_j^2\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 是叶子数， <span displaypfx="inline-" class="mathjax-container">\(\gamma T\)</span> 惩罚“树长出太多叶子”， <span displaypfx="inline-" class="mathjax-container">\(\frac{\lambda}{2}\sum_j w_j^2\)</span> 惩罚“每个叶子的输出值过大”。前者控制结构复杂度，后者控制数值幅度，两者合起来让模型更稳。</p>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 是叶子数， <span displaypfx="inline-" class="mathjax-container">\(w_j\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个叶子的输出， <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 控制“新增一个叶子是否值得”， <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 控制叶子输出过大的代价。对损失在当前预测附近做二阶泰勒展开，定义：</p>
<span displaypfx="" class="mathjax-container">\[g_i=\frac{\partial \ell(y_i,\tilde y_i)}{\partial \tilde y_i}\Big|_{\tilde y_i=\tilde y_i^{(t-1)}},\qquad h_i=\frac{\partial^2 \ell(y_i,\tilde y_i)}{\partial \tilde y_i^2}\Big|_{\tilde y_i=\tilde y_i^{(t-1)}}\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(g_i\)</span> 是一阶导数，表示“当前这个样本朝哪个方向改，损失下降最快”； <span displaypfx="inline-" class="mathjax-container">\(h_i\)</span> 是二阶导数，表示“这个方向有多陡、多稳定”。XGBoost 同时使用这两项信息，所以比只用一阶梯度的做法更精细。</p>
<p>则近似目标为：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\text{Obj}}^{(t)}\approx \sum_{i=1}^{N} \left(g_i f_t(\boldsymbol{x}_i)+\frac{1}{2}h_i f_t(\boldsymbol{x}_i)^2\right)+\Omega(f_t)\]</span>
<p>这条近似目标里， <span displaypfx="inline-" class="mathjax-container">\(f_t(\boldsymbol{x}_i)\)</span> 是新树在样本 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 上给出的修正值。线性项 <span displaypfx="inline-" class="mathjax-container">\(g_i f_t(\boldsymbol{x}_i)\)</span> 反映“沿当前方向修正是否有利”，二次项 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}h_i f_t(\boldsymbol{x}_i)^2\)</span> 反映“修正过大时会不会带来额外代价”。</p>
<p>若固定树结构，记落在叶子 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的样本集合为 <span displaypfx="inline-" class="mathjax-container">\(I_j\)</span>，并定义 <span displaypfx="inline-" class="mathjax-container">\(G_j=\sum_{i\in I_j}g_i\)</span>、<span displaypfx="inline-" class="mathjax-container">\(H_j=\sum_{i\in I_j}h_i\)</span>，则该叶子的最优输出为：</p>
<span displaypfx="" class="mathjax-container">\[w_j^*=-\frac{G_j}{H_j+\lambda}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(G_j\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个叶子里所有样本一阶梯度的总和， <span displaypfx="inline-" class="mathjax-container">\(H_j\)</span> 是二阶梯度总和。分子告诉模型这个叶子整体应该往哪个方向修正，分母则用二阶信息和正则项 <span displaypfx="inline-" class="mathjax-container">\(\lambda\)</span> 把修正幅度稳住。</p>
<p>某次分裂把父叶拆成左右两叶后的增益（Gain）为：</p>
<span displaypfx="" class="mathjax-container">\[\text{Gain}=\frac{1}{2}\left(\frac{G_L^2}{H_L+\lambda}+\frac{G_R^2}{H_R+\lambda}-\frac{G^2}{H+\lambda}\right)-\gamma\]</span>
<p>这个增益式子比较的是“父叶不分裂”和“分成左右两叶”谁更划算。 <span displaypfx="inline-" class="mathjax-container">\(G_L,H_L\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(G_R,H_R\)</span> 分别是左右子叶的梯度统计量， <span displaypfx="inline-" class="mathjax-container">\(G,H\)</span> 是父叶统计量；最后减去 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span>，表示每多长一个叶子都要付复杂度代价。</p>
<p>这说明 XGBoost 的分裂同时考虑“收益”和“复杂度惩罚”，并以此控制结构扩张。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>用当前模型计算每个样本的一阶梯度 <span displaypfx="inline-" class="mathjax-container">\(g_i\)</span> 与二阶梯度 <span displaypfx="inline-" class="mathjax-container">\(h_i\)</span>。</li>
<li>在候选特征与候选阈值上搜索分裂增益最大的切分。</li>
<li>固定树结构后，利用 <span displaypfx="inline-" class="mathjax-container">\(w_j^*\)</span> 计算每个叶子的最优输出。</li>
<li>把新树加入现有模型，继续下一轮迭代。</li>
<li>预测时把所有树的输出累加，再映射到分类或回归结果。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在点击率预估（CTR Prediction）中，用户、广告和上下文之间往往存在复杂交互。XGBoost 可以通过逐轮加树自动学习“什么样的用户在什么时间、看到什么样的广告更容易点击”这类组合规则，因此在工业级表格任务中长期保持强竞争力。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：表格数据表现强，目标函数清晰，正则化明确，工程生态成熟。</li>
<li>局限：超参数较多；树深、学习率、树数、采样比例之间存在明显耦合。</li>
<li>适用场景：风控、广告、推荐、排序与一般结构化表格建模。</li>
</ul>
<div class="blog_h3"><span class="graybg">LightGBM</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>LightGBM 是面向大规模数据与高维稀疏特征优化的 GBDT 实现。它关注的核心问题是：当样本量和特征维度都很大时，如何降低分裂搜索的时间与内存成本，同时尽量不牺牲精度。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>LightGBM 的两个关键设计是直方图分桶（Histogram Binning）和叶子优先生长（Leaf-wise Growth）。前者把连续特征离散到有限个桶上，后者每一步都继续分裂当前收益最大的叶子，从而在相同叶子预算下更快降低训练误差。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>在优化目标上，LightGBM 与 GBDT / XGBoost 一致，仍然是逐轮加入树来降低损失。它的区别主要体现在计算方式：若某个连续特征被离散到 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 个桶，则算法只需在桶边界上搜索分裂点，而不必在所有实数取值上逐一枚举。这样，每个节点维护的是桶级梯度统计量，而非逐样本的原始实数值。</p>
<p>叶子优先生长意味着：算法始终选择当前分裂增益最大的叶子继续向下扩展。它通常比按层生长（Level-wise）更激进，因而在同样叶子数限制下更容易得到较低训练误差，但也更容易在局部区域长出很深的树。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>将连续特征离散到有限个桶，并统计桶级梯度信息。</li>
<li>在每个节点上基于桶统计量搜索最优切分。</li>
<li>选择全局增益最大的叶子继续分裂。</li>
<li>使用最大深度、最大叶子数、最小叶子样本数等约束抑制过拟合。</li>
<li>预测时沿树路径到达叶子并累加所有树的输出。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在大规模推荐粗排任务中，输入特征通常极高维且高度稀疏。LightGBM 能在保留较强拟合能力的同时显著缩短训练时间，因此非常适合需要频繁重训的工业流水线。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：训练快、内存占用低，对大规模稀疏特征友好。</li>
<li>局限：叶子优先策略若约束不足，局部树会过深，过拟合风险更高。</li>
<li>适用场景：超大规模表格数据、稀疏特征建模、追求训练效率的工业场景。</li>
</ul>
<div class="blog_h2"><span class="graybg">概率模型</span></div>
<p>这一类方法处理的核心问题是：模型不仅要给出“预测是什么”，还要明确回答“这个结果有多可信、数据是如何生成的、隐藏结构是什么”。因此它们直接建模概率分布（Probability Distribution）、隐变量（Latent Variables）或序列依赖关系，在分类、密度估计、软聚类、序列推断与不确定性表达中具有统一优势。</p>
<div class="blog_h3"><span class="graybg">朴素贝叶斯</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>朴素贝叶斯（Naive Bayes）用于分类问题。给定特征 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span>，它要估计样本属于每个类别 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 的后验概率 <span displaypfx="inline-" class="mathjax-container">\(p(y|\boldsymbol{x})\)</span>。该方法尤其适合高维稀疏、小样本或需要快速概率基线的任务。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>朴素贝叶斯的核心假设是：在给定类别 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 的条件下，各个特征条件独立（Conditional Independence）。这个假设通常并不严格成立，但它把高维联合分布的估计问题，转化为多个一维条件分布的估计问题。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>由贝叶斯公式：</p>
<span displaypfx="" class="mathjax-container">\[p(y|\boldsymbol{x})=\frac{p(\boldsymbol{x}|y)p(y)}{p(\boldsymbol{x})}\]</span>
<p>这个贝叶斯公式把后验概率拆成三部分： <span displaypfx="inline-" class="mathjax-container">\(p(y|\boldsymbol{x})\)</span> 是看到特征后属于类别 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 的概率； <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{x}|y)\)</span> 是该类别生成这组特征的可能性； <span displaypfx="inline-" class="mathjax-container">\(p(y)\)</span> 是类别先验； <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{x})\)</span> 则是对所有类别做归一化的总证据。</p>
<p>由于 <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{x})\)</span> 对所有类别相同，分类时只需比较：</p>
<span displaypfx="" class="mathjax-container">\[p(y|\boldsymbol{x})\propto p(y)\prod_{j=1}^{d}p(x_j|y)\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\propto\)</span> 表示“成正比”。因为对同一个样本来说，分母 <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{x})\)</span> 对所有类别都相同，所以比较类别大小时只需看右边：先验 <span displaypfx="inline-" class="mathjax-container">\(p(y)\)</span> 乘上每个特征条件概率 <span displaypfx="inline-" class="mathjax-container">\(p(x_j|y)\)</span> 的连乘积。</p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(p(y)\)</span> 是先验概率（Prior）， <span displaypfx="inline-" class="mathjax-container">\(p(x_j|y)\)</span> 是条件似然（Likelihood）。根据特征类型不同，可以得到高斯朴素贝叶斯、伯努利朴素贝叶斯、多项式朴素贝叶斯等变体。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>从训练集统计每个类别的先验概率 <span displaypfx="inline-" class="mathjax-container">\(p(y)\)</span>。</li>
<li>估计每个类别下各特征的条件分布 <span displaypfx="inline-" class="mathjax-container">\(p(x_j|y)\)</span>。</li>
<li>推断时计算各类别的对数后验分数 <span displaypfx="inline-" class="mathjax-container">\(\log p(y)+\sum_j \log p(x_j|y)\)</span>。</li>
<li>选择分数最大的类别作为预测输出。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在垃圾邮件检测中，若某些词在垃圾邮件中显著更常见，那么这些词对应的条件概率会被估得更大，从而把包含这些词的邮件判到垃圾类别。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：训练快、推断快、对高维稀疏特征友好。</li>
<li>局限：条件独立假设常被破坏；概率校准有时较弱。</li>
<li>适用场景：文本分类、垃圾邮件过滤、简单可靠的概率基线。</li>
</ul>
<div class="blog_h3"><span class="graybg">高斯混合模型（GMM）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>高斯混合模型（Gaussian Mixture Model, GMM）处理的是“数据由多个潜在高斯成分混合生成”的建模问题。与 K-Means 只输出硬划分不同，GMM 希望估计每个样本属于各个簇的概率，并允许不同簇有不同的协方差结构。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>GMM 引入潜变量 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 表示样本来自哪一个高斯成分。整体分布由多个高斯分布按混合系数加权得到，因此每个样本对各个簇的归属是软的（Soft Assignment），并以概率形式表达。</p>
<p>一个直观比喻是：把数据想成混在一起的几类人群，只能看到每个人的外在特征，却看不到他原本属于哪一类。每一类人群都有自己的“中心位置”和“分散形状”，对应一个高斯成分；整个数据集则像这些人群按不同比例叠在一起形成的总体分布。与 K-Means 把每个样本硬塞进某一个簇不同，GMM 会给出“这个样本更像第 1 类，也有一部分像第 2 类”这样的软归属结果。</p>
<p>因此，混合系数 <span displaypfx="inline-" class="mathjax-container">\(\pi_k\)</span> 可以理解为各类人群在总体中的占比，均值 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\mu}_k\)</span> 是每类人群的中心，协方差 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\Sigma}_k\)</span> 描述该群体沿不同方向的扩散方式，而责任度 <span displaypfx="inline-" class="mathjax-container">\(\gamma_{ik}\)</span> 则表示“样本 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 有多大程度属于第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 类”。这也是 GMM 比 K-Means 更灵活的原因：它允许边界模糊，也允许簇具有不同大小、方向和椭球形状。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>对样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span>，GMM 的概率密度写为：</p>
<span displaypfx="" class="mathjax-container">\[p(\boldsymbol{x})=\sum_{k=1}^{K} \pi_k \mathcal{N}(\boldsymbol{x}\mid \boldsymbol{\mu}_k,\boldsymbol{\Sigma}_k)\]</span>
<p>这条式子说明 GMM 的整体密度不是由单个高斯给出，而是 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个高斯成分的加权和。 <span displaypfx="inline-" class="mathjax-container">\(\pi_k\)</span> 决定第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个成分在总体中的占比， <span displaypfx="inline-" class="mathjax-container">\(\mathcal{N}(\boldsymbol{x}\mid \boldsymbol{\mu}_k,\boldsymbol{\Sigma}_k)\)</span> 描述样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 在该成分下有多典型。</p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\pi_k\)</span> 是混合系数，满足 <span displaypfx="inline-" class="mathjax-container">\(\pi_k\ge 0\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(\sum_k \pi_k=1\)</span>。给定样本后，其属于第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个成分的责任度（Responsibility）为：</p>
<span displaypfx="" class="mathjax-container">\[\gamma_{ik}=p(z_i=k|\boldsymbol{x}_i)=\frac{\pi_k \mathcal{N}(\boldsymbol{x}_i\mid \boldsymbol{\mu}_k,\boldsymbol{\Sigma}_k)}{\sum_{j=1}^{K}\pi_j \mathcal{N}(\boldsymbol{x}_i\mid \boldsymbol{\mu}_j,\boldsymbol{\Sigma}_j)}\]</span>
<p>责任度 <span displaypfx="inline-" class="mathjax-container">\(\gamma_{ik}\)</span> 是“样本 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 属于成分 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 的后验概率”。分子表示“成分 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 解释这个样本的能力”，分母则把所有成分对该样本的解释能力加总起来做归一化，因此所有 <span displaypfx="inline-" class="mathjax-container">\(\gamma_{ik}\)</span> 加起来等于 1。</p>
<p>GMM 的参数通常通过期望最大化（Expectation-Maximization, EM）求解。E 步计算责任度，M 步更新参数：</p>
<span displaypfx="" class="mathjax-container">\[N_k=\sum_{i=1}^{N}\gamma_{ik},\qquad \pi_k=\frac{N_k}{N}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(N_k\)</span> 不是成分 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 的硬计数，而是软计数：每个样本只按自己的责任度贡献一部分。于是混合系数更新为 <span displaypfx="inline-" class="mathjax-container">\(\pi_k=\frac{N_k}{N}\)</span>，表示第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个成分在总体中的相对权重。</p>
<span displaypfx="" class="mathjax-container">\[\boldsymbol{\mu}_k=\frac{1}{N_k}\sum_{i=1}^{N}\gamma_{ik}\boldsymbol{x}_i,\qquad \boldsymbol{\Sigma}_k=\frac{1}{N_k}\sum_{i=1}^{N}\gamma_{ik}(\boldsymbol{x}_i-\boldsymbol{\mu}_k)(\boldsymbol{x}_i-\boldsymbol{\mu}_k)^\top\]</span>
<p>均值更新式说明：每个样本按责任度大小对中心 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\mu}_k\)</span> 做加权贡献；协方差更新式则是在该加权中心周围统计离散程度。责任度越大，样本对该成分的中心和形状影响越大。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>初始化混合系数、均值和协方差。</li>
<li>E 步：计算每个样本对各高斯成分的责任度。</li>
<li>M 步：根据责任度更新参数。</li>
<li>重复 E / M，直到对数似然收敛。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在用户分群中，若人群天然分成多个椭球状子群体，那么 GMM 不仅能给出簇划分，还能输出“某个用户属于每个群体的概率”，这比 K-Means 的硬分配更细致。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：软聚类、概率解释清晰、能建模不同协方差形状。</li>
<li>局限：对初始化敏感；高维时协方差估计代价高。</li>
<li>适用场景：软聚类、密度估计、带概率解释的聚类分析。</li>
</ul>
<div class="blog_h3"><span class="graybg">隐马尔可夫模型（HMM）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>隐马尔可夫模型（Hidden Markov Model, HMM）用于序列建模，尤其适合处理序列标注（Sequence Labeling）问题。序列标注的任务形式是：给定一个按时间或位置排列的输入序列，为序列中的每个位置分配一个标签。例如，在词性标注中，句子“我爱北京天安门”的每个词或字都需要对应一个词性标签；在语音识别中，一段连续声学信号需要对应到一串离散文字或音素标签。</p>
<p>HMM 是序列模型中的经典早期方法，在语音识别等领域长期具有重要地位。它的优势在于结构清晰、推断高效，能够以较低的计算成本处理序列决策；与此同时，它主要依赖局部状态转移和局部发射关系，所使用的上下文信息相对有限。</p>
<p>HMM 的建模方式是：观测序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}_{1:T}\)</span> 可以看到，但生成这些观测的状态序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{z}_{1:T}\)</span> 看不到。这里的隐藏状态通常对应词性、命名实体类别或发音状态等潜在标签。围绕这一模型，核心问题通常包括计算某段观测序列的概率、恢复最可能的隐藏状态路径，以及估计模型参数。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>HMM 假设隐藏状态满足一阶马尔可夫性（First-order Markov Property）：当前状态只依赖前一个状态；同时每个观测只依赖当前状态。一个直接的比喻是：无法直接看到远方朋友每天所处的天气，但可以持续看到他发布的活动，例如“去游泳”“去逛街”或“在家睡觉”。在这个比喻里，天气是隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(z_t\)</span>，活动是观测 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span>。HMM 的生成逻辑正是<span style="background-color: #c0c0c0;">先生成隐藏状态序列，再由每个状态发射对应观测</span>。</p>
<p>如果今天是晴天，明天仍然晴天的概率通常较高；如果今天下雨，朋友在家休息的概率通常高于去游泳。这分别对应 HMM 中的状态转移概率 <span displaypfx="inline-" class="mathjax-container">\(p(z_t|z_{t-1})\)</span> 和发射概率 <span displaypfx="inline-" class="mathjax-container">\(p(x_t|z_t)\)</span>。因此，HMM 把序列建模为一个“剧本模拟器”：先按转移规律生成一条看不见的天气轨迹，再按照每一天的天气生成看得见的活动记录。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">HMM 概念</td>
<td style="text-align: center;">比喻中的对应物</td>
<td style="text-align: center;">含义</td>
</tr>
</thead>
<tbody>
<tr>
<td>隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(z_t\)</span></td>
<td>每天的天气</td>
<td>真实存在，但观察者不能直接看到。</td>
</tr>
<tr>
<td>观测 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span></td>
<td>朋友圈里的活动</td>
<td>可以直接看到，用来反推隐藏状态。</td>
</tr>
<tr>
<td>初始分布 <span displaypfx="inline-" class="mathjax-container">\(p(z_1)\)</span></td>
<td>第一天各种天气出现的概率</td>
<td>序列从什么状态开始。</td>
</tr>
<tr>
<td>转移概率 <span displaypfx="inline-" class="mathjax-container">\(p(z_t|z_{t-1})\)</span></td>
<td>天气从今天到明天的变化规律</td>
<td>例如“晴天后仍是晴天”的概率较高。</td>
</tr>
<tr>
<td>发射概率 <span displaypfx="inline-" class="mathjax-container">\(p(x_t|z_t)\)</span></td>
<td>某种天气下出现某种活动的概率</td>
<td>例如下雨时更可能“在家睡觉”。</td>
</tr>
<tr>
<td>隐藏状态序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{z}_{1:T}\)</span></td>
<td>整段天气变化轨迹</td>
<td>模型希望恢复的潜在过程。</td>
</tr>
<tr>
<td>观测序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}_{1:T}\)</span></td>
<td>整段活动记录</td>
<td>模型的输入数据。</td>
</tr>
</tbody>
</table>
<p>这个比喻也解释了 HMM 的优势与局限。它简单、快速、可解释，因为联合概率能够拆成局部转移和局部发射的乘积，并可用动态规划高效推断。但它的条件独立假设也很强：某一天的活动只由当天的天气决定，不直接依赖前后活动。在词性标注等任务中，一个词的标签往往同时受左右上下文影响，这种假设就会限制表达能力。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>HMM 的联合分布写为：</p>
<span displaypfx="" class="mathjax-container">\[p(\boldsymbol{x}_{1:T},\boldsymbol{z}_{1:T})=p(z_1)\prod_{t=2}^{T}p(z_t|z_{t-1})\prod_{t=1}^{T}p(x_t|z_t)\]</span>
<p>这条联合分布把一整条序列拆成三部分： <span displaypfx="inline-" class="mathjax-container">\(p(z_1)\)</span> 是初始状态概率， <span displaypfx="inline-" class="mathjax-container">\(\prod_{t=2}^{T}p(z_t|z_{t-1})\)</span> 是状态转移链， <span displaypfx="inline-" class="mathjax-container">\(\prod_{t=1}^{T}p(x_t|z_t)\)</span> 是每个状态发射观测的概率。正因为有这种乘积分解，HMM 才能用动态规划高效求解。</p>
<p>若记初始分布为 <span displaypfx="inline-" class="mathjax-container">\(\pi\)</span>，转移矩阵为 <span displaypfx="inline-" class="mathjax-container">\(A\)</span>，发射分布为 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>，则前向算法（Forward Algorithm）的核心量为：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_t(j)=p(x_{1:t},z_t=j)\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(\alpha_t(j)\)</span> 的含义是：看到前 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个观测，同时当前时刻状态恰好是第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个状态的联合概率。它把“历史观测信息”和“当前落在哪个状态”压缩成一个可递推的中间量。</p>
<p>其递推为：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_t(j)=\left[\sum_i \alpha_{t-1}(i)A_{ij}\right]B_j(x_t)\]</span>
<p>递推式可以分成两步看：先对所有上一步状态 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 求和，用 <span displaypfx="inline-" class="mathjax-container">\(A_{ij}\)</span> 累积“从 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 转到 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的可能性”；再乘上 <span displaypfx="inline-" class="mathjax-container">\(B_j(x_t)\)</span>，表示当前状态 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 发射出观测 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 的概率。</p>
<p>最可能状态路径可由维特比算法（Viterbi Algorithm）求解；参数估计通常采用 Baum-Welch 算法，即 HMM 上的 EM。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>定义状态空间、转移概率和发射概率。</li>
<li>若参数已知，用前向 / 后向算法做概率计算，用维特比算法做解码。</li>
<li>若参数未知，用 Baum-Welch 在训练集上迭代估计参数。</li>
<li>根据任务输出状态路径、序列概率或边缘状态分布。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在词性标注中，观测是单词序列，隐藏状态是词性标签。HMM 会同时利用“某个词在某种词性下更常见”的发射规律和“词性之间如何转移”的序列规律完成整体解码。</p>
<p>例如，对序列“我 / 爱 / 北京 / 天安门”，模型看到的是词本身，看不到的是背后的标签序列。HMM 会比较多种候选路径，如“代词（Pronoun）→ 动词（Verb）→ 专有名词（Proper Noun）→ 专有名词（Proper Noun）”与其他不合理组合的概率。由于“我”更容易由代词状态发射，“爱”更容易由动词状态发射，而“代词后接动词、动词后接名词性成分”又符合常见转移规律，这条标签路径就会获得更高概率。这个过程可以理解为：模型一边根据词本身猜标签，一边检查整条词性路径是否顺畅。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/hmm.jpg"><img class="alignnone size-full wp-image-41251" src="https://blog.gmem.cc/wp-content/uploads/2026/03/hmm.jpg" alt="hmm" width="1408" height="768" /></a></p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：序列结构清晰，动态规划推断高效，可解释性强。</li>
<li>局限：一阶马尔可夫假设较强，表达能力有限。</li>
<li>适用场景：基础序列标注、时间状态切换建模、中小规模序列任务。</li>
</ul>
<div class="blog_h3"><span class="graybg">条件随机场（CRF）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>条件随机场（Conditional Random Field, CRF）主要用于结构化预测（Structured Prediction），尤其是序列标注。它处理的问题是：在给定输入序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 的条件下，如何联合预测输出标签序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}\)</span>，并显式建模标签之间的依赖关系。</p>
<p>CRF 可以看作对 HMM 的进一步发展：它不再试图解释输入序列如何被生成，而是直接在给定输入的条件下预测最合理的标签结构。通过引入全局特征和更灵活的上下文依赖，CRF 缓解了 HMM 仅依赖局部独立假设所带来的信息不足。在深度学习广泛进入 NLP 之前，CRF 长期是各类标注任务中的核心方法。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>CRF 的核心不是描述数据如何生成，而是在给定观测序列的条件下，对整条标签序列进行全局评分。仍以天气和活动的比喻来理解：已知一整周的活动记录后，CRF 不会先假设天气如何一步步生成活动，而是把各种可能的天气序列都拿来比较，判断哪一种与这组活动整体最一致。它建模的是条件分布 <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{y}|\boldsymbol{x})\)</span>，而不是 HMM 那样的联合分布 <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{x},\boldsymbol{y})\)</span>。</p>
<p>这种“全局把控”体现在两个层面。第一，CRF 的打分对象是完整的标签路径，而不是每个位置互相独立的局部决策；第二，打分依据可以是灵活定义的特征函数（Feature Function）。例如，若连续三天都出现“游泳”，对应“连续晴天”的标签组合就应得到更高分；若一周中出现“滑雪”这类活动，与“夏天”一致的天气标签组合就应被显著压低。特征函数可以同时利用当前位置、前后邻域以及相邻标签之间的组合关系。</p>
<p>因此，CRF 可以视为一个<span style="background-color: #c0c0c0;">面向整条序列的打分系统</span>：输入固定，模型比较不同标签序列的相对合理性，并选择得分最高的一条。它的优势是能够充分利用复杂上下文和标签依赖，在序列标注任务中通常比 HMM 更准确；代价是训练和推断都更重，需要计算整条序列上的归一化与动态规划。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>线性链 CRF 的条件分布通常写为：</p>
<span displaypfx="" class="mathjax-container">\[p(\boldsymbol{y}|\boldsymbol{x})=\frac{1}{Z(\boldsymbol{x})}\exp\left(\sum_{t=1}^{T}\sum_k \lambda_k f_k(y_{t-1},y_t,\boldsymbol{x},t)\right)\]</span>
<p>这个式子最好分三层看。先看最里面的 <span displaypfx="inline-" class="mathjax-container">\(\sum_{t=1}^{T}\sum_k \lambda_k f_k(y_{t-1},y_t,\boldsymbol{x},t)\)</span>：它表示对整条标签序列逐位置累积特征得分。再看外面的指数 <span displaypfx="inline-" class="mathjax-container">\(\exp(\cdot)\)</span>：它把“总得分”变成一个始终为正的数，而且总得分越大，这个数就越大。最后再除以 <span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span>，把这些正数正规化成概率。因此，CRF 的计算顺序可以理解为：<span style="background-color: #c0c0c0;">先打分，再变成正权重，最后归一化成概率</span>。</p>
<p>为了看清楚 <span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span> 在做什么，可以先临时把分子记成一个“未归一化分数”：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{p}(\boldsymbol{y}|\boldsymbol{x})=\exp\left(\sum_{t=1}^{T}\sum_k \lambda_k f_k(y_{t-1},y_t,\boldsymbol{x},t)\right)\]</span>
<p>这里特意加波浪号，是为了强调它<span style="background-color: #c0c0c0;">还不是概率</span>。原因很简单：把所有可能标签序列的 <span displaypfx="inline-" class="mathjax-container">\(\tilde{p}(\boldsymbol{y}|\boldsymbol{x})\)</span> 加起来，结果一般不会恰好等于 1。它只是每条路径的相对权重，表达“这条标签路径有多合理”。</p>
<p>这时配分函数（Partition Function）就出现了：</p>
<span displaypfx="" class="mathjax-container">\[Z(\boldsymbol{x})=\sum_{\boldsymbol{y}} \exp\left(\sum_{t=1}^{T}\sum_k \lambda_k f_k(y_{t-1},y_t,\boldsymbol{x},t)\right)\]</span>
<p>之所以你会觉得它“和上面那个公式一样”，是因为它确实就是<span style="background-color: #c0c0c0;">把上式分子对所有可能的标签序列整体求和</span>。上面的 <span displaypfx="inline-" class="mathjax-container">\(p(\boldsymbol{y}|\boldsymbol{x})\)</span> 针对的是某一条固定标签序列 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}\)</span>；而这里的 <span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span> 不是看某一条路径，而是把所有可能路径都算一遍，再全部加起来。所以它不是“另一条几乎一样的公式”，而是“分子在全标签空间上的总和”。</p>
<p>于是条件概率就变成：</p>
<span displaypfx="" class="mathjax-container">\[p(\boldsymbol{y}|\boldsymbol{x})=\frac{\tilde{p}(\boldsymbol{y}|\boldsymbol{x})}{Z(\boldsymbol{x})}\]</span>
<p>现在这个式子就容易理解了：某条路径的概率，等于“这条路径自己的权重”除以“所有路径权重的总和”。这和 softmax 的归一化逻辑完全一致，只不过 softmax 是在有限个类别上归一化，而 CRF 是在所有可能的标签序列上归一化。</p>
<p>配分函数这个名字来自统计物理，但在这里不需要物理背景也能理解：它本质上就是一个<span style="background-color: #c0c0c0;">归一化常数</span>。没有它，模型只能说“路径 A 比路径 B 更合理”；有了它，模型才能进一步说“路径 A 的概率是多少”。也正因为 <span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span> 需要把所有可能标签路径都考虑进去，CRF 训练时才必须借助动态规划，而不能暴力枚举。</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(f_k(y_{t-1},y_t,\boldsymbol{x},t)\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个特征函数，描述当前位置、相邻标签和输入之间的某种局部模式。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\lambda_k\)</span>：该特征的权重；越大表示模型越重视这个模式。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span>：固定输入序列；在讨论 <span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span> 时，它不变。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}\)</span>：某一条候选标签序列；计算 <span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span> 时要对所有可能的 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}\)</span> 求和。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sum_{t=1}^{T}\sum_k \lambda_k f_k(\cdot)\)</span>：整条标签序列的总得分。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\exp(\cdot)\)</span>：把总得分映射成正权重，并放大高分路径与低分路径之间的差异。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(Z(\boldsymbol{x})\)</span>：所有候选标签路径未归一化权重的总和，用于把相对权重变成概率。</li>
</ul>
<p>训练时最大化条件对数似然，解码时用维特比算法寻找最优标签序列。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>定义特征函数，描述输入与标签、标签与标签之间的关系。</li>
<li>用前向后向算法计算配分函数与梯度。</li>
<li>通过梯度法优化参数 <span displaypfx="inline-" class="mathjax-container">\(\lambda_k\)</span>。</li>
<li>推断时用维特比算法输出最优标签序列。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在命名实体识别（NER）中，局部分类器可能会把某个词单独判成人名，但 CRF 会进一步考虑相邻标签是否合法，从而减少孤立的局部误判。这里“是否合法”指的不是语法合法，而是<span style="background-color: #c0c0c0;">标签序列是否符合该任务定义下允许出现的邻接模式</span>。例如，在常见的 BIO 标注体系中， <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 表示 Begin，即实体开始； <span displaypfx="inline-" class="mathjax-container">\(I\)</span> 表示 Inside，即实体内部； <span displaypfx="inline-" class="mathjax-container">\(O\)</span> 表示 Outside，即不属于任何实体。也有一些任务使用 BIOES 标注体系，其中 <span displaypfx="inline-" class="mathjax-container">\(E\)</span> 表示 End， <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 表示 Single。与 BIO 相比，BIOES 会把实体结束位置和单字实体显式标出来，因此边界信息更细。于是，标签 <span displaypfx="inline-" class="mathjax-container">\(B\text{-}PER\)</span> 表示一个人名实体的开始， <span displaypfx="inline-" class="mathjax-container">\(I\text{-}PER\)</span> 表示该人名实体的内部， <span displaypfx="inline-" class="mathjax-container">\(O\)</span> 表示不属于任何实体。于是， <span displaypfx="inline-" class="mathjax-container">\(B\text{-}PER\rightarrow I\text{-}PER\)</span> 是常见且合法的相邻转移， <span displaypfx="inline-" class="mathjax-container">\(O\rightarrow B\text{-}PER\)</span> 也合法；但 <span displaypfx="inline-" class="mathjax-container">\(O\rightarrow I\text{-}PER\)</span> 通常不合法，因为一个实体内部标签不能无缘无故直接开始，前面必须先有对应的开始标签。同样， <span displaypfx="inline-" class="mathjax-container">\(B\text{-}LOC\rightarrow I\text{-}PER\)</span> 这类“实体类型突然不一致”的连接也通常应被压低分数。</p>
<p>例如，在句子“张三 在 北京 工作”中，若任务要识别人名（PER）和地点（LOC），一个合理的 BIO 标注序列可能是“张三 / 在 / 北京 / 工作”对应 <span displaypfx="inline-" class="mathjax-container">\(B\text{-}PER, O, B\text{-}LOC, O\)</span>。这里首先需要区分两件事：<span style="background-color: #c0c0c0;">某个位置更像哪一种实体类型</span>，以及<span style="background-color: #c0c0c0;">这些局部判断连起来是否构成一条合理的标签序列</span>。前者主要来自输入本身提供的证据，例如“张三”在词形上很像中文人名，“北京”本身强烈像地点名词，而“在 北京 工作”这种上下文也会继续加强“北京是地点”的判断。传统 CRF 会把这些信息写成特征函数，例如“当前词是否常见于人名词表”“当前词是否带有地名后缀”“左邻词是否是介词‘在’”“右邻词是否是动作词‘工作’”等；每个特征都会给某个候选标签加分或减分。</p>
<p>CRF 的作用是在这些局部类型证据之上，再做一次全局一致性的联合解码。换言之，实体类型 A 还是 B，并不是和 CRF 完全无关；但也不是由 CRF 凭空决定的。更准确地说，<span style="background-color: #c0c0c0;">实体类型的语义判断主要来自输入特征，CRF 负责把这些局部判断放到整条序列里统一协调</span>。例如，如果“北京”这个位置单看局部证据时，对 LOC 的分数高于 PER，那么 CRF 会倾向保留“地点”这一判断；但它还会进一步检查，当前位置前后的标签连接是否自然。如果某条候选路径把“张三”切成 <span displaypfx="inline-" class="mathjax-container">\(B\text{-}PER, O\)</span>，或者把“北京”接成 <span displaypfx="inline-" class="mathjax-container">\(B\text{-}PER\rightarrow I\text{-}LOC\)</span>，即使某个局部位置的分数不低，整条路径仍会因为边界断裂或类型转移不一致而被整体压低。于是 CRF 做的不是单点分类，而是“局部类型打分 + 全局路径约束”的联合决策。</p>
<p>从工程实现上看，这个分工在不同年代的模型里表现形式不同。在传统 CRF 中，“局部类型打分”通常来自人工设计的特征模板，例如当前词、前后词、词性、字形、是否出现在人名词典或地名词典中；CRF 再把这些手工特征组合成整条序列的全局分数。在 BiLSTM-CRF 或 BERT-CRF 这类现代模型中，局部证据不再主要依赖手工模板，而是先由 BiLSTM 或 BERT 生成上下文化表示（Contextual Representation），再由线性层给出每个位置对各标签的局部分数，最后仍由 CRF 层负责建模标签转移和整条路径解码。也就是说，上游编码器主要回答“这个位置像什么类型”，CRF 层主要回答“这些位置判断拼在一起是否构成一条最合理的标签序列”。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/crf-1.jpg"><img class="alignnone size-full wp-image-41263" src="https://blog.gmem.cc/wp-content/uploads/2026/03/crf-1.jpg" alt="crf-1" width="1408" height="768" /></a></p>
<p>类似的全局打分思想也可以推广到依存句法分析（Dependency Parsing, DEP）这类结构化任务。句子中的每个词都需要找到自己的中心词（head），模型的目标不是孤立地决定“这个词连向谁”，而是评估整棵依存树是否合理。例如，在“她 喜欢 自然语言处理”中，“喜欢”通常更可能作为中心谓词，“她”依附到“喜欢”形成主谓关系，“自然语言处理”整体依附到“喜欢”形成宾语关系。若某个局部决策把“她”错误地连到“自然语言处理”，单看两个词的局部相似度未必很低，但放到整棵树的全局结构中就会显得不协调。CRF 的价值正体现在这里：它通过全局归一化和结构约束，偏好整体验证一致的输出结构，而不是一组彼此冲突的局部最优决策。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/crf-2.jpg"><img class="alignnone size-full wp-image-41257" src="https://blog.gmem.cc/wp-content/uploads/2026/03/crf-2.jpg" alt="crf-2" width="1408" height="768" /></a></p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：适合结构化输出，能显式编码标签依赖。</li>
<li>局限：训练与推断成本高于普通独立分类器。</li>
<li>适用场景：序列标注、分词、命名实体识别等条件结构化预测任务。</li>
</ul>
<div class="blog_h2"><span class="graybg">近邻模型</span></div>
<p>这一类方法处理的核心问题是：在缺少可靠全局函数形式时，是否可以直接依赖“局部相似样本通常有相似输出”这一假设完成预测。近邻模型不急于学习一个显式参数化函数，而是把相似性度量（Similarity Metric）本身作为建模中心：先找邻居，再由邻居投票、平均或加权得到结果。</p>
<div class="blog_h3"><span class="graybg">K 近邻（KNN）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>K 近邻（K-Nearest Neighbors, KNN）用于分类与回归。给定一个待预测样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span>，它要根据训练集中与其最相似的样本来决定输出。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>KNN 的基本假设是局部平滑性（Local Smoothness）：在特征空间中彼此接近的样本，往往具有相近的标签或数值。它不显式学习参数化模型，训练集本身就是局部比较的参照集。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>给定距离函数 <span displaypfx="inline-" class="mathjax-container">\(d(\boldsymbol{x},\boldsymbol{x}')\)</span> 与邻居数 <span displaypfx="inline-" class="mathjax-container">\(K\)</span>，若 <span displaypfx="inline-" class="mathjax-container">\(N_K(\boldsymbol{x})\)</span> 表示 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个最近邻，则分类时可写为：</p>
<span displaypfx="" class="mathjax-container">\[\hat y=\mathrm{mode}\left(\{y_i:(\boldsymbol{x}_i,y_i)\in N_K(\boldsymbol{x})\}\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(N_K(\boldsymbol{x})\)</span> 是样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个最近邻集合，花括号中收集的是这些邻居的标签， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{mode}(\cdot)\)</span> 则返回出现次数最多的类别。也就是说，KNN 分类本质上就是“看最近的邻居们大多数是谁”。</p>
<p>回归时常取邻居标签平均：</p>
<span displaypfx="" class="mathjax-container">\[\hat y=\frac{1}{K}\sum_{(\boldsymbol{x}_i,y_i)\in N_K(\boldsymbol{x})}y_i\]</span>
<p>回归版 KNN 只是把“多数投票”换成“数值平均”。 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{K}\sum\)</span> 表示把最近 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个邻居的输出做均值，因此预测值会受到局部邻域中所有数值样本的共同影响。</p>
<p>若距离使用欧氏距离，则默认所有特征尺度可比，因此标准化（Standardization）通常是必要前处理。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>训练阶段几乎不做参数学习，只保存全部训练样本。</li>
<li>推断时计算待测样本到训练样本的距离。</li>
<li>选出最近的 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个样本。</li>
<li>分类取多数投票，回归取平均或距离加权平均。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在简单的手写数字识别中，如果一张新图片与训练集中大量“3”的图像都很接近，而与“8”的图像明显更远，那么 KNN 会依据局部邻域投票把它判为 3。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：概念简单、无需复杂训练、局部非线性表达能力强。</li>
<li>局限：推断成本高；对特征尺度和无关特征敏感；维度灾难会削弱距离判别力。</li>
<li>适用场景：中小规模数据、快速基线、基于相似度的简单分类与回归。</li>
</ul>
<div class="blog_h2"><span class="graybg">聚类</span></div>
<p>聚类处理的核心问题是：在没有标签的前提下，如何仅根据样本之间的几何关系、密度结构或层次关系，把数据自动分成若干组。这里不存在唯一正确的“簇”定义：K-Means 假设簇围绕中心分布，层次聚类强调多粒度组织结构，DBSCAN / HDBSCAN 则把簇理解为高密度连通区域。因此，聚类算法的选择本质上是在选择“什么样的结构应被视为同一类”。</p>
<div class="blog_h3"><span class="graybg">K-Means</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>K-Means 处理的是无监督聚类问题：给定一组没有标签的样本，希望把它们分成 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个簇，使同一簇内样本尽量接近，不同簇之间尽量分开。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>K-Means 用“每个簇由一个中心点代表”的方式近似数据分布。算法不断重复两件事：把样本分配给最近的中心；再用簇内样本均值更新中心。它本质上是在最小化簇内平方误差。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/k-means.jpg"><img class="alignnone size-full wp-image-41287" src="https://blog.gmem.cc/wp-content/uploads/2026/03/k-means.jpg" alt="k-means" width="1024" height="559" /></a></p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>目标函数为：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\{c_k\},\{z_i\}} \sum_{i=1}^{N} \|\boldsymbol{x}_i-c_{z_i}\|_2^2\]</span>
<p>这个目标里， <span displaypfx="inline-" class="mathjax-container">\(c_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个簇中心， <span displaypfx="inline-" class="mathjax-container">\(z_i\)</span> 表示样本 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 被分到哪个簇， <span displaypfx="inline-" class="mathjax-container">\(\|\boldsymbol{x}_i-c_{z_i}\|_2^2\)</span> 是样本到所属簇中心的平方距离。K-Means 想做的，就是让所有样本离各自中心都尽可能近。</p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(c_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个簇中心， <span displaypfx="inline-" class="mathjax-container">\(z_i\in\{1,\dots,K\}\)</span> 表示样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}_i\)</span> 属于哪个簇。固定簇分配时，最优中心是该簇样本均值：</p>
<span displaypfx="" class="mathjax-container">\[c_k=\frac{1}{|S_k|}\sum_{\boldsymbol{x}_i\in S_k} \boldsymbol{x}_i\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(S_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个簇里所有样本的集合， <span displaypfx="inline-" class="mathjax-container">\(|S_k|\)</span> 是该簇样本数。这个更新式说明簇中心就是簇内样本的算术平均，因此 K-Means 的“中心”确实是均值意义上的代表点。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>初始化 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个簇中心。</li>
<li>分配步骤：把每个样本分到最近中心。</li>
<li>更新步骤：用各簇样本均值更新中心。</li>
<li>重复迭代直到簇分配稳定或目标下降很小。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在用户分群中，若特征是消费频次与客单价，K-Means 往往会自动形成“高频低客单”“低频高客单”“中频中客单”等若干均值中心明确的群体。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：实现简单、可扩展、训练速度快。</li>
<li>局限：需要预先指定 <span displaypfx="inline-" class="mathjax-container">\(K\)</span>；对初始化与离群点敏感；不适合非凸簇。</li>
<li>适用场景：簇近似球形、需要快速聚类或作为预处理的任务。</li>
</ul>
<div class="blog_h3"><span class="graybg">层次聚类</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>层次聚类（Hierarchical Clustering）输出的是一个由粗到细的聚类层次结构，因此簇数可以在观察树状图后再决定。它适合需要观察“簇是如何逐步合并或拆分”的任务。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>凝聚式层次聚类（Agglomerative Clustering）从每个样本单独成簇开始，每一步合并当前最相近的两个簇；分裂式层次聚类则从一个大簇开始不断拆分。实践中更常见的是凝聚式版本。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/h-clustering.jpg"><img class="alignnone size-full wp-image-41293" src="https://blog.gmem.cc/wp-content/uploads/2026/03/h-clustering.jpg" alt="h-clustering" width="1024" height="559" /></a></p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>关键在于定义簇间距离。常见联接准则（Linkage Criteria）包括：</p>
<span displaypfx="" class="mathjax-container">\[d_{\text{single}}(A,B)=\min_{\boldsymbol{x}\in A,\boldsymbol{y}\in B} d(\boldsymbol{x},\boldsymbol{y})\]</span>
<p>单链距离只看两簇之间最近的那一对点，因此它很容易把“靠得最近的局部桥梁”连起来，适合发现链式结构，但也更容易被噪声点串联。</p>
<span displaypfx="" class="mathjax-container">\[d_{\text{complete}}(A,B)=\max_{\boldsymbol{x}\in A,\boldsymbol{y}\in B} d(\boldsymbol{x},\boldsymbol{y})\]</span>
<p>完全链距离只看两簇之间最远的那一对点，因此它会避免把跨度过大的簇合并到一起，更偏好紧凑、直径较小的簇。</p>
<span displaypfx="" class="mathjax-container">\[d_{\text{average}}(A,B)=\frac{1}{|A||B|}\sum_{\boldsymbol{x}\in A,\boldsymbol{y}\in B} d(\boldsymbol{x},\boldsymbol{y})\]</span>
<p>平均链则对两簇之间所有点对的距离取平均。这里 <span displaypfx="inline-" class="mathjax-container">\(|A||B|\)</span> 是点对总数，因此它不只看最近点或最远点，而是综合考虑两簇整体的平均接近程度。</p>
<p>不同联接方式对应不同簇形偏好：单链更容易形成链式簇，完全链更偏向紧凑簇，平均链则居中。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>初始化每个样本为单独一个簇。</li>
<li>计算簇间距离矩阵。</li>
<li>反复合并距离最近的两个簇，并更新距离矩阵。</li>
<li>得到树状图（Dendrogram）后，在某个高度切开即可获得聚类结果。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在文档聚类中，层次聚类不仅能区分“体育”“财经”“科技”等大类，还能进一步展示每一大类内部的细粒度层级关系。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：无需预先指定簇数；可以输出多层次聚类结构。</li>
<li>局限：计算与存储成本较高；早期合并错误通常无法回退。</li>
<li>适用场景：中小规模数据、需要树状关系解释的聚类分析。</li>
</ul>
<div class="blog_h3"><span class="graybg">DBSCAN</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>DBSCAN（Density-Based Spatial Clustering of Applications with Noise）用于识别任意形状的高密度簇，并显式发现噪声点。它尤其适合处理非球形簇与含离群点数据。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>DBSCAN 通过局部密度定义簇。若一个点周围半径 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 内有足够多的邻居，则它是核心点（Core Point）；核心点可以把周围密度可达（Density-Reachable）的样本不断扩展成一个簇。既不够密、又不属于任何核心点邻域的样本被视为噪声。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/dbscan.jpg"><img class="alignnone size-full wp-image-41305" src="https://blog.gmem.cc/wp-content/uploads/2026/03/dbscan.jpg" alt="dbscan" width="1024" height="559" /></a></p>
<p>&nbsp;</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>记 <span displaypfx="inline-" class="mathjax-container">\(N_{\epsilon}(\boldsymbol{x})\)</span> 为点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 邻域。若：</p>
<span displaypfx="" class="mathjax-container">\[|N_{\epsilon}(\boldsymbol{x})|\ge \text{minPts}\]</span>
<p>判断核心点只需看两件事： <span displaypfx="inline-" class="mathjax-container">\(N_{\epsilon}(\boldsymbol{x})\)</span> 是点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 在半径 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 内的邻域集合， <span displaypfx="inline-" class="mathjax-container">\(|N_{\epsilon}(\boldsymbol{x})|\)</span> 是邻域里有多少点。只要这个数量不小于 <span displaypfx="inline-" class="mathjax-container">\(\text{minPts}\)</span>，就说明该点周围密度足够高，可以作为簇扩张的核心。</p>
<p>则 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 是核心点。若某点落在某个核心点邻域中但自身并非核心点，则为边界点（Border Point）；不属于任何簇的点为噪声（Noise）。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>遍历未访问样本，计算其 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 邻域。</li>
<li>若邻居数少于 <span displaypfx="inline-" class="mathjax-container">\(\text{minPts}\)</span>，则暂记为噪声或边界候选。</li>
<li>若为核心点，则以它为起点递归扩展所有密度可达的点。</li>
<li>重复直到所有样本都被标记。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在地理位置聚类中，餐馆、商圈、交通枢纽附近的点位通常形成形状复杂的密集区域。DBSCAN 可以识别这些非凸热点，同时把零散孤立点保留为噪声。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：无需预设簇数；可识别任意形状簇；对离群点鲁棒。</li>
<li>局限：对 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\text{minPts}\)</span> 敏感；难以同时适配不同密度簇。</li>
<li>适用场景：空间聚类、热点区域发现、非凸簇与带噪声数据。</li>
</ul>
<div class="blog_h3"><span class="graybg">HDBSCAN</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>HDBSCAN（Hierarchical DBSCAN）用于缓解 DBSCAN 的单一密度阈值问题。它面对的核心困难是：真实数据中的簇密度常常并不一致，用一组固定的 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\text{minPts}\)</span> 很难同时兼顾所有簇。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>HDBSCAN 会在多个密度尺度上构建层次结构，再从中选出稳定簇（Stable Clusters）作为结果。这让它比 DBSCAN 更适合处理密度差异显著的数据。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/hdbscan-intuition.png"><img class="alignnone size-full wp-image-41327" src="https://blog.gmem.cc/wp-content/uploads/2026/03/hdbscan-intuition.png" alt="hdbscan-intuition" width="3045" height="12714" /></a></p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>给定参数 <span displaypfx="inline-" class="mathjax-container">\(k\)</span>，先定义核心距离（Core Distance）为点到其第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 近邻的距离，再定义互可达距离（Mutual Reachability Distance）：</p>
<span displaypfx="" class="mathjax-container">\[d_{\text{mreach},k}(\boldsymbol{x},\boldsymbol{y})=\max\big(\text{core}_k(\boldsymbol{x}),\text{core}_k(\boldsymbol{y}),d(\boldsymbol{x},\boldsymbol{y})\big)\]</span>
<p>互可达距离把三个量取最大值：点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 的核心距离、点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}\)</span> 的核心距离，以及它们之间的原始距离。这样做的效果是：低密度区域的点会被“拉远”，从而更清楚地暴露不同密度簇之间的结构边界。</p>
<p>随后算法在互可达图上构建最小生成树（Minimum Spanning Tree），再转成聚类层次，并依据簇稳定性选择最终输出。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>计算所有点的核心距离。</li>
<li>基于互可达距离构造图并求最小生成树。</li>
<li>从图中得到随密度变化的层次聚类结构。</li>
<li>在压缩树（Condensed Tree）上选择稳定簇作为结果。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在用户行为嵌入空间中，有些兴趣群体很紧密，有些较松散。HDBSCAN 可以同时保留这两类簇，而不需要强行用同一个密度阈值描述它们。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：对多密度簇更稳健，仍保留噪声识别能力。</li>
<li>局限：实现更复杂，计算与内存开销通常高于 DBSCAN。</li>
<li>适用场景：嵌入聚类、用户分群、不同密度簇共存的数据。</li>
</ul>
<div class="blog_h2"><span class="graybg">升维</span></div>
<p>升维（Feature Expansion / Lifting）处理的问题，与降维正好互补。降维试图把高维表示压缩到更低维空间，以减少冗余、噪声与计算量；升维则试图把原始特征映射到一个更高维的表示空间，使原本难以表达、难以分离或难以拟合的结构，在新空间里变得更容易处理。它的目标不是“把维度变大本身”，而是<span style="background-color: #c0c0c0;">通过增加表示自由度，把非线性关系改写为更容易由简单模型处理的形式</span>。</p>
<div class="blog_h3"><span class="graybg">背景和问题定义</span></div>
<p>许多经典机器学习模型本体是线性的，例如线性回归、逻辑回归、线性支持向量机（Support Vector Machine, SVM）。它们直接在原始输入空间中学习一个线性决策函数或线性预测函数。若数据关系本身高度非线性，那么模型能力可能不足。升维的思路因此是：先把输入映射为一个更高维的新表示，再在新表示上使用线性模型。模型形式仍然简单，但由于工作空间改变了，整体表达能力会显著提升。</p>
<p>更一般地，若原始输入为 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\in \mathbb{R}^d\)</span>，则升维映射可写成：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\boldsymbol{x}}=\phi(\boldsymbol{x}),\qquad \phi: \mathbb{R}^d\to \mathbb{R}^D,\qquad D&gt;d\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 是原始输入； <span displaypfx="inline-" class="mathjax-container">\(\tilde{\boldsymbol{x}}\)</span> 是升维后的新特征； <span displaypfx="inline-" class="mathjax-container">\(\phi(\boldsymbol{x})\)</span> 是特征映射； <span displaypfx="inline-" class="mathjax-container">\(D&gt;d\)</span> 表示新的表示空间维度高于原空间维度。若后续模型写成 <span displaypfx="inline-" class="mathjax-container">\(f(\boldsymbol{x})=\boldsymbol{w}^\top \tilde{\boldsymbol{x}}+b\)</span>，那么它虽然在新空间中仍然是线性的，但在原始输入 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 上通常已经对应一个非线性函数。</p>
<div class="blog_h3"><span class="graybg">核心思想</span></div>
<p>升维的直观含义，是把原来“纠缠在一起”的关系展开。例如在二维平面里，某些数据可能无法被一条直线分开；但若把 <span displaypfx="inline-" class="mathjax-container">\((x_1,x_2)\)</span> 映射成 <span displaypfx="inline-" class="mathjax-container">\((x_1,x_2,x_1^2,x_2^2,x_1x_2)\)</span> 这样的更高维特征，原本的弯曲边界就可能对应高维空间中的一个超平面。于是复杂性并没有消失，而是被转移到了特征映射 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\boldsymbol{x}}=\phi(\boldsymbol{x})\)</span> 上。</p>
<p>这也是经典机器学习里一个非常常见的套路：<span style="background-color: #c0c0c0;">先做特征构造或特征展开，再用结构简单、优化稳定的线性模型</span>。因此，升维常常并不以“升维”这个名字出现，而是以多项式特征、基函数展开、核方法、one-hot 编码、N-gram 稀疏特征、随机特征等形式出现。</p>
<div class="blog_h3"><span class="graybg">公式和详细解释</span></div>
<p>升维后的线性模型通常写成：</p>
<span displaypfx="" class="mathjax-container">\[f(\boldsymbol{x})=\boldsymbol{w}^\top \tilde{\boldsymbol{x}}+b\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\boldsymbol{x}}\)</span> 是由原始输入 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 构造出来的高维特征向量； <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}\in \mathbb{R}^D\)</span> 是新空间中的参数； <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是偏置。关键点在于：模型在 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\boldsymbol{x}}\)</span> 空间里仍然是线性的，但如果 <span displaypfx="inline-" class="mathjax-container">\(\phi(\boldsymbol{x})\)</span> 含有平方项、交叉项、基函数或核映射，那么 <span displaypfx="inline-" class="mathjax-container">\(f(\boldsymbol{x})\)</span> 相对于原始输入 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 就会表现出非线性。</p>
<p>以二次多项式特征为例，若原始输入为 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}=(x_1,x_2)\)</span>，则可构造：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\boldsymbol{x}}=(x_1,x_2,x_1^2,x_2^2,x_1x_2)\]</span>
<p>此时线性模型</p>
<span displaypfx="" class="mathjax-container">\[f(\boldsymbol{x})=w_1x_1+w_2x_2+w_3x_1^2+w_4x_2^2+w_5x_1x_2+b\]</span>
<p>在参数上仍然是线性的，但在输入上已经能够表达二次曲面或二次决策边界。这正是升维最核心的数学作用：<span style="background-color: #c0c0c0;">把原空间中的非线性关系，改写成高维空间中的线性关系</span>。</p>
<div class="blog_h3"><span class="graybg">常见升维方式</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">方式</td>
<td style="text-align: center;">升维形式</td>
<td style="text-align: center;">核心作用</td>
<td style="text-align: center;">典型场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>多项式特征</td>
<td>加入平方项、立方项、交叉项</td>
<td>把低阶线性模型扩展为可拟合非线性关系</td>
<td>线性回归、逻辑回归、线性分类器</td>
</tr>
<tr>
<td>基函数展开</td>
<td>高斯基、样条基、傅里叶基等</td>
<td>用一组预定义函数把局部或周期结构显式展开</td>
<td>回归、广义加性模型、核近似</td>
</tr>
<tr>
<td>核方法</td>
<td>通过核函数隐式映射到高维甚至无限维空间</td>
<td>在不显式展开特征的情况下提升可分性</td>
<td>SVM、核岭回归、核 PCA</td>
</tr>
<tr>
<td>One-hot 编码</td>
<td>把离散类别映射为高维稀疏向量</td>
<td>让类别变量进入线性模型并保持类别独立性</td>
<td>表格特征、推荐、广告、点击率预估</td>
</tr>
<tr>
<td>N-gram / 词袋</td>
<td>把文本映射为高维稀疏词项空间</td>
<td>显式展开局部共现与组合模式</td>
<td>传统文本分类、检索、朴素贝叶斯、线性 SVM</td>
</tr>
<tr>
<td>随机特征</td>
<td>用随机映射近似某些核空间</td>
<td>在显式高维表示与核方法之间做折中</td>
<td>Random Fourier Features、核近似</td>
</tr>
</tbody>
</table>
<p>其中，核方法是经典机器学习里最有代表性的升维思想。以 SVM 为例，若定义特征映射 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\boldsymbol{x}}=\phi(\boldsymbol{x})\)</span>，则线性分类器可写成 <span displaypfx="inline-" class="mathjax-container">\(f(\boldsymbol{x})=\boldsymbol{w}^\top \tilde{\boldsymbol{x}}+b\)</span>。核技巧（Kernel Trick）并不显式构造 <span displaypfx="inline-" class="mathjax-container">\(\tilde{\boldsymbol{x}}\)</span>，而是直接通过核函数</p>
<span displaypfx="" class="mathjax-container">\[K(\boldsymbol{x},\boldsymbol{x}')=\phi(\boldsymbol{x})^\top \phi(\boldsymbol{x}')\]</span>
<p>计算升维后特征空间中的内积。这样既保留了高维映射的表达力，又避免了显式展开到巨大维度的代价。也正因为如此，SVM 是很多人最先感受到“升维威力”的经典模型。</p>
<div class="blog_h3"><span class="graybg">应用实例</span></div>
<p>在圆形可分但线性不可分的数据中，原始二维平面上一条直线无法把内圈与外圈分开；但若加入半径相关的二次特征，例如 <span displaypfx="inline-" class="mathjax-container">\(x_1^2+x_2^2\)</span>，则问题可以转写为更高维空间中的线性分离。文本任务里，N-gram 稀疏特征同样是一种典型升维：原始句子是离散序列，经过词袋或 N-gram 展开后，就变成了数万维甚至更高维的稀疏向量，随后再交给逻辑回归、朴素贝叶斯或线性 SVM 处理。</p>
<p>表格任务中的类别变量处理也体现了同样思想。一个城市字段看起来只是一个离散取值，但 one-hot 编码后，它会被展开成一个高维稀疏向量，使模型能够为每个类别学习独立参数。推荐系统、广告点击率预估、工业风控中的大规模离散特征，长期都高度依赖这种升维方式。</p>
<div class="blog_h3"><span class="graybg">为什么经典机器学习更谨慎地升维</span></div>
<p>经典机器学习并不是很少使用升维，而是更谨慎。原因在于，维度一旦上去，过拟合风险、存储开销与计算开销都会迅速增加，这就是维度灾难（Curse of Dimensionality）的典型体现。于是经典方法常把“升维”与“控制复杂度”配套使用：一边通过特征展开提升表达能力，一边用正则化（Regularization）、特征选择（Feature Selection）或后续降维来抑制过拟合。</p>
<p>因此，在经典机器学习里，常见工作流并不是“只升维”或“只降维”，而是两者交替配合：先做有针对性的特征展开，让结构变得更容易表达；再通过正则化、筛选或压缩保留真正有效的部分。升维是在展开表达能力，降维是在压缩冗余信息，它们并不是对立操作，而是围绕表示空间做的两种互补控制。</p>
<div class="blog_h3"><span class="graybg">维度灾难</span></div>
<p>维度灾难（Curse of Dimensionality）指的是：当特征维度不断升高时，许多在低维空间中直观、有效的统计与几何规律会迅速恶化，导致数据需求、计算成本与建模难度同时上升。它不是某一个单独问题，而是一组高维效应的统称，包括样本空间体积指数级膨胀、样本变得极其稀疏、局部邻域难以稳定估计、距离与密度统计的判别力下降，以及模型更容易用复杂边界去记忆训练集。</p>
<p>其中一个最重要的现象确实是：<span style="background-color: #c0c0c0;">高维里距离往往会变得不再像低维那样有区分力</span>。直观地说，当维度很多时，样本之间的最近距离和最远距离可能越来越接近，导致“谁是真正近邻”这件事变得没那么清晰。依赖距离或局部邻域的方法，例如 KNN、聚类、核密度估计、局部异常检测等，往往会因此退化。这也是为什么高维数据上，距离度量、标准化、特征筛选和嵌入学习会变得格外重要。</p>
<p>但“高维更容易过拟合”并不只因为距离失去意义。更根本的原因是：当维度升高后，表示空间的自由度与可容纳的划分方式急剧增加，而训练样本相对于整个空间显得越来越稀疏。模型于是更容易找到一些只对训练集成立、却不能推广到新样本的偶然边界或偶然相关性。换句话说，<span style="background-color: #c0c0c0;">距离退化主要伤害的是邻域、相似度与密度估计；过拟合风险上升则更直接地来自空间稀疏化、参数自由度增加和有效样本覆盖不足</span>。</p>
<p>这也是为什么经典机器学习在做升维时往往必须同步引入约束：一方面用特征展开提升表达能力，另一方面通过正则化（Regularization）、特征选择（Feature Selection）、降维（Dimensionality Reduction）或更强的数据先验，限制模型不要把高维空间当成“背题空间”。因此，维度灾难并不是在说“高维一定不好”，而是在提醒：<span style="background-color: #c0c0c0;">维度每增加一层，模型就需要更多数据、更强归纳偏置和更谨慎的复杂度控制，才能让新增维度真正转化为有效表达能力</span>。</p>
<div class="blog_h3"><span class="graybg">和深度学习中的升维关系</span></div>
<p>深度学习中的很多操作，本质上也在做升维。Transformer 的前馈网络（Feed-Forward Network, FFN / MLP）常把 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 升到更大的 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{ff}}\)</span>，再降回原维度；这与经典机器学习里“先展开、再用简单变换处理”的思想是一脉相承的。差别在于，经典方法里的升维往往是人工设计或固定形式的，而深度学习中的升维通常是可学习的线性投影与非线性组合。</p>
<p>因此，若从表示学习角度看，升维在机器学习中并不罕见。SVM 的核空间、逻辑回归的多项式特征、文本的 N-gram 稀疏向量、推荐系统的 one-hot 离散展开，以及 Transformer MLP 中的中间维度扩张，本质上都属于同一条主线：<span style="background-color: #c0c0c0;">把原本难以处理的关系，展开到一个更容易表达与分离的表示空间里</span>。</p>
<div class="blog_h2"><span class="graybg">降维</span></div>
<p>降维处理的核心问题是：高维数据往往包含冗余、相关性与噪声，既增加计算成本，也削弱可视化与建模稳定性。目标不是简单“删维度”，而是在压缩表示的同时尽量保留有用结构——这个“有用”可以是方差、类别可分性、局部邻域、全局流形，具体取决于所采用的方法。</p>
<div class="blog_h3"><span class="graybg">主成分分析（PCA）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>主成分分析（Principal Component Analysis, PCA）处理的是线性降维问题：在尽量保留数据主要变化信息的前提下，把高维样本映射到更低维空间。它常用于压缩维度、去相关、可视化与噪声抑制。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/pca.jpg"><img class="alignnone size-full wp-image-41347" src="https://blog.gmem.cc/wp-content/uploads/2026/03/pca.jpg" alt="pca" width="1024" height="559" /></a></p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>PCA 用方差（Variance）近似衡量信息量。若数据在某个方向上的投影变化很大，说明该方向承载了更多结构；于是 PCA 选择能最大化投影方差的一组正交方向作为新的表示基底。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>给定中心化后的数据矩阵 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{X}_c\in\mathbb{R}^{N\times d}\)</span>，协方差矩阵为：</p>
<span displaypfx="" class="mathjax-container">\[\boldsymbol{\Sigma}=\frac{1}{N}\boldsymbol{X}_c^\top \boldsymbol{X}_c\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{X}_c\)</span> 是已经减去均值后的数据矩阵， <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{X}_c^\top \boldsymbol{X}_c\)</span> 汇总了各个特征之间如何共同变化；再除以 <span displaypfx="inline-" class="mathjax-container">\(N\)</span>，就得到平均意义下的协方差矩阵 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\Sigma}\)</span>。PCA 正是从这个矩阵里找“变化最大的方向”。</p>
<p>第一主成分对应的优化问题为：</p>
<span displaypfx="" class="mathjax-container">\[\max_{\boldsymbol{u}} \quad \boldsymbol{u}^\top \boldsymbol{\Sigma}\boldsymbol{u} \quad \text{s.t.} \quad \|\boldsymbol{u}\|_2=1\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{u}\)</span> 是候选投影方向， <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{u}^\top \boldsymbol{\Sigma}\boldsymbol{u}\)</span> 表示数据投影到该方向后的方差，约束 <span displaypfx="inline-" class="mathjax-container">\(\|\boldsymbol{u}\|_2=1\)</span> 则防止通过把向量无限放大来虚增方差。于是这个优化问题真正寻找的是“单位长度下最能保留变化信息的方向”。</p>
<p>其解是协方差矩阵最大特征值对应的特征向量。取前 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个主成分组成矩阵 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{U}_k\)</span> 后，样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span> 的低维表示为：</p>
<span displaypfx="" class="mathjax-container">\[\boldsymbol{z}=\boldsymbol{U}_k^\top(\boldsymbol{x}-\boldsymbol{\mu})\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\mu}\)</span> 是原始数据均值， <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}-\boldsymbol{\mu}\)</span> 先把样本中心化， <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{U}_k\)</span> 由前 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个主成分方向组成，最终 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{z}\)</span> 就是样本在这组主方向上的低维坐标。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>对数据做中心化，必要时再做标准化。</li>
<li>计算协方差矩阵或直接对数据矩阵做奇异值分解（SVD）。</li>
<li>取前 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个主成分方向。</li>
<li>把数据投影到这些方向上得到低维表示。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在人脸图像压缩中，大量像素变化往往由少数全局因素驱动。PCA 可以用少量主成分保留大部分有效变化，从而显著降低特征维度。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：线性、稳定、可解释，常用作预处理和可视化。</li>
<li>局限：只能捕捉线性结构；对异常值敏感。</li>
<li>适用场景：线性降维、特征压缩、去相关与噪声过滤。</li>
</ul>
<div class="blog_h3"><span class="graybg">线性判别分析（LDA）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>线性判别分析（Linear Discriminant Analysis, LDA）用于监督降维与分类。与 PCA 只看输入分布不同，LDA 利用类别标签寻找一个投影空间，使同类样本尽量聚集、异类样本尽量分开。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/lda.jpg"><img class="alignnone size-full wp-image-41349" src="https://blog.gmem.cc/wp-content/uploads/2026/03/lda.jpg" alt="lda" width="1024" height="559" /></a></p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>LDA 同时考虑类内散度（Within-class Scatter）和类间散度（Between-class Scatter）。好的投影方向应当让类间距离大、类内波动小，因此它优化的核心是判别性。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>定义类内散度矩阵：</p>
<span displaypfx="" class="mathjax-container">\[\boldsymbol{S}_W=\sum_{k=1}^{C}\sum_{\boldsymbol{x}_i\in \mathcal{C}_k}(\boldsymbol{x}_i-\boldsymbol{\mu}_k)(\boldsymbol{x}_i-\boldsymbol{\mu}_k)^\top\]</span>
<p>类内散度矩阵 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{S}_W\)</span> 统计的是“同一类内部有多分散”。 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{C}_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 类的样本集合， <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\mu}_k\)</span> 是该类均值。若类内样本围绕各自均值分布得很紧， <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{S}_W\)</span> 就会较小。</p>
<p>类间散度矩阵：</p>
<span displaypfx="" class="mathjax-container">\[\boldsymbol{S}_B=\sum_{k=1}^{C}N_k(\boldsymbol{\mu}_k-\boldsymbol{\mu})(\boldsymbol{\mu}_k-\boldsymbol{\mu})^\top\]</span>
<p>类间散度矩阵 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{S}_B\)</span> 统计的是“各类中心彼此有多分开”。其中 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\mu}\)</span> 是全局均值， <span displaypfx="inline-" class="mathjax-container">\(N_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 类样本数，因此样本多的大类会对类间结构贡献更大权重。</p>
<p>LDA 寻找投影向量 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}\)</span> 使 Fisher 判别准则最大：</p>
<span displaypfx="" class="mathjax-container">\[J(\boldsymbol{w})=\frac{\boldsymbol{w}^\top \boldsymbol{S}_B \boldsymbol{w}}{\boldsymbol{w}^\top \boldsymbol{S}_W \boldsymbol{w}}\]</span>
<p>Fisher 准则的分子衡量“投影后类间有多分开”，分母衡量“投影后类内有多混在一起”。因此 <span displaypfx="inline-" class="mathjax-container">\(J(\boldsymbol{w})\)</span> 越大，说明方向 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}\)</span> 越有助于把不同类别拉开、同时保持同类紧凑。</p>
<p>该问题可转化为广义特征值问题 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{S}_B\boldsymbol{w}=\lambda \boldsymbol{S}_W\boldsymbol{w}\)</span>。对 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 类问题，最多能得到 <span displaypfx="inline-" class="mathjax-container">\(C-1\)</span> 个有效判别方向。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>根据标签统计每一类的均值与全局均值。</li>
<li>构造类内散度矩阵和类间散度矩阵。</li>
<li>求解广义特征值问题，选择主要判别方向。</li>
<li>将样本投影到低维判别子空间中做分类或可视化。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在手写数字分类中，LDA 关注的是“哪些方向最有助于把不同数字分开”，因此在有标签监督时，它往往比只最大化总体方差的 PCA 更适合分类前降维。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：利用标签信息，投影方向更有判别性。</li>
<li>局限：线性假设较强；类内散度矩阵可能奇异。</li>
<li>适用场景：有标签监督的降维、分类前特征压缩、判别性可视化。</li>
</ul>
<div class="blog_h3"><span class="graybg">t-SNE</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>t-SNE（t-distributed Stochastic Neighbor Embedding）主要用于二维或三维可视化。它主要关注如何在低维图上尽量保留高维空间中的局部邻域关系。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>t-SNE 把“样本彼此接近”转写为概率相似度：在高维空间中，接近的样本应当有较大概率互为邻居；在低维嵌入中，也希望这种邻近关系继续成立。优化的目标是让两种邻域概率分布尽可能接近。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>在高维空间中，先定义邻域概率 <span displaypfx="inline-" class="mathjax-container">\(p_{ij}\)</span>；在低维空间中，对嵌入点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}_i\)</span> 定义：</p>
<span displaypfx="" class="mathjax-container">\[q_{ij}=\frac{(1+\|\boldsymbol{y}_i-\boldsymbol{y}_j\|_2^2)^{-1}}{\sum_{a\ne b}(1+\|\boldsymbol{y}_a-\boldsymbol{y}_b\|_2^2)^{-1}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}_i\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{y}_j\)</span> 是低维嵌入坐标，分子把距离越近的点赋予越大的相似度，分母则把所有点对的相似度加总起来做归一化，因此 <span displaypfx="inline-" class="mathjax-container">\(q_{ij}\)</span> 可以被解释成低维空间里的邻域概率。</p>
<p>t-SNE 最小化高维邻域分布与低维邻域分布之间的 KL 散度：</p>
<span displaypfx="" class="mathjax-container">\[\text{KL}(P\|Q)=\sum_{i\ne j} p_{ij} \log \frac{p_{ij}}{q_{ij}}\]</span>
<p>KL 散度衡量的是“低维邻域分布 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 与高维邻域分布 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 相差多少”。若某对样本在高维里很近，即 <span displaypfx="inline-" class="mathjax-container">\(p_{ij}\)</span> 很大，但在低维里被拉得太开， <span displaypfx="inline-" class="mathjax-container">\(q_{ij}\)</span> 就会过小，从而产生较大惩罚。</p>
<p>低维中采用重尾 Student-t 分布，主要是为了缓解拥挤问题（Crowding Problem），使远处点更容易被拉开。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>根据高维距离计算样本间的邻域概率。</li>
<li>随机初始化低维坐标。</li>
<li>计算低维相似度 <span displaypfx="inline-" class="mathjax-container">\(q_{ij}\)</span>。</li>
<li>通过梯度下降最小化 KL 散度并更新低维坐标。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在词向量或图像嵌入可视化中，t-SNE 常把语义相近的样本压到局部团簇中，从而帮助研究者直观看到表示空间里是否出现了合理分群。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：二维可视化效果强，局部邻域保持能力好。</li>
<li>局限：全局距离与簇间相对位置不稳定；对超参数和随机初始化敏感。</li>
<li>适用场景：嵌入可视化、表示质量诊断、探索性数据分析。</li>
</ul>
<div class="blog_h3"><span class="graybg">UMAP</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>UMAP（Uniform Manifold Approximation and Projection）同样用于低维可视化与非线性降维。它面对的任务与 t-SNE 类似，但更强调在保留局部结构的同时，尽量维持一定的全局几何关系，并提升速度与可扩展性。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/umap.jpg"><img class="alignnone size-full wp-image-41351" src="https://blog.gmem.cc/wp-content/uploads/2026/03/umap.jpg" alt="umap" width="1024" height="559" /></a></p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>UMAP 先在高维空间构建一个加权近邻图，把数据视为流形（Manifold）上的离散采样；随后在低维空间中寻找一张新图，使低维图与高维图的模糊连通结构尽量一致。它本质上是在匹配两张图的连通结构，而不只是在匹配两组欧氏距离。</p>
<p>这里的流形可以先从一个标准定义理解：设样本位于高维空间 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^D\)</span> 中，若它们实际上集中在某个低维集合 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{M}\subseteq\mathbb{R}^D\)</span> 附近，并且对 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{M}\)</span> 上任一点，都能在一个足够小的邻域内用 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 维坐标平滑描述，其中 <span displaypfx="inline-" class="mathjax-container">\(d\ll D\)</span>，那么这个集合就可以看作一个 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 维流形。流形的关键性质不是“它弯不弯”，而是<span style="background-color: #c0c0c0;">它在局部看起来像低维欧氏空间，在全局上则可以嵌入到更高维空间并发生弯曲、卷曲或拉伸</span>。</p>
<p>在数据分析里，这个概念表示：虽然原始特征有很多维，但样本并没有真正填满整个高维空间，而是分布在某种低维结构附近。例如一组人脸图像在像素空间里维度极高，但由姿态、光照、表情等少数潜在因素变化时，样本往往只覆盖其中一个低维子区域。UMAP 的出发点正是：若数据主要几何结构由这个低维流形决定，那么局部近邻关系比“全局欧氏直线距离”更能反映真实结构。</p>
<p>“离散采样”则表示：真实流形 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{M}\)</span> 本身是连续对象，但我们手里只有有限个样本点 <span displaypfx="inline-" class="mathjax-container">\(\{x_i\}_{i=1}^N\)</span>。UMAP 通过近邻图近似这些点在流形上的局部连通关系，再在低维空间中寻找一组坐标 <span displaypfx="inline-" class="mathjax-container">\(\{y_i\}_{i=1}^N\)</span>，使这种局部连通关系尽量被保留下来。因此它不是直接恢复整张流形，而是在有限样本层面恢复流形的邻域结构。</p>
<p>与 t-SNE 相比，UMAP 更强调“样本来自某个低维流形，并且近邻图是在近似这个流形的局部连通结构”；t-SNE 的核心对象则更偏向“高维邻域概率分布与低维邻域概率分布的匹配”。两者都重视局部关系，但 UMAP 的表述更几何化，t-SNE 的表述更概率化。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>若高维图边权为 <span displaypfx="inline-" class="mathjax-container">\(p_{ij}\)</span>，低维图边权常写为：</p>
<span displaypfx="" class="mathjax-container">\[q_{ij}=\frac{1}{1+a\|\boldsymbol{y}_i-\boldsymbol{y}_j\|_2^{2b}}\]</span>
<p>UMAP 用这个函数把低维距离转换成连通强度。 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 控制曲线形状：距离很近时 <span displaypfx="inline-" class="mathjax-container">\(q_{ij}\)</span> 接近 1，距离变远时迅速衰减到接近 0，因此它可以把“近邻关系”转写成平滑的图边权。</p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(a,b\)</span> 决定低维距离与连接强度的映射形状。UMAP 的优化目标通常是高维图与低维图之间的交叉熵（Cross-Entropy）：既鼓励高维中相连的点在低维中也靠近，也鼓励高维中不相连的点在低维中适当分开。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>计算近邻图，并为边赋予模糊连通权重。</li>
<li>初始化低维坐标。</li>
<li>通过随机优化最小化高维图与低维图之间的交叉熵。</li>
<li>得到二维或三维嵌入用于可视化或下游分析。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在单细胞测序、文本嵌入或推荐向量分析中，UMAP 常被用来把高维表示映射到二维平面，从而观察群体结构、类别分布与异常点。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：通常比 t-SNE 更快，较好兼顾局部与部分全局结构。</li>
<li>局限：嵌入结果仍依赖超参数与随机种子；二维距离不能机械等同于原空间距离。</li>
<li>适用场景：大规模嵌入可视化、非线性降维、聚类前的低维表示。</li>
</ul>
<div class="blog_h2"><span class="graybg">异常检测</span></div>
<p>异常检测处理的核心问题是：正常样本通常大量存在，而异常样本稀少、形态多变、甚至在训练阶段根本拿不到完整标签。模型因此不再主要学习“类别之间如何区分”，而是学习“什么算正常”以及“偏离正常结构有多严重”。不同方法对“异常”的定义并不相同：有的依赖隔离难易度，有的依赖局部密度，有的学习正常区域边界。</p>
<div class="blog_h3"><span class="graybg">什么叫异常</span></div>
<p>在业务语境里，“异常”并不等于“数值特别大”或“离均值很远”。更准确地说，异常是<span style="background-color: #c0c0c0;">相对于当前业务规则、历史模式或同类群体而言，不应当出现、很少出现、或者一旦出现就值得额外关注的样本</span>。因此异常是一个“相对概念”，必须依赖参照系：相对于谁、在哪个时间段、在什么上下文里、以什么代价衡量。</p>
<p>例如，单笔消费 5000 元在全国范围内不一定异常，但若这位用户平时只在本地便利店做几十元交易，而这次交易突然发生在异地、高风险设备、深夜时段，并伴随支付习惯突变，那么它在风控上就可能是异常。类似地，服务器 CPU 使用率 85% 在大促期间可能是正常负载，在凌晨低峰却可能意味着任务堆积；工厂传感器温度轻微升高若同时伴随振动模式变化，也可能预示故障正在形成。</p>
<p>这说明业务上的异常通常至少包含三类含义。第一类是<span style="background-color: #c0c0c0;">统计稀有</span>：样本落在低概率区域。第二类是<span style="background-color: #c0c0c0;">行为失配</span>：它与该对象自己的历史模式不一致。第三类是<span style="background-color: #c0c0c0;">群体失配</span>：它在全局上不一定极端，但相对于同类群体显著不同。异常检测算法的差异，本质上就在于它们分别更擅长刻画哪一种“失配”。</p>
<p>因此，做异常检测时首先要回答的不是“用哪种模型”，而是“业务到底把什么视为异常”。若异常意味着“明显稀少且容易与大部队分开”，隔离式方法更合适；若异常意味着“在本地邻域里显得稀疏”，应优先考虑密度比较；若正常样本边界清楚、异常样本类型杂乱，则更适合只学习正常区域。算法不是在定义业务，而是在实现业务已经确定的异常标准。</p>
<div class="blog_h3"><span class="graybg">孤立森林（Isolation Forest）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>孤立森林（Isolation Forest）用于无监督异常检测：在没有可靠异常标签时，仅根据数据分布本身识别“容易被孤立”的异常样本。它尤其适合高维表格数据与大规模检测场景。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>孤立森林利用一个非常直接的直觉：异常点通常更稀少、更孤立，因此在随机切分下更容易被提早隔离。路径越短，越可能是异常；路径越长，越像处于正常群体内部。若把正常样本理解为“扎堆生活在高密度区域里的大群体”，那么它们往往需要经过很多次随机切割才会被单独分离出来；而那些落在边缘、稀疏区域、或与主体分布明显脱节的样本，只需少量切分就会被单独留在某个叶节点里。</p>
<p>这种思路和距离或密度方法非常不同。K 近邻（K-Nearest Neighbors, KNN）或局部异常因子（Local Outlier Factor, LOF）会显式比较“离别人有多远”或“局部密度有多低”；孤立森林则直接把异常检测改写成一个更具操作性的判据：<span style="background-color: #c0c0c0;">一个样本在随机树里平均多快会被单独隔开</span>。因此它并不先估计复杂的概率分布，也不依赖全局距离结构，而是用“隔离难易度”来近似刻画异常性。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/random-forest.png"><img class="alignnone size-full wp-image-41363" src="https://blog.gmem.cc/wp-content/uploads/2026/03/random-forest.png" alt="random-forest" width="1600" height="1308" /></a></p>
<div class="blog_h4"><span class="graybg">图示解读</span></div>
<p>图中的示意数据由两个主要正常簇和若干离散分布的异常点构成。背景等高线展示的是孤立森林学到的“异常分数地形”：颜色越深，表示该区域样本平均路径长度越长，更接近模型眼中的正常高密度区域；颜色越浅，表示样本更容易在随机切分中被提早隔离，因此更接近潜在异常区域。</p>
<p>图中圆形点对应被模型判为正常的样本，它们主要聚集在两个深色中心附近；叉号对应被模型判为异常的样本，它们更多分布在边缘、簇间空白带或浅色区域。这种可视化非常适合帮助理解孤立森林的工作方式：它并不是在画一条严格的几何边界，而是在表达“哪里更容易被随机树迅速切出来”。因此，等高线的深浅更接近一种“隔离难度地图”，而不是传统分类器意义上的硬分类边界。</p>
<div class="blog_h4"><span class="graybg">隔离树是什么</span></div>
<p>隔离树（Isolation Tree, iTree）可以理解成一棵专门为了“把样本逐步切开”而构造的随机二叉树。它与决策树（Decision Tree）在外形上相似，但目标完全不同：决策树是在找最有区分力的切分规则，隔离树则故意随机选择一个特征，再在该特征当前取值范围内随机选择一个切分点，把样本递归分到左右子节点。</p>
<p>设当前节点包含样本集合 <span displaypfx="inline-" class="mathjax-container">\(S\)</span>，随机选到的特征为第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 维，其切分阈值为 <span displaypfx="inline-" class="mathjax-container">\(\tau\)</span>，则一次切分可写成</p>
<span displaypfx="" class="mathjax-container">\[S_{\mathrm{left}}=\{\boldsymbol{x}\in S\mid x_j&lt;\tau\},\qquad S_{\mathrm{right}}=\{\boldsymbol{x}\in S\mid x_j\ge \tau\}\]</span>
<p>递归继续进行，直到某个节点只剩下 1 个样本，或所有样本在当前节点上已经无法再被有效区分。对一个本来就远离主体、所在区域又很稀疏的样本而言，随机切分往往只需要很少几步就能把它单独留在某个叶节点中；而对处在高密度正常群体内部的样本，通常要经过更多次切分才能被单独隔离。隔离树因此并不显式估计概率密度，而是把“异常”转写为“被随机切分提早单独分离”的难易度。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>对样本 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{x}\)</span>，记它在一棵隔离树中的路径长度为 <span displaypfx="inline-" class="mathjax-container">\(h(\boldsymbol{x})\)</span>。在多棵树上取平均路径长度 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[h(\boldsymbol{x})]\)</span> 后，异常分数定义为：</p>
<span displaypfx="" class="mathjax-container">\[s(\boldsymbol{x},n)=2^{-\mathbb{E}[h(\boldsymbol{x})]/c(n)}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(h(\boldsymbol{x})\)</span> 是样本在一棵树里被隔离所需的路径长度， <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[h(\boldsymbol{x})]\)</span> 是在整片森林中的平均值， <span displaypfx="inline-" class="mathjax-container">\(c(n)\)</span> 是针对样本规模 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 的归一化常数。路径越短，指数里的值越小，异常分数 <span displaypfx="inline-" class="mathjax-container">\(s(\boldsymbol{x},n)\)</span> 就越接近 1。</p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(c(n)\)</span> 是平均路径长度的归一化常数，用来把不同样本规模下的路径长度放到可比较的尺度上。它常写为 <span displaypfx="inline-" class="mathjax-container">\(c(n)=2H_{n-1}-2(n-1)/n\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(H_{n-1}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(n-1\)</span> 个调和数（Harmonic Number）。若某点明显比普通样本更早被切分隔离，则其平均路径长度更小，异常分数更接近 1。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>从训练集中随机抽取多个子样本。</li>
<li>为每个子样本构建随机隔离树。</li>
<li>对待测点计算其在所有树中的平均路径长度。</li>
<li>将平均路径长度映射为异常分数。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在交易风控中，若某条交易在金额、时间、地点、设备等多个维度上都明显偏离正常模式，它往往能在随机切分下被较早隔离出来，因此会获得更高异常分数。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：无需显式估计密度，也不依赖两两距离矩阵；因此在大规模数据上通常比基于邻域或密度的方法更高效。</li>
<li>优点：对高维表格数据较友好，对随机噪声通常也有较强鲁棒性，因为最终判断来自多棵随机树上的平均隔离行为，而不是某一次局部切分。</li>
<li>局限：对特征编码和特征尺度的业务含义仍然敏感；若异常与正常高度混叠、或者异常本身并不更容易被切开，隔离优势会下降。</li>
<li>适用场景：风控、日志异常、设备故障、指标监控等无监督异常检测，尤其适合作为高维表格场景中的强基线。</li>
</ul>
<div class="blog_h3"><span class="graybg">局部异常因子（LOF）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>局部异常因子（Local Outlier Factor, LOF）主要解决“全局上不远，但在局部邻域中显著稀疏”的异常检测问题。当数据不同区域密度差异很大时，只看全局距离通常不够稳定。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>LOF 比较的是“这个点相对于其邻居是否更稀疏”。若一个点周围的局部密度显著低于邻居自己的局部密度，则它更像局部异常。它识别的不是“全局上最远的点”，而是<span style="background-color: #c0c0c0;">相对于自己所在局部环境显得不协调的点</span>。因此，当不同区域本来就有不同密度时，LOF 往往比只看全局距离的方法更稳。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/lof.png"><img class="alignnone size-full wp-image-41371" src="https://blog.gmem.cc/wp-content/uploads/2026/03/lof.png" alt="lof" width="1000" height="800" /></a></p>
<div class="blog_h4"><span class="graybg">图示解读</span></div>
<p>图中的实心圆点表示样本点，围绕样本点的空心圆圈大小表示该点的 LOF 分数大小。圆圈越小，说明该点的局部密度与其邻居相近，更像正常簇中的内部样本；圆圈越大，说明该点所在位置相对于邻居显得更稀疏，因此更可能是局部异常点。</p>
<p>这张图直观展示了 LOF 的核心判断方式：它并不先问“这个点离全局中心有多远”，而是先问“这个点和自己周围那一圈邻居相比，是不是显得过于稀疏”。因此，大圆圈对应的未必是全局最远的点，而更可能是那些<span style="background-color: #c0c0c0;">周围邻居仍然较密、但它自己明显脱离了局部密度水平</span>的点。</p>
<p>若把这张示意图看作两个高密度簇与少量随机噪声点的组合，则邻居数 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 决定了算法观察“局部环境”的尺度。 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 较小时，模型更敏感于非常局部的扰动； <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 较大时，密度比较更平滑，但也可能削弱对细粒度异常的敏感性。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>给定邻居数 <span displaypfx="inline-" class="mathjax-container">\(k\)</span>，设 <span displaypfx="inline-" class="mathjax-container">\(N_k(\boldsymbol{p})\)</span> 表示点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{p}\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个最近邻集合。LOF 的计算可以拆成三层：先算可达距离（Reachability Distance），再算局部可达密度（Local Reachability Density, LRD），最后比较邻居密度与自身密度，得到局部异常因子（Local Outlier Factor, LOF）。</p>
<p>先定义可达距离（Reachability Distance）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{rd}_k(\boldsymbol{p},\boldsymbol{o})=\max\big(\text{k-distance}(\boldsymbol{o}),d(\boldsymbol{p},\boldsymbol{o})\big)\]</span>
<p>可达距离会把两个量取最大值：邻居点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{o}\)</span> 自己的第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 邻距离，以及点对之间的真实距离。这样做可以防止极近的点对把局部密度估得过于夸张，使密度估计更稳。</p>
<p>再定义局部可达密度（Local Reachability Density, LRD）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{lrd}_k(\boldsymbol{p})=\left(\frac{1}{|N_k(\boldsymbol{p})|}\sum_{\boldsymbol{o}\in N_k(\boldsymbol{p})} \mathrm{rd}_k(\boldsymbol{p},\boldsymbol{o})\right)^{-1}\]</span>
<p>局部可达密度 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{lrd}_k(\boldsymbol{p})\)</span> 本质上是“平均可达距离”的倒数：平均距离越小，周围越拥挤，密度越大；平均距离越大，周围越稀疏，密度越小。</p>
<p>最终的 LOF 分数为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{LOF}_k(\boldsymbol{p})=\frac{1}{|N_k(\boldsymbol{p})|}\sum_{\boldsymbol{o}\in N_k(\boldsymbol{p})} \frac{\mathrm{lrd}_k(\boldsymbol{o})}{\mathrm{lrd}_k(\boldsymbol{p})}\]</span>
<p>LOF 分数比较的是“邻居的局部密度”和“自己本身的局部密度”。若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{lrd}_k(\boldsymbol{p})\)</span> 明显小于邻居的密度，分数就会大于 1，说明该点相对周围环境显得更孤立。更具体地说，分子是邻居局部密度的平均水平，分母是点 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{p}\)</span> 自己的局部密度，因此这个比值本质上是在问：<span style="background-color: #c0c0c0;">你周围的人都很挤，而你自己是不是站得太空</span>。</p>
<p>结果通常可以这样解读：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{LOF}_k(\boldsymbol{p})\approx 1\)</span>：该点的局部密度与邻居相近，通常属于正常样本。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{LOF}_k(\boldsymbol{p})&lt;1\)</span>：该点甚至比邻居更密集，往往处于簇的核心区域。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{LOF}_k(\boldsymbol{p})&gt;1\)</span>：该点局部密度低于邻居，越大越像异常点。</li>
</ul>
<p>因此，LOF 不是在回答“这个点离全局中心远不远”，而是在回答“这个点在自己的局部邻域里是不是显得过于稀疏”。这也是它能识别局部异常、却对距离度量与邻居数 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 较敏感的根本原因。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>为每个样本找到 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个近邻。</li>
<li>计算可达距离与局部可达密度。</li>
<li>比较样本与邻居的局部密度，得到 LOF 分数。</li>
<li>按分数排序或设置阈值输出异常点。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在消费行为数据中，某一线城市用户的高消费在全国范围内未必异常，但在“同年龄、同区域、同收入”的邻域里可能明显偏离。LOF 正是通过这种局部密度比较识别这类异常。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：适合不同密度区域共存的数据，能识别“全局上不极端、但局部上明显失配”的异常。</li>
<li>优点：解释性较强，因为 LOF 分数直接来自“邻居密度 / 自身密度”的局部比较，便于回答异常是相对于谁显得异常。</li>
<li>局限：对距离度量、标准化和邻居数 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 很敏感； <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 太小会放大噪声，太大又可能抹平真正的局部异常。</li>
<li>局限：大规模近邻搜索成本较高，因此在超大数据上常需要索引加速或近似近邻方法配合。</li>
<li>适用场景：局部离群检测、消费异常、群体内部行为异常、同类用户或设备群体中的行为失配识别。</li>
</ul>
<div class="blog_h3"><span class="graybg">单类支持向量机（One-Class SVM）</span></div>
<div class="blog_h4"><span class="graybg">背景和问题定义</span></div>
<p>单类支持向量机（One-Class Support Vector Machine, One-Class SVM）用于“只有正常样本、缺少可靠异常样本”的异常检测任务。它的目标是学习一个描述正常样本区域的边界。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>One-Class SVM 通过核映射把样本送到高维特征空间，再寻找一个把大多数正常样本与原点分开的超平面。等价地看，它学习的是一个“包住正常样本”的高维边界，边界外的点更可能是异常。</p>
<div class="blog_h4"><span class="graybg">算法公式和详细解释</span></div>
<p>一种标准形式为：</p>
<span displaypfx="" class="mathjax-container">\[\min_{\boldsymbol{w},\rho,\boldsymbol{\xi}} \frac{1}{2}\|\boldsymbol{w}\|_2^2+\frac{1}{\nu N}\sum_{i=1}^{N} \xi_i-\rho\]</span>
<p>One-Class SVM 的目标由三部分组成： <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{2}\|\boldsymbol{w}\|_2^2\)</span> 控制边界不要太复杂， <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{\nu N}\sum_i \xi_i\)</span> 惩罚落在边界外或靠得太近的样本， <span displaypfx="inline-" class="mathjax-container">\(-\rho\)</span> 则鼓励把正常区域尽量向外推开。 <span displaypfx="inline-" class="mathjax-container">\(\nu\)</span> 越大，对违约样本的容忍度越高。</p>
<span displaypfx="" class="mathjax-container">\[\text{s.t.} \quad \boldsymbol{w}^\top \phi(\boldsymbol{x}_i) \ge \rho-\xi_i,\qquad \xi_i \ge 0\]</span>
<p>这些约束表示：样本映射到特征空间后，其投影值至少要达到阈值 <span displaypfx="inline-" class="mathjax-container">\(\rho\)</span>；若做不到，就用非负松弛变量 <span displaypfx="inline-" class="mathjax-container">\(\xi_i\)</span> 记录违约程度。于是模型允许少量样本越界，但必须为此付出代价。</p>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}\)</span> 是超平面法向量， <span displaypfx="inline-" class="mathjax-container">\(\rho\)</span> 是阈值， <span displaypfx="inline-" class="mathjax-container">\(\xi_i\)</span> 是松弛变量， <span displaypfx="inline-" class="mathjax-container">\(\nu\in(0,1]\)</span> 控制允许落在边界外的比例与支持向量比例。判别函数为：</p>
<span displaypfx="" class="mathjax-container">\[f(\boldsymbol{x})=\text{sign}\left(\boldsymbol{w}^\top \phi(\boldsymbol{x})-\rho\right)\]</span>
<p>判别函数先计算样本在特征空间里相对边界的位置：若 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{w}^\top \phi(\boldsymbol{x})-\rho\)</span> 为正，说明样本位于学习到的正常区域一侧；若为负，则更可能落在边界之外，被视为异常。</p>
<p>若使用核函数 <span displaypfx="inline-" class="mathjax-container">\(K(\boldsymbol{x},\boldsymbol{x}')\)</span>，则该边界可以是非线性的，因此能表达复杂的正常区域。</p>
<div class="blog_h4"><span class="graybg">训练或推断流程</span></div>
<ol>
<li>只用正常样本训练模型，选择核函数与超参数 <span displaypfx="inline-" class="mathjax-container">\(\nu\)</span>。</li>
<li>在特征空间中学习分离超平面。</li>
<li>对新样本计算判别函数值。</li>
<li>若分数低于边界阈值，则判为异常。</li>
</ol>
<div class="blog_h4"><span class="graybg">应用实例</span></div>
<p>在设备健康监控中，异常故障类型往往变化很大，难以完整收集，但正常运行数据很多。One-Class SVM 可以只基于正常样本学习边界，一旦新样本落出该区域，就触发异常告警。</p>
<div class="blog_h4"><span class="graybg">优缺点与适用场景</span></div>
<ul>
<li>优点：只依赖正常样本；核方法可表达复杂边界。</li>
<li>局限：对特征缩放和核参数敏感；大规模训练较重。</li>
<li>适用场景：设备监控、入侵检测、质量控制等“正常样本丰富、异常样本稀缺”的任务。</li>
</ul>
<div class="blog_h1"><span class="graybg">神经网络</span></div>
<div class="blog_h2"><span class="graybg">前馈神经网络</span></div>
<div class="blog_h3"><span class="graybg">感知机</span></div>
<p>感知机（Perceptron）是最早的神经元模型之一，也是现代神经网络最基本的计算原型。无论是 MLP、CNN、RNN，还是 Transformer，本质上都由大量“线性变换 + 非线性变换”的单元堆叠而成；从这个意义上说，理解感知机，就是理解大型模型最小的功能部件。</p>
<p>最原始的感知机先做线性组合，再经过一个阈值函数给出二分类输出：</p>
<span displaypfx="" class="mathjax-container">\[\hat y=\mathrm{sign}(\mathbf{w}^\top \mathbf{x}+b)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{x}\)</span> 是输入特征， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}\)</span> 是权重， <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是偏置。 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{w}^\top \mathbf{x}+b\)</span> 的含义是“沿着权重指定的方向对输入做加权打分”，而 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{sign}(\cdot)\)</span> 则把连续分数变成离散决策：高于阈值判为正类，低于阈值判为负类。</p>
<p>需要特别区分的是：最早的感知机确实直接做分类，但现代神经网络里的大多数“感知机式单元”并不直接输出最终类别。隐藏层单元更常见的形式是 <span displaypfx="inline-" class="mathjax-container">\(h=\phi(\mathbf{w}^\top \mathbf{x}+b)\)</span>，它们输出的是中间表示（Intermediate Representation），职责是检测局部模式、重组特征并为后续层提供更有用的表示；只有最后的任务头（Task Head）才把这些中间表示转成分类、回归或生成输出。</p>
<p>这里的中间表示（Intermediate Representation）本质上就是一组<span style="background-color: #c0c0c0;">可被后续层继续计算的数值特征</span>。它既可以是向量（Vector），也可以是矩阵（Matrix），更一般地说，它通常是张量（Tensor）。向量和矩阵都只是张量的特殊情形：若只看单个样本的 MLP 隐层输出，最常见的是向量 <span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{h}\in\mathbb{R}^{d}\)</span>；若看 Transformer 对整段序列的隐藏状态，常写成矩阵 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\times d}\)</span>；若再把 batch 维也带上，则会变成三维张量 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}\in\mathbb{R}^{B\times L\times d}\)</span>。在卷积网络（Convolutional Neural Network, CNN）里，中间表示则常是特征图张量（Feature Map Tensor） <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}\in\mathbb{R}^{C\times H\times W}\)</span>，或带 batch 的 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{B\times C\times H\times W}\)</span>。</p>
<p>因此，“中间表示”不是某种神秘对象，它就是网络在某一层对输入所形成的内部编码。它不像最终标签那样直接面向人类语义，而是把输入重写成更适合下一层处理的坐标系。例如，文本模型中的某一层隐藏状态可能同时编码词义、上下文关系、句法位置和任务相关线索；图像模型中的某一层特征图则可能突出边缘、纹理、局部部件或更高层形状。后续层与任务头读到的，正是这些内部编码。</p>
<p>感知机的重要性不止在于历史地位，更在于今天大型模型中的知识，本质上仍然是通过这类权重结构逐层编码进去的。训练过程并不会把知识像数据库那样逐条写成显式记录，而是不断调整参数，使某些输入模式被放大、某些输入模式被抑制。于是，模型在数据中反复见到的统计规律——词与词的共现、图像局部纹理、特征之间的组合关系——都会被压缩进参数矩阵的数值结构中。</p>
<p>更准确地说，大模型中的知识通常不是“某一个感知机单独存储一条事实”，而是以<span style="background-color: #c0c0c0;">分布式表示（Distributed Representation）</span>的形式分散在大量参数里。单个单元更像一个局部特征探测器（Feature Detector）：它只对某种模式敏感；许多单元级联后，网络才能把低层简单模式组合成高层抽象概念。模型规模越大、层数越深、参数越多，可被编码的模式组合也越丰富，这正是大模型具备强表达能力与“知识容量”的原因之一。</p>
<p>感知机能学会线性可分任务，但无法处理 XOR 这类线性不可分问题，这正是多层网络出现的动机：当一个超平面不够时，就需要通过多层组合把输入空间逐步重写成更容易分开的表示。</p>
<div class="blog_h3"><span class="graybg">多层感知机（MLP）</span></div>
<p>多层感知机（Multi-Layer Perceptron, MLP）处理的核心问题是：当输入与输出之间的关系不是一个超平面就能表达时，如何通过多层可学习变换，把原始特征逐步改写成更容易完成任务的表示。它由多层线性变换与逐元素非线性激活交替组成，是最基本也最通用的前馈网络结构。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/mlp.png"><img class="alignnone size-full wp-image-41415" src="https://blog.gmem.cc/wp-content/uploads/2026/03/mlp.png" alt="mlp" width="1024" height="1024" /></a></p>
<p>单层可写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{h}=\phi(W\mathbf{x}+\mathbf{b})\]</span>
<p>多层堆叠后，可写为：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{h}^{(1)}=\phi(W^{(1)}\mathbf{x}+\mathbf{b}^{(1)}),\quad \mathbf{h}^{(2)}=\phi(W^{(2)}\mathbf{h}^{(1)}+\mathbf{b}^{(2)}),\quad \hat{\mathbf{y}}=W^{(3)}\mathbf{h}^{(2)}+\mathbf{b}^{(3)}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(W^{(l)}\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}^{(l)}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层参数， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{h}^{(l)}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层隐藏表示。所谓“逐层重写表示”，就是每一层都在回答一个更具体的问题：哪些原始模式值得保留、哪些组合值得放大、哪些方向更利于后续任务。于是，早期层往往捕捉较局部、较简单的模式，后期层则把这些模式组合成更抽象的语义结构。</p>
<p>MLP 比单层感知机强，关键不在“层数更多”本身，而在于层与层之间插入了非线性激活函数 <span displaypfx="inline-" class="mathjax-container">\(\phi\)</span>。如果没有非线性，多层线性变换满足</p>
<span displaypfx="" class="mathjax-container">\[W^{(2)}(W^{(1)}\mathbf{x}+\mathbf{b}^{(1)})+\mathbf{b}^{(2)}=\tilde W\mathbf{x}+\tilde{\mathbf{b}}\]</span>
<p>最终仍然等价于一层线性变换，表达能力不会因为堆叠而提升。只有加入非线性后，网络才能把输入空间切分、折叠、拉伸并重新组合，形成复杂的分段线性或平滑非线性决策边界。</p>
<p>从几何角度看，单层模型像“用一个超平面切一次”；而多层 MLP 则是在表示空间中反复做坐标变换和非线性折叠，把原本难分的数据逐步变成线性头也能分开的形状。文中的激活函数对比图展示的正是这个过程：不同激活函数会把同一个三层网络变成完全不同的几何变换器。</p>
<p>在现代大模型中，MLP 的作用远不只是“附属模块”。在 Transformer 里，注意力层负责在 token 之间路由信息，而 MLP / Feed-Forward Network（FFN）则负责在每个位置上做通道维度的非线性特征变换，把路由来的信息重新编码进更强的表示。因此，很多语义模式、组合规则与任务相关知识，最终都会沉淀到这些大规模参数化的 MLP 权重中。</p>
<p>更进一步说，在 Transformer 的常见解释框架里，MLP / FFN 往往被看作<span style="background-color: #c0c0c0;">事实性知识的重要载体之一</span>。一个常见直觉是把 FFN 看成参数化的“键值存储器（Key-Value Memory）”：第一层线性变换更像在检测当前输入是否匹配某种模式或概念，第二层线性变换则把与该模式相关的语义方向重新写回残差流（Residual Stream）。这里的残差流，可以理解为 Transformer 里那条贯穿各层的主表示通道：每一层注意力与 MLP 的输出，都会通过残差相加的方式写回这条主通道，再交给后续层继续处理。因此，诸如“实体—属性”“术语—定义”“模式—响应”这类较稳定的关联，常常更容易在 MLP 权重里留下痕迹。</p>
<p>但这并不意味着“一个神经元就存一条事实”。更准确的描述是：<span style="background-color: #c0c0c0;">知识通常以分布式方式存在于许多层、许多通道和许多参数方向里</span>。单个神经元有时会对某种关系或概念特别敏感，因而出现所谓“知识神经元（Knowledge Neurons）”现象；但更稳定的事实表示，通常仍然依赖一组共同激活的单元和跨层传递的表示。可以把两类子层的分工概括为：注意力更擅长“去哪里找、和谁建立联系”，MLP 更擅长“把匹配到的模式变成可供后续层使用的语义内容”。因此，说 MLP 是知识的主要载体之一是合理的；说知识只存在于 MLP、完全不在注意力里，则过于简单。</p>
<div class="blog_h2"><span class="graybg">激活函数</span></div>
<p>激活函数（Activation Function）的作用是给线性层引入非线性。如果没有激活函数，多层线性层叠起来仍然等价于一层线性变换，深度就失去了意义。</p>
<p>先给出一个实用的选型总表。它不代替后文的机制分析，但可以先回答工程上最常见的问题：某个激活函数通常应该放在输出层、隐藏层，还是特定结构里。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">激活函数</td>
<td style="text-align: center;">输出范围</td>
<td style="text-align: center;">典型适用场景</td>
<td style="text-align: center;">主要原因</td>
</tr>
</thead>
<tbody>
<tr>
<td>Sigmoid</td>
<td><span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span></td>
<td>二分类输出层；LSTM / GRU 门控</td>
<td>天然给出概率或开关强度，但隐藏层易饱和</td>
</tr>
<tr>
<td>Tanh</td>
<td><span displaypfx="inline-" class="mathjax-container">\((-1,1)\)</span></td>
<td>较浅网络；需要零中心有界激活的结构</td>
<td>比 sigmoid 更利于优化，但深层仍会饱和</td>
</tr>
<tr>
<td>ReLU</td>
<td><span displaypfx="inline-" class="mathjax-container">\([0,+\infty)\)</span></td>
<td>深层前馈网络、CNN 的默认隐藏层</td>
<td>不饱和、计算便宜、分段线性、优化稳定</td>
</tr>
<tr>
<td>Leaky ReLU</td>
<td><span displaypfx="inline-" class="mathjax-container">\((-\infty,+\infty)\)</span></td>
<td>担心 Dying ReLU 的深层隐藏层</td>
<td>保留 ReLU 优点，同时避免负半轴完全断梯度</td>
</tr>
<tr>
<td>ELU</td>
<td><span displaypfx="inline-" class="mathjax-container">\((-\alpha,+\infty)\)</span></td>
<td>希望激活更平滑、且均值更接近 0 的隐藏层</td>
<td>负区间平滑并可取负值，但计算开销高于 ReLU</td>
</tr>
<tr>
<td>GELU</td>
<td><span displaypfx="inline-" class="mathjax-container">\((-\infty,+\infty)\)</span></td>
<td>Transformer、BERT 类模型的隐藏层</td>
<td>选择性强且过渡平滑，兼顾表达能力与优化平滑性</td>
</tr>
<tr>
<td>Swish / SiLU</td>
<td><span displaypfx="inline-" class="mathjax-container">\((-\infty,+\infty)\)</span></td>
<td>现代卷积网络；部分大模型隐藏层</td>
<td>软门控、平滑、比硬截断更柔和</td>
</tr>
<tr>
<td>Softmax</td>
<td>各分量在 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span> 且总和为 1</td>
<td>多分类输出层；语言模型词表分布输出</td>
<td>把 logits 归一化为概率分布，不用于普通隐藏层</td>
</tr>
</tbody>
</table>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/mlp-activation-summary.png"><img class="alignnone size-full wp-image-41289" src="https://blog.gmem.cc/wp-content/uploads/2026/03/mlp-activation-summary.png" alt="mlp-activation-summary" width="1920" height="957" /></a></p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/activation-functions-grid.png"><img class="alignnone size-full wp-image-41461" src="https://blog.gmem.cc/wp-content/uploads/2026/03/activation-functions-grid.png" alt="activation-functions-grid" width="1920" height="1079" /></a></p>
<div class="blog_h3"><span class="graybg">Sigmoid</span></div>
<p>Sigmoid 把实数压到 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\sigma(x)=\frac{1}{1+e^{-x}}\]</span>
<p>Sigmoid 的优势在于输出天然落在 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span>，因此非常适合表示概率，尤其常用于二分类输出层或门控结构中。但它在隐藏层里的主要问题是饱和（saturation）：当输入绝对值较大时，函数会迅速贴近 0 或 1，此时导数接近 0，梯度在反向传播时会被不断压缩，深层网络因此容易出现梯度消失（Vanishing Gradient）。从优化角度看，这意味着前面层参数即使有误，也很难收到足够强的更新信号。</p>
<p>此外，sigmoid 的输出始终为正，不以 0 为中心，这会使后续层接收到带偏移的激活分布，通常不利于优化动态的稳定性。因此，sigmoid 今天更多保留在“需要概率解释”的输出层，或在 LSTM / GRU 等门控结构中充当开关函数，而不再是深层前馈隐藏层的默认选择。</p>
<div class="blog_h3"><span class="graybg">Tanh</span></div>
<p>Tanh 的输出范围是 <span displaypfx="inline-" class="mathjax-container">\((-1,1)\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\tanh(x)=\frac{e^x-e^{-x}}{e^x+e^{-x}}\]</span>
<p>Tanh 可以看作“零中心版 sigmoid”：它同样会在大幅度输入时饱和，但输出分布位于 <span displaypfx="inline-" class="mathjax-container">\((-1,1)\)</span> 且以 0 为中心，这通常比 sigmoid 更利于优化，因为后续层接收到的激活不再整体偏向正侧。对于需要表达“正负方向”差异的隐藏表示，tanh 往往也比 sigmoid 更自然。</p>
<p>不过，tanh 并没有解决饱和带来的根本问题：当 <span displaypfx="inline-" class="mathjax-container">\(|x|\)</span> 很大时，导数仍接近 0，深层网络中的梯度传播依然会变弱。因此，在较深的前馈网络里，tanh 通常不如 ReLU 家族稳定；它更多出现在较浅网络、早期神经网络设计，或某些希望激活有界且零中心的结构中。</p>
<div class="blog_h3"><span class="graybg">ReLU</span></div>
<p>ReLU（Rectified Linear Unit）定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{ReLU}(x)=\max(0,x)\]</span>
<p>ReLU 看起来几乎“过于简单”，但它之所以长期有效，关键不在公式花哨，而在于它同时满足了深层优化最需要的几条性质。第一，ReLU 在正半轴不饱和（non-saturating）：当 <span displaypfx="inline-" class="mathjax-container">\(x&gt;0\)</span> 时导数恒为 1，梯度穿过这一单元时不会像 sigmoid / tanh 那样被持续压小，因此更有利于深层网络中的梯度传播。第二，ReLU 保留了非线性，但正半轴仍是线性的，这使整个网络变成<span style="background-color: #c0c0c0;">分段线性（piecewise linear）</span>系统：表达能力足够强，同时局部优化形状又比高度弯曲的饱和函数更“规整”。第三，负半轴直接截断会带来自然的稀疏激活（sparse activation）：不是所有单元都会在每个样本上同时活跃，这通常有助于提升表示分解能力，并降低无效共适应（co-adaptation）。这里的共适应，指多个神经元在训练中形成了过强的相互依赖：某个单元之所以有效，不是因为它单独学到了稳定、可迁移的模式，而是因为它总是和另外几个特定单元“成套工作”。一旦输入分布变化，或其中某些单元没有按训练时那样响应，这种脆弱的协同关系就容易失效，从而削弱泛化能力。</p>
<p>从工程角度看，ReLU 的优势还包括计算代价极低，只需一次比较运算；这在大规模训练中会被成千上万层和数十亿次前向/反向传播放大。更深层的原因是：深度网络真正需要的不是“平滑得很漂亮”的激活函数，而是一个既能打破线性、又不会在大范围内把梯度压扁、还能让优化器容易工作的非线性。ReLU 恰好在这三点之间取得了非常实用的平衡，这就是为什么一个形式上极其朴素的函数，反而成为现代深度学习最成功的默认选择之一。它的代价也很明确：负区间梯度为 0，单元可能长期失活，这就是后面 Leaky ReLU、ELU、GELU 等变体继续改进的出发点。</p>
<div class="blog_h3"><span class="graybg">Leaky ReLU</span></div>
<p>Leaky ReLU 给负半轴保留一个很小的斜率：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{LeakyReLU}(x)=\max(\alpha x,x),\quad \alpha\ll 1\]</span>
<p>这样做是为了缓解“死亡 ReLU（Dying ReLU）”问题：若某神经元长期落在负区间，普通 ReLU 的梯度可能一直为 0，而 Leaky ReLU 仍保留一点更新信号。</p>
<p>从机制上看，Leaky ReLU 的核心改动很小，但很有针对性：它保留了 ReLU 在正半轴的不饱和与分段线性优点，同时避免把负半轴完全切断。这样即使某个单元暂时落入负区间，参数仍有机会通过非零梯度被重新拉回活跃状态。因此，Leaky ReLU 可以看作对 ReLU 的保守修正：表达风格几乎不变，但训练风险更低。</p>
<div class="blog_h3"><span class="graybg">ELU</span></div>
<p>ELU（Exponential Linear Unit）在负半轴使用指数平滑：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{ELU}(x)=\begin{cases}x,&amp;x&gt;0\\ \alpha(e^x-1),&amp;x\le 0\end{cases}\]</span>
<p>它的目标是兼顾 ReLU 的优化优势和负区间的平滑性，使激活均值更接近 0。</p>
<p>与 Leaky ReLU 相比，ELU 在负半轴不再是简单直线，而是用指数曲线平滑衰减到负饱和值。这带来两个效果：一是避免了 ReLU 在 0 点附近过于生硬的折线结构；二是允许激活出现稳定的负值，从而减轻隐藏表示整体偏正的问题。代价是计算比 ReLU 更重，且负区间在极小值处同样会逐渐饱和，因此它通常被视为“更平滑、更零中心”的 ReLU 变体，而不是彻底不同的一类激活。</p>
<div class="blog_h3"><span class="graybg">GELU</span></div>
<p>GELU（Gaussian Error Linear Unit）可理解为“按输入大小平滑地决定保留多少信号”。它不像 ReLU 那样硬截断，而是对小正值和小负值做连续、概率化的保留，因此在 0 附近更平滑。</p>
<p>这种设计的价值在于：它仍然保留了 ReLU 家族的选择性——不是所有信号都被同等对待——但又避免了硬截断带来的尖锐折点和完全失活区。于是，GELU 往往能在“表达选择性”和“优化平滑性”之间取得更好的折中，这也是它在 Transformer、BERT 及其后续大量变体中被广泛采用的重要原因。</p>
<div class="blog_h3"><span class="graybg">Swish / SiLU</span></div>
<p>Swish / SiLU 定义为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{SiLU}(x)=x\,\sigma(x)\]</span>
<p>它是平滑、非单调的激活函数，在某些深层网络里表现优于 ReLU。其结构可以直接读成“输入值 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 乘上一个 sigmoid 门控 <span displaypfx="inline-" class="mathjax-container">\(\sigma(x)\)</span>”：当 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 很大时， <span displaypfx="inline-" class="mathjax-container">\(\sigma(x)\approx 1\)</span>，信号几乎原样通过；当 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 很小时， <span displaypfx="inline-" class="mathjax-container">\(\sigma(x)\approx 0\)</span>，信号被显著压低；在 0 附近则是连续、平滑的软过渡。</p>
<p>这种形式的价值在于：它既不像 ReLU 那样做硬截断，也不像 sigmoid 那样把输出彻底压进固定区间，而是让网络学到一种“按输入强度自适应通过多少”的软门控机制。结果是，SiLU / Swish 往往能在保持优化平滑性的同时，保留较强的表达灵活性，因此在一些现代卷积网络与大模型变体中表现良好。它可以看作介于 ReLU 家族与门控激活之间的一种折中设计。</p>
<div class="blog_h3"><span class="graybg">Softmax</span></div>
<p>Softmax 把一组实数分数（scores）映射为概率分布（Probability Distribution）。在分类与语言模型里，这组分数通常称为 logit（Logits）：它们是 softmax 之前的未归一化输出。</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{softmax}(z)_i=\frac{e^{z_i}}{\sum_{j=1}^{V} e^{z_j}}\]</span>
<p>logits 的两个关键性质：</p>
<ul>
<li>logits 不需要是概率，可以是任意实数；softmax 才把它变成 <span displaypfx="inline-" class="mathjax-container">\([0,1]\)</span> 且和为 1 的分布。</li>
<li>softmax 对整体平移不敏感：对任意常数 <span displaypfx="inline-" class="mathjax-container">\(c\)</span>，有 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{softmax}(z)=\mathrm{softmax}(z+c\mathbf{1})\)</span>。因此实现里常用 <span displaypfx="inline-" class="mathjax-container">\(z\leftarrow z-\max_i z_i\)</span> 做数值稳定（Numerical Stability）。</li>
</ul>
<p>这说明 softmax 真正关心的不是某个 logit 的绝对数值，而是各个 logit 之间的相对差值。若把所有分数同时加 10，模型对“哪一类更占优”的判断不会改变，因为指数项会在分子和分母里同时乘上 <span displaypfx="inline-" class="mathjax-container">\(e^{10}\)</span>，最终完全约掉。改变 softmax 输出的，不是整体抬高或压低所有分数，而是某个类别相对其他类别高了多少。</p>
<p>也正因为如此，logit 更适合理解为“未归一化偏好分数（unnormalized preference scores）”，而不是“概率雏形”。例如两类 logits 从 <span displaypfx="inline-" class="mathjax-container">\((1,2)\)</span> 变成 <span displaypfx="inline-" class="mathjax-container">\((101,102)\)</span>，softmax 输出完全相同；但若从 <span displaypfx="inline-" class="mathjax-container">\((1,2)\)</span> 变成 <span displaypfx="inline-" class="mathjax-container">\((1,5)\)</span>，第二类相对第一类的优势被显著拉大，概率才会明显变化。</p>
<p>在语言模型（Language Model）中，给定最后一层隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d_{\text{model}}}\)</span>，线性输出头产生词表大小 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 的 logits： <span displaypfx="inline-" class="mathjax-container">\(z=hW_{\text{vocab}}+b\)</span>，再经 softmax 得到下一个 token 的分布。若采用权重共享（Weight Tying），则输入嵌入表 <span displaypfx="inline-" class="mathjax-container">\(E\in\mathbb{R}^{V\;\times d_{\text{model}}}\)</span> 与输出头满足 <span displaypfx="inline-" class="mathjax-container">\(W_{\text{vocab}}=E^\top\)</span>，于是可直接写成 <span displaypfx="inline-" class="mathjax-container">\(z=hE^\top+b\)</span>。</p>
<div class="blog_h4"><span class="graybg">Softmax和分类任务</span></div>
<p>在多分类任务里，这几个概念实际上是一条连续的计算链：任务头先输出 logits <span displaypfx="inline-" class="mathjax-container">\(z\in\mathbb{R}^{C}\)</span>，softmax 把它们变成条件概率 <span displaypfx="inline-" class="mathjax-container">\(p(y=i|x)\)</span>，再取真实类别 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 的负对数概率作为单样本损失，也就是负对数似然（Negative Log-Likelihood, NLL）。</p>
<span displaypfx="" class="mathjax-container">\[p(y=i|x)=\mathrm{softmax}(z)_i=\frac{e^{z_i}}{\sum_{j=1}^{C}e^{z_j}},\qquad \ell_{\mathrm{NLL}}(z,c)=-\log p(y=c|x)\]</span>
<p>把两步合起来，NLL 可以直接写成 logits 的函数：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{NLL}}(z,c)=-\log\frac{e^{z_c}}{\sum_{j=1}^{C}e^{z_j}}=-z_c+\log\sum_{j=1}^{C}e^{z_j}\]</span>
<p>这个式子把训练目标拆成了两部分：第一项 <span displaypfx="inline-" class="mathjax-container">\(-z_c\)</span> 要求真实类别的 logit 足够大；第二项 <span displaypfx="inline-" class="mathjax-container">\(\log\sum_j e^{z_j}\)</span> 是归一化项（log-sum-exp），它把所有类别的竞争都算进去。因此训练并不是单独把正确类别分数抬高，而是要让它相对其他类别更占优势。</p>
<p>softmax 的平移不变性（Translation Invariance）在这里也能直接看见。对任意常数 <span displaypfx="inline-" class="mathjax-container">\(a\)</span>，若把所有 logits 同时改为 <span displaypfx="inline-" class="mathjax-container">\(z+a\mathbf{1}\)</span>，则 softmax 概率不变，NLL 也不变：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{softmax}(z+a\mathbf{1})_i=\mathrm{softmax}(z)_i,\qquad \ell_{\mathrm{NLL}}(z+a\mathbf{1},c)=\ell_{\mathrm{NLL}}(z,c)\]</span>
<p>因此 logits 的绝对零点没有意义，真正有意义的是类别之间的相对差值。工程实现里常把 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 先整体减去 <span displaypfx="inline-" class="mathjax-container">\(\max_i z_i\)</span>，再计算 softmax 或 log-sum-exp。这样不会改变概率与损失，但能显著降低指数溢出的风险。</p>
<div class="blog_h2"><span class="graybg">损失函数</span></div>
<p>术语区分：假设函数（Hypothesis Function）/模型 <span displaypfx="inline-" class="mathjax-container">\(f_\theta\)</span> 定义“模型在做什么映射”；样本损失（Loss Function）定义在单样本上；代价函数/成本函数（Cost Function）是把样本损失在全数据集上做平均或求和后的经验风险；目标函数（Objective Function）是优化器真正要优化的函数，最常见写法是 <span displaypfx="inline-" class="mathjax-container">\(J(\theta)=L(\theta)+\lambda\Omega(\theta)\)</span>。</p>
<div class="blog_h3"><span class="graybg">回归损失</span></div>
<div class="blog_h4"><span class="graybg">MSE</span></div>
<p>均方误差（Mean Squared Error, MSE）定义为</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{MSE}}(y,\hat y)=(\hat y-y)^2\]</span>
<p>平方的作用是让大误差被放大处罚。若一个样本错 10，另一个样本错 1，那么前者在 MSE 里不是“重 10 倍”，而是“重 100 倍”。因此 MSE 很适合你明确希望重罚大错的场景，也对应前面讲过的高斯噪声假设。</p>
<div class="blog_h4"><span class="graybg">MAE</span></div>
<p>平均绝对误差（Mean Absolute Error, MAE）对应单样本形式</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{MAE}}(y,\hat y)=|\hat y-y|\]</span>
<p>它直接度量偏差大小，对离群点更鲁棒，因为不会像平方那样把大误差急剧放大。若做房价预测，数据中存在一批价格远高于主体分布的豪宅样本时，MAE 往往比 MSE 更稳；这里的“主体分布”指的是样本中占大多数的普通住宅价格区间，而豪宅样本相对它明显偏高。这样一来，哪怕豪宅样本数量不多，它们也会在 MSE 下因为误差被平方而获得过大的影响力。</p>
<div class="blog_h4"><span class="graybg">Huber Loss</span></div>
<p>Huber Loss 结合了 MSE 与 MAE：误差小时像平方误差，误差大时像绝对误差。设阈值 <span displaypfx="inline-" class="mathjax-container">\(\delta\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\ell_\delta(r)=\begin{cases}\frac{1}{2}r^2,&amp;|r|\le \delta\\ \delta(|r|-\frac{1}{2}\delta),&amp;|r|&gt;\delta\end{cases},\quad r=\hat y-y\]</span>
<p>这条式子的直觉是：小误差区间内保持平滑、便于优化；大误差区间内降低对离群点的过度敏感。它像“正常误差严肃处理，极端异常别让它一票否决整个模型”。</p>
<div class="blog_h3"><span class="graybg">分类损失</span></div>
<div class="blog_h4"><span class="graybg">交叉熵损失（Binary）</span></div>
<p>二分类交叉熵（Binary Cross-Entropy, BCE）用来训练输出概率 <span displaypfx="inline-" class="mathjax-container">\(p\in(0,1)\)</span> 的二分类器。这里 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 通常表示模型预测“正类”的概率， <span displaypfx="inline-" class="mathjax-container">\(y\in\{0,1\}\)</span> 是真实标签： <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span> 表示正类， <span displaypfx="inline-" class="mathjax-container">\(y=0\)</span> 表示负类。</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{BCE}}(y,p)=-\Big(y\log p+(1-y)\log(1-p)\Big)\]</span>
<p>左边的 <span displaypfx="inline-" class="mathjax-container">\(\ell_{\mathrm{BCE}}(y,p)\)</span> 表示“单个样本在真实标签为 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>、模型预测正类概率为 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 时的 BCE 损失值”。也就是说，这不是新的概率，而是一个标量惩罚：预测越符合真实标签，它越小；预测越违背真实标签，它越大。</p>
<p>这条公式会根据 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 的取值自动选择应当惩罚哪一项。当 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span> 时，式中的 <span displaypfx="inline-" class="mathjax-container">\((1-y)=0\)</span>，因此第二项消失，损失化简为 <span displaypfx="inline-" class="mathjax-container">\(-\log p\)</span>；当 <span displaypfx="inline-" class="mathjax-container">\(y=0\)</span> 时，第一项中的 <span displaypfx="inline-" class="mathjax-container">\(y=0\)</span>，因此第一项消失，损失化简为 <span displaypfx="inline-" class="mathjax-container">\(-\log(1-p)\)</span>。于是它惩罚的本质就是：让真实类别对应的概率尽可能高。</p>
<p>数值例子（自然对数）：若 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span> 且 <span displaypfx="inline-" class="mathjax-container">\(p=0.9\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\ell\approx 0.105\)</span>；若 <span displaypfx="inline-" class="mathjax-container">\(p=0.1\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\ell\approx 2.303\)</span>。正确但不自信会被罚，错误且自信会被重罚。</p>
<div class="blog_h4"><span class="graybg">交叉熵损失（Categorical）</span></div>
<p>多分类交叉熵（Categorical Cross-Entropy, CE）与 softmax 通常配套使用。这里 <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 是真实分布（Ground-Truth Distribution）在第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 类上的概率质量， <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span> 是模型预测分布（Predicted Distribution）在第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 类上的概率。若类别总数为 <span displaypfx="inline-" class="mathjax-container">\(C\)</span>，则单样本损失写成：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{CE}}(y,p)=-\sum_{i=1}^{C} y_i\log p_i\]</span>
<p>左边的 <span displaypfx="inline-" class="mathjax-container">\(\ell_{\mathrm{CE}}(y,p)\)</span> 表示“当真实标签分布为 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>、模型预测分布为 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 时，这个样本对应的交叉熵损失值”。其本质是：<span style="background-color: #c0c0c0;">用真实标签分布 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 作为权重，对预测分布的负对数概率 <span displaypfx="inline-" class="mathjax-container">\(-\log p_i\)</span> 做加权平均</span>。</p>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 是 one-hot 分布时，只有真实类别 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 那一维的权重为 1，其余维度权重都为 0，因此求和会自动塌缩成单项：</p>
<span displaypfx="" class="mathjax-container">\[\ell=-\log p_c\]</span>
<p>这正是分类任务里最常见的形式。它与最大似然估计（Maximum Likelihood Estimation, MLE）完全一致：最小化交叉熵等价于最大化真实类别的对数似然。</p>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 不再是 one-hot，而是一个在多个类别上分配了非零概率质量的分布，那么交叉熵就不再只读取单个类别 <span displaypfx="inline-" class="mathjax-container">\(p_c\)</span>，而是会对整条标签分布做加权。常见来源有三类。</p>
<p>第一类是软标签（Soft Label）。它指真实监督信号本身就是一个概率分布，而不是“只有一个绝对正确类别”的硬标签（Hard Label）。例如一张图像可能被标注为“70% 像猫、30% 像狐狸”，或一个样本本身就带有多标注者投票汇总后的类别分布。在这种情况下， <span displaypfx="inline-" class="mathjax-container">\(y_i\)</span> 直接表示第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 类的目标概率，交叉熵自然要对所有类别一起计算。</p>
<p>第二类是标签平滑（Label Smoothing）。它是一种正则化技术：原本 one-hot 标签会把真实类别的目标概率设为 1，其余类别全设为 0；标签平滑则故意把这件事放松一些，例如对 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 分类问题，取一个很小的 <span displaypfx="inline-" class="mathjax-container">\(\varepsilon\in(0,1)\)</span>，把目标分布改写为：</p>
<span displaypfx="" class="mathjax-container">\[y_i^{\mathrm{LS}}=(1-\varepsilon)\mathbf{1}[i=c]+\frac{\varepsilon}{C}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 是真实类别， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}[i=c]\)</span> 是指示函数：当 <span displaypfx="inline-" class="mathjax-container">\(i=c\)</span> 时取 1，否则取 0。这样做的效果是：真实类别仍占最大权重，但其他类别也获得一小部分概率质量。它可以抑制模型过度自信，使输出分布更平滑，并在一定程度上改善泛化与校准。</p>
<p>第三类是教师分布蒸馏（Knowledge Distillation from Teacher Distribution）。知识蒸馏（Knowledge Distillation）的做法是：不用人工标签单独监督学生模型（Student Model），而是让学生去拟合教师模型（Teacher Model）给出的类别分布。若教师在某个样本上输出 <span displaypfx="inline-" class="mathjax-container">\(q\)</span>，学生输出 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>，则训练目标常包含 <span displaypfx="inline-" class="mathjax-container">\(-\sum_i q_i\log p_i\)</span> 这样的交叉熵或等价的 KL 散度项。它传递的不只是“哪一类是对的”，还传递“其余类别分别有多像”，因此常被称为暗知识（Dark Knowledge）。</p>
<p>这三种情况的共同点是：标签本身已经不是单点答案，而是一条分布。于是交叉熵的计算对象就不再只是“真实类别那一项”，而是整个目标分布与预测分布之间的匹配程度。</p>
<p>从信息论角度看，交叉熵的标准定义是两个分布 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 之间的</p>
<span displaypfx="" class="mathjax-container">\[H(P,Q)=-\sum_x P(x)\log Q(x)\]</span>
<p>分类里的 <span displaypfx="inline-" class="mathjax-container">\(\ell_{\mathrm{CE}}(y,p)\)</span> 与这个定义并不矛盾，它只是把信息论中的 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 分别具体化成了“单个样本对应的真实标签分布 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>”与“模型在这个样本上的预测分布 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>”。若标签是 one-hot，那么 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 退化成一个只在真实类别处取值为 1 的离散分布，于是信息论里的交叉熵自然退化成 <span displaypfx="inline-" class="mathjax-container">\(-\log p_c\)</span>。</p>
<p>进一步地，交叉熵与 KL 散度（Kullback–Leibler Divergence）的关系可以直接写成：</p>
<span displaypfx="" class="mathjax-container">\[H(P,Q)=H(P)+D_{\mathrm{KL}}(P\|Q)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(H(P)=-\sum_x P(x)\log P(x)\)</span> 是真实分布 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 自身的熵（Entropy），只由数据分布决定； <span displaypfx="inline-" class="mathjax-container">\(D_{\mathrm{KL}}(P\|Q)=\sum_x P(x)\log\frac{P(x)}{Q(x)}\)</span> 是 KL 散度，用来衡量预测分布 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 相对真实分布 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 的偏离程度。于是，在训练数据固定时， <span displaypfx="inline-" class="mathjax-container">\(H(P)\)</span> 是常数，最小化交叉熵 <span displaypfx="inline-" class="mathjax-container">\(H(P,Q)\)</span> 就等价于最小化 <span displaypfx="inline-" class="mathjax-container">\(D_{\mathrm{KL}}(P\|Q)\)</span>。</p>
<p>因此，“最小化交叉熵就是最小化 KL 散度”这句话在监督学习里通常成立，但更准确的表述是：<span style="background-color: #c0c0c0;">在真实分布固定不变时，最小化交叉熵与最小化预测分布对真实分布的 KL 偏离是等价的</span>。KL 散度确实可以理解为“与真实分布的差异或偏离”，但它不是对称距离（Symmetric Distance）：一般有 <span displaypfx="inline-" class="mathjax-container">\(D_{\mathrm{KL}}(P\|Q) \neq D_{\mathrm{KL}}(Q\|P)\)</span>，也不满足严格距离函数的三角不等式，因此更准确的名称是分布失配（distribution mismatch）或相对熵（relative entropy）。</p>
<p>若进一步把 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 写成 softmax 作用在 logits <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 上，则多分类交叉熵可直接写成：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{CE}}(z,c)=-\log\mathrm{softmax}(z)_c=-z_c+\log\sum_{j=1}^{C}e^{z_j}\]</span>
<p>这条式子把任务头和损失函数直接接起来：线性头先输出 logits，softmax 把它们归一化为概率，交叉熵再读取真实类别的负对数概率。由于 softmax 具有平移不变性，给所有 logits 同时加上同一个常数不会改变这个损失，因此实现中通常直接从 logits 计算交叉熵（log-sum-exp 形式），并先减去 <span displaypfx="inline-" class="mathjax-container">\(\max_j z_j\)</span> 做数值稳定，而不是显式先算 softmax 再取 log。</p>
<div class="blog_h4"><span class="graybg">Focal Loss</span></div>
<p>Focal Loss 常用于类别极不平衡的分类任务，尤其是目标检测（Object Detection）这类“负样本远多于正样本”的场景。它不是简单换掉交叉熵，而是在交叉熵前再乘一个与样本难度相关的调制因子，使已经分得很对的容易样本贡献变小，把梯度预算更多留给困难样本：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{focal}}(p_t)=-(1-p_t)^\gamma\log p_t\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(p_t\)</span> 表示“真实类别对应的预测概率”：若真实标签 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(p_t=p\)</span>；若 <span displaypfx="inline-" class="mathjax-container">\(y=0\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(p_t=1-p\)</span>。因此 <span displaypfx="inline-" class="mathjax-container">\(-\log p_t\)</span> 就是普通交叉熵，而前面的 <span displaypfx="inline-" class="mathjax-container">\((1-p_t)^\gamma\)</span> 是额外加上的难度调制项。参数 <span displaypfx="inline-" class="mathjax-container">\(\gamma\ge 0\)</span> 控制聚焦强度： <span displaypfx="inline-" class="mathjax-container">\(\gamma=0\)</span> 时，Focal Loss 退化回普通交叉熵； <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 越大，对容易样本的压低越明显。</p>
<p>这个机制的关键在于样本难度如何反映到权重上。若某个样本已经分得很对，例如真实类别概率 <span displaypfx="inline-" class="mathjax-container">\(p_t=0.99\)</span>，则调制因子 <span displaypfx="inline-" class="mathjax-container">\((1-p_t)^\gamma\)</span> 会非常小；以 <span displaypfx="inline-" class="mathjax-container">\(\gamma=2\)</span> 为例，权重大约只有 <span displaypfx="inline-" class="mathjax-container">\((0.01)^2=10^{-4}\)</span>，这意味着它对总损失和梯度的影响被大幅削弱。相反，若某个样本很难，例如 <span displaypfx="inline-" class="mathjax-container">\(p_t=0.2\)</span>，则权重约为 <span displaypfx="inline-" class="mathjax-container">\((0.8)^2=0.64\)</span>，其损失会被较大程度保留。于是训练过程不再被海量“早就分对的简单样本”主导，而会持续关注误分样本、边界样本和少数类样本。</p>
<p>目标检测是最典型的应用例子。以单阶段检测器（One-stage Detector）为例，一张图像上往往有成千上万个候选框（Anchors），但真正包含目标的正样本只占极少数；绝大多数候选框都是背景。若直接使用普通交叉熵，训练会被这些“背景且容易判断”的负样本淹没：它们单个损失虽小，但数量太多，累积后仍然主导梯度。Focal Loss 的作用正是把这批容易背景样本的权重压下去，让模型把更多注意力放在少数正样本、遮挡目标、边界模糊目标，以及那些看起来像目标但其实是背景的困难负样本上。这样做通常会显著改善长尾检测与前景-背景极不平衡时的训练效果。</p>
<div class="blog_h4"><span class="graybg">Hinge Loss</span></div>
<p>Hinge Loss 是 SVM 常用的分类损失：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{hinge}}(y,f)=\max(0,1-yf),\quad y\in\{-1,+1\}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(f\)</span> 是模型分数而不是概率。当 <span displaypfx="inline-" class="mathjax-container">\(yf\ge 1\)</span> 时，说明不仅分类正确，而且留出了足够间隔，损失为 0；当 <span displaypfx="inline-" class="mathjax-container">\(yf&lt;1\)</span> 时，就要受罚。它强调的不只是“分对”，而是“分对且留有安全距离”。</p>
<div class="blog_h3"><span class="graybg">度量学习损失</span></div>
<p>度量学习（Metric Learning）不直接预测类别，而是学习一个表示空间，让“应该相似的样本靠近，不该相似的样本拉远”。这类损失在检索、人脸识别、推荐召回和 embedding 学习中非常常见。</p>
<div class="blog_h4"><span class="graybg">Contrastive Loss</span></div>
<p>Contrastive Loss 处理样本对（pair）。若一对样本应相似，则拉近它们；若应不同，则至少推开到某个间隔 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 之外：</p>
<span displaypfx="" class="mathjax-container">\[\ell=y\,d^2+(1-y)\max(0,m-d)^2\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 是两者在嵌入空间中的距离， <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span> 表示正对， <span displaypfx="inline-" class="mathjax-container">\(y=0\)</span> 表示负对。它像“朋友要坐得近，陌生人至少别挤在一起”。</p>
<div class="blog_h4"><span class="graybg">Triplet Loss</span></div>
<p>Triplet Loss 使用三元组：锚点（Anchor）、正样本（Positive）、负样本（Negative）。目标是让锚点离正样本比离负样本更近至少一个 margin：</p>
<span displaypfx="" class="mathjax-container">\[\ell=\max\big(0,\ d(a,p)-d(a,n)+m\big)\]</span>
<p>这条式子表达的是一种相对排序约束，而不是绝对相似度分数。它非常适合“谁比谁更像”的任务，例如人脸验证：同一个人的两张照片应比不同人的照片更近。</p>
<div class="blog_h4"><span class="graybg">InfoNCE</span></div>
<p>InfoNCE 是现代对比学习最常见的损失之一。对一个锚点来说，它把正样本放进一堆候选里，要求模型把正样本打分最高：</p>
<span displaypfx="" class="mathjax-container">\[-\log \frac{\exp(\mathrm{sim}(z_i,z_i^+)/\tau)}{\sum_j \exp(\mathrm{sim}(z_i,z_j)/\tau)}\]</span>
<p>分子是正确配对，分母是所有候选。这个结构和 softmax 分类非常像，只不过类别不再是固定标签，而是“在一堆候选里，谁才是真正匹配的那个”。在大语言模型 embedding、图像表征学习和多模态对齐里，它几乎是标准配置。</p>
<div class="blog_h2"><span class="graybg">任务头（Task Head）</span></div>
<p>任务头（Task Head）是把主干网络（Backbone）产出的隐藏表示（Hidden Representation）映射到具体任务输出空间的模块。主干负责抽取通用特征，任务头负责把特征“读出来”并对齐到目标形式（类别、数值、序列标签、跨度、关系等）。在工程上，绝大多数“用 Transformer 做下游任务”都可以写成：<span style="background-color: #c0c0c0;">Transformer backbone + task head + task loss</span>。</p>
<div class="blog_h3"><span class="graybg">中间表示、logits 与任务头的关系</span></div>
<p>主干网络输出的隐藏表示（Hidden Representation）是任务头的输入，任务头则是把这种内部表示读成具体任务输出的最后一层或最后几层变换。若把主干输出记为 <span displaypfx="inline-" class="mathjax-container">\(h\)</span>、<span displaypfx="inline-" class="mathjax-container">\(H\)</span> 或更一般的张量 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{H}\)</span>，则任务头通常先做一次线性读出（Linear Readout）得到分数，再视任务类型决定是否接 sigmoid、softmax、CRF 解码或直接保留实数输出。</p>
<p>logits 就是在这个读出阶段最常见的中间产物。它们是<span style="background-color: #c0c0c0;">任务头输出、但尚未归一化或尚未解码的原始分数</span>。例如，多分类头常先产生 <span displaypfx="inline-" class="mathjax-container">\(z\in\mathbb{R}^{C}\)</span>，这里 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 是类别数， <span displaypfx="inline-" class="mathjax-container">\(z_c\)</span> 表示模型对第 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 类的偏好分数；softmax 之后这些分数才变成概率。对 token 分类任务，task head 产生的不是单个向量，而是一整张 logits 矩阵 <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{L\times C}\)</span>；对语言模型，输出则是词表 logits <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{L\times V}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 是词表大小。</p>
<p>因此，关系可以概括为：<span style="background-color: #c0c0c0;">输入先被 backbone 编码成中间表示，中间表示再被 task head 读成 logits 或其他任务分数，最后再由概率映射、解码器或损失函数把这些分数变成最终预测</span>。logits 不是所有中间表示的统称，而是“距离最终任务输出只差一步”的那类任务分数；它们通常由任务头生成，而不是由主干网络中间每一层都显式生成。</p>
<div class="blog_h3"><span class="graybg">任务头输出对照表</span></div>
<p>不同任务头的差异，最核心地体现在“直接输出什么张量、这些张量后面还要经过什么处理”这两个问题上。下面这张表把常见任务头的输入形状、直接输出和后续处理并列起来，便于从工程实现角度快速对照。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">任务类型</td>
<td style="text-align: center;">任务头常见输入</td>
<td style="text-align: center;">任务头直接输出</td>
<td style="text-align: center;">后续处理</td>
</tr>
</thead>
<tbody>
<tr>
<td>二分类</td>
<td>单样本表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span></td>
<td>标量 logit <span displaypfx="inline-" class="mathjax-container">\(z\in\mathbb{R}\)</span>，或二维 logits <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\in\mathbb{R}^{2}\)</span></td>
<td>sigmoid 或 softmax，得到类别概率</td>
</tr>
<tr>
<td>多分类</td>
<td>单样本表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span></td>
<td>类别 logits 向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\in\mathbb{R}^{C}\)</span></td>
<td>softmax 后得到 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 类概率</td>
</tr>
<tr>
<td>多标签分类</td>
<td>单样本表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span></td>
<td>每个标签一个 logit，组成 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\in\mathbb{R}^{C}\)</span></td>
<td>对每一维独立做 sigmoid，而不是在类别间做 softmax</td>
</tr>
<tr>
<td>回归</td>
<td>单样本表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span></td>
<td>实数或向量 <span displaypfx="inline-" class="mathjax-container">\(\hat{\mathbf{y}}\in\mathbb{R}^{m}\)</span></td>
<td>通常不做概率归一化，直接配合回归损失</td>
</tr>
<tr>
<td>Token 分类 / NER</td>
<td>序列表示 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\times d}\)</span></td>
<td>token-level logits 矩阵 <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{L\times C}\)</span></td>
<td>逐 token softmax，或接 CRF 做全局解码</td>
</tr>
<tr>
<td>语言模型 / 文本生成</td>
<td>序列表示 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\times d}\)</span></td>
<td>词表 logits <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{L\times V}\)</span></td>
<td>对每个位置在词表维做 softmax，得到 next-token 分布</td>
</tr>
<tr>
<td>跨度抽取（Span Extraction）</td>
<td>序列表示 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\times d}\)</span></td>
<td>起点 logits <span displaypfx="inline-" class="mathjax-container">\(\mathbf{a}\in\mathbb{R}^{L}\)</span> 与终点 logits <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\in\mathbb{R}^{L}\)</span>，或 span 分数矩阵</td>
<td>在起止位置上做 softmax 或联合评分，输出片段边界</td>
</tr>
<tr>
<td>依存句法 / 关系抽取</td>
<td>成对表示 <span displaypfx="inline-" class="mathjax-container">\(h_i,h_j\)</span> 或序列表示 <span displaypfx="inline-" class="mathjax-container">\(H\)</span></td>
<td>边分数矩阵 <span displaypfx="inline-" class="mathjax-container">\(S\in\mathbb{R}^{L\times L}\)</span>，或关系分数张量 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{S}\in\mathbb{R}^{L\times L\times C}\)</span></td>
<td>argmax、biaffine 解码或图结构约束解码</td>
</tr>
<tr>
<td>度量学习 / 检索</td>
<td>单样本表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span></td>
<td>embedding 向量 <span displaypfx="inline-" class="mathjax-container">\(e\in\mathbb{R}^{d'}\)</span></td>
<td>不直接输出 logits；后续用相似度函数或对比损失比较</td>
</tr>
</tbody>
</table>
<p>这张表的关键在于区分“任务头直接输出什么”和“用户最终看到什么”。很多任务头直接输出的并不是概率，也不是标签，而是 logits、边分数、起止位置分数或 embedding。概率、标签、生成 token、依存边、异常分数等最终结果，通常还需要经过归一化、解码、阈值化或搜索过程才能得到。</p>
<div class="blog_h3"><span class="graybg">分类头</span></div>
<p>分类头（Classification Head）的核心职责，是把主干网络输出的表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span> 变成类别分数（Class Scores）或 logits。最常见的做法是一层线性映射：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{z}=W\mathbf{h}+\mathbf{b}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{h}\in\mathbb{R}^{d}\)</span> 是单个样本的隐藏表示， <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 是任务头权重矩阵， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{b}\)</span> 是偏置向量， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\)</span> 是未归一化类别分数。分类任务的关键区别不在于“有没有线性层”，而在于：<span style="background-color: #c0c0c0;">输出空间是否互斥、每个样本允许几个标签成立、以及这些分数之后接什么归一化与损失</span>。</p>
<p>直觉上，这个线性头就是一个“可学习的读出（Readout）”：在高维表示空间里用超平面（Hyperplane）切分区域，或用线性映射把表示投影到目标坐标系。这里的“读出”指的是：主干网络先把输入编码成内部表示，而任务头再把这种内部表示转换成模型真正需要输出的量，例如类别 logits、词表 logits、回归值、span 分数或关系分数。换言之，读出不是“再提特征”，而是<span style="background-color: #c0c0c0;">把已经形成的表示翻译成任务空间中的可判定分数</span>。</p>
<div class="blog_h4"><span class="graybg">二分类</span></div>
<p>二分类（Binary Classification）要求每个样本只在两个互斥类别中选一个，例如“垃圾 / 非垃圾”“欺诈 / 正常”“阳性 / 阴性”。最常见的写法是输出一个标量 logit：</p>
<span displaypfx="" class="mathjax-container">\[z=\mathbf{w}^\top \mathbf{h}+b\]</span>
<p>然后通过 sigmoid 得到正类概率：</p>
<span displaypfx="" class="mathjax-container">\[p=P(y=1\mid x)=\sigma(z)=\frac{1}{1+e^{-z}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 是模型对正类的原始偏好分数， <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是正类概率，负类概率则是 <span displaypfx="inline-" class="mathjax-container">\(1-p\)</span>。训练时通常配合二分类交叉熵（Binary Cross-Entropy, BCE）。工程上也可以输出二维 logits <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\in\mathbb{R}^{2}\)</span>，再接 softmax；但若任务确实只有两个互斥类别，单 logit + sigmoid 更常见，也更经济。</p>
<div class="blog_h4"><span class="graybg">多分类</span></div>
<p>多分类（Multi-class Classification）要求每个样本在 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 个互斥类别中恰好选一个类别，例如“猫 / 狗 / 鸟”“体育 / 财经 / 科技 / 娱乐”。此时任务头不会只输出一个标量，而是输出长度为 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 的 logits 向量：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{z}=W\mathbf{h}+\mathbf{b},\qquad W\in\mathbb{R}^{C\times d},\ \mathbf{z}\in\mathbb{R}^{C}\]</span>
<p>其中第 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 维 <span displaypfx="inline-" class="mathjax-container">\(z_c\)</span> 表示模型对第 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 个类别的原始偏好分数。由于这些类别互斥，后续通常接 softmax，把整组分数归一化成概率分布：</p>
<span displaypfx="" class="mathjax-container">\[p(y=c\mid x)=\frac{e^{z_c}}{\sum_{j=1}^{C}e^{z_j}}\]</span>
<p>这条式子的含义很直接：分子 <span displaypfx="inline-" class="mathjax-container">\(e^{z_c}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 类的相对强度，分母 <span displaypfx="inline-" class="mathjax-container">\(\sum_{j=1}^{C}e^{z_j}\)</span> 把所有类别一起归一化，因此最终得到的 <span displaypfx="inline-" class="mathjax-container">\(p(y=c\mid x)\)</span> 落在 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span> 之间，且所有类别概率和为 1。也正因为总和必须为 1，多分类头天然表达的是“类间竞争”：某一类概率上升，其他类的总概率就必须下降。</p>
<p>训练时通常直接把 logits <span displaypfx="inline-" class="mathjax-container">\(\mathbf{z}\)</span> 输入多分类交叉熵（Cross-Entropy）损失，而不是手动先算 softmax 再取对数。这样做的原因是数值稳定：损失函数内部会把 softmax 与对数合并成 log-sum-exp 形式，避免指数溢出。推理时若只关心类别标签，直接取 <span displaypfx="inline-" class="mathjax-container">\(\arg\max_c z_c\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(\arg\max_c p(y=c\mid x)\)</span> 即可；若需要概率、阈值或校准，再显式使用 softmax。</p>
<div class="blog_h4"><span class="graybg">多标签分类</span></div>
<p>多标签分类（Multi-label Classification）与多分类名字相近，但任务结构完全不同。它不是“从多个类里选一个”，而是<span style="background-color: #c0c0c0;">同一个样本可以同时拥有多个标签</span>。例如一篇文章可以同时属于“AI、NLP、Transformer”，一张图片可以同时打上“室内、人物、宠物”三个标签。</p>
<p>这种任务里，标签之间不再互斥，因此不能使用 softmax。若仍用 softmax，所有标签概率会被强制归一化为和 1，相当于错误地假设“只能有一个标签成立”。多标签头通常输出 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 个 logits：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{z}=W\mathbf{h}+\mathbf{b},\qquad \mathbf{z}\in\mathbb{R}^{C}\]</span>
<p>但后续不是做一次整体 softmax，而是对每一维独立做 sigmoid：</p>
<span displaypfx="" class="mathjax-container">\[p_i=\sigma(z_i),\qquad i=1,\dots,C\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span> 表示“第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个标签是否成立”的独立概率。训练时通常使用逐维二分类交叉熵，即把每个标签都当作一个独立的二分类问题，再在标签维上求和或取平均：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{multi\mbox{-}label}}=-\sum_{i=1}^{C}\Big(y_i\log p_i+(1-y_i)\log(1-p_i)\Big)\]</span>
<p>因此，多标签头的关键不是“输出维度也叫 <span displaypfx="inline-" class="mathjax-container">\(C\)</span>”，而是<span style="background-color: #c0c0c0;">这 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 维之间不竞争，每一维都在独立回答一个 yes/no 问题</span>。推理时也不是取单个 argmax，而是对每一维做阈值判断，例如输出所有满足 <span displaypfx="inline-" class="mathjax-container">\(p_i&gt;0.5\)</span> 的标签，或按业务分别设定不同标签阈值。</p>
<div class="blog_h3"><span class="graybg">回归头</span></div>
<p>回归头（Regression Head）不负责在离散类别之间做判别，而是直接输出连续数值（Continuous Value）或连续向量。最常见的形式仍然是一层线性映射：</p>
<span displaypfx="" class="mathjax-container">\[\hat{\mathbf{y}}=W\mathbf{h}+\mathbf{b},\qquad \hat{\mathbf{y}}\in\mathbb{R}^{m}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 是回归目标维度。若 <span displaypfx="inline-" class="mathjax-container">\(m=1\)</span>，就是标量回归，例如房价预测、评分预测、温度预测；若 <span displaypfx="inline-" class="mathjax-container">\(m&gt;1\)</span>，则是多维回归，例如边界框坐标回归、姿态参数回归或多目标数值预测。</p>
<p>回归头通常不接 softmax，也不强制输出落在 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span>。原因很简单：回归任务关心的是数值大小本身，而不是类别概率竞争。训练时常配合均方误差（MSE）、平均绝对误差（MAE）或 Huber Loss。只有当目标值本身有明确范围约束时，才会额外接 sigmoid、tanh 或其他变换，把输出压到指定区间。</p>
<div class="blog_h3"><span class="graybg">语言模型头（LM Head）</span></div>
<p>语言模型头（Language Modeling Head, LM Head）是把隐藏表示映射回词表空间的输出头。只要任务目标是“在若干位置上对词表中的 token 做预测”，就会出现这一类头；因此它不只存在于 Decoder-only 大模型，也存在于 Encoder-only 的掩码语言模型（Masked Language Model, MLM）以及 Encoder-Decoder 的生成端。它读取主干网络在每个位置输出的隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\;\times d_{\text{model}}}\)</span>，并把每个位置的表示投影到整张词表空间，得到词表 logits：</p>
<span displaypfx="" class="mathjax-container">\[Z = HW_{\text{vocab}} + \mathbf{1}b^\top,\quad W_{\text{vocab}}\in\mathbb{R}^{d_{\text{model}}\;\times {V}},\ Z\in\mathbb{R}^{L\;\times {V}}\]</span>
<p>这条式子先描述整体矩阵，再自然落到单个位置。这里 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 是序列长度，表示当前一共有多少个位置； <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 是隐藏维度； <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 是词表大小（Vocabulary Size）。因此，隐藏状态矩阵 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\;\times d_{\text{model}}}\)</span> 的每一行对应一个位置的表示，输出权重矩阵 <span displaypfx="inline-" class="mathjax-container">\(W_{\text{vocab}}\in\mathbb{R}^{d_{\text{model}}\;\times {V}}\)</span> 的每一列对应“词表中某个 token 作为候选答案时的读出方向”，最终得到的 <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{L\;\times {V}}\)</span> 就是一个“位置 <span displaypfx="inline-" class="mathjax-container">\(\times\)</span> 词表”的打分表：行表示位置，列表示候选 token。</p>
<p>把这张打分表聚焦到第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 行，就得到 <span displaypfx="inline-" class="mathjax-container">\(Z_{t,:}\in\mathbb{R}^{V}\)</span>，也就是“第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置对整张词表所有 token 的一整行 logits”。这里 <span displaypfx="inline-" class="mathjax-container">\(H_t\in\mathbb{R}^{d_{\text{model}}}\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置的隐藏状态，冒号 <span displaypfx="inline-" class="mathjax-container">\(:\)</span> 表示该行的全部列；若写成 <span displaypfx="inline-" class="mathjax-container">\(Z_{:,i}\)</span>，则表示第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 列，也就是“所有位置对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 token 的分数”。继续缩小到单个元素 <span displaypfx="inline-" class="mathjax-container">\(Z_{t,i}\)</span>，它表示“在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置，把词表中第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 token 作为下一个输出时的原始分数”。</p>
<p>把矩阵形式按单个位置、单个候选 token 展开后，打分可写成：</p>
<span displaypfx="" class="mathjax-container">\[Z_{t,i}=H_t\cdot W_{\text{vocab},:,i}+b_i\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(W_{\text{vocab},:,i}\)</span> 表示输出权重矩阵的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 列，也就是与词表第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 token 对应的参数向量； <span displaypfx="inline-" class="mathjax-container">\(b_i\)</span> 是该 token 的偏置项。这个公式的读法是：拿第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置的隐藏表示 <span displaypfx="inline-" class="mathjax-container">\(H_t\)</span>，与“token <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 的读出向量”做一次点积，再加偏置，就得到该 token 在该位置的 logit。</p>
<p>LM Head 与分类头的根本区别在于输出空间。普通分类头通常只需输出 <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 个类别分数；LM Head 则要在每个位置输出 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 个分数，而 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 往往达到几万甚至几十万。因此，LM Head 本质上是“逐位置的大规模多分类器”：每一步都在问“下一个 token 应该是词表中的哪一个”。</p>
<p>同一个 LM Head 公式，在不同 Transformer 架构里的使用方式并不相同。对 Encoder-only 模型，LM Head 通常服务于掩码语言建模：模型先用双向注意力得到各位置隐藏状态，再只在被遮蔽的位置上读取 <span displaypfx="inline-" class="mathjax-container">\(Z_{t,:}\)</span> 来预测原 token；这一过程通常是一次性编码，不涉及自回归生成，也没有 KV Cache 逐步增长的问题。对 Decoder-only 模型，LM Head 用于 next-token 预测：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置的隐藏状态对应预测 <span displaypfx="inline-" class="mathjax-container">\(x_{t+1}\)</span>，推理时会逐步生成，因此会配合因果注意力（Causal Self-Attention）和 KV Cache。对 Encoder-Decoder 模型，LM Head 位于解码器一侧：编码器先产出源序列表示，解码器再在因果自注意力与交叉注意力（Cross-Attention）的共同作用下生成目标侧隐藏状态，最后由 LM Head 映射到词表。</p>
<p>训练时，自回归语言模型（Autoregressive Language Model）通常采用 next-token 目标：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置的隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(H_t\)</span> 用来预测真实的下一个 token <span displaypfx="inline-" class="mathjax-container">\(x_{t+1}\)</span>。对应损失可写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{LM}}=-\sum_{t=1}^{L-1}\log \frac{\exp(Z_{t,x_{t+1}})}{\sum_{i=1}^{V}\exp(Z_{t,i})}\]</span>
<p>这个损失公式也可以逐项拆开理解。左边的 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}_{\mathrm{LM}}\)</span> 是整段序列的语言模型损失；求和下标 <span displaypfx="inline-" class="mathjax-container">\(t=1,\dots,L-1\)</span> 表示：前 <span displaypfx="inline-" class="mathjax-container">\(L-1\)</span> 个位置都要各自预测一次下一个 token。分子里的 <span displaypfx="inline-" class="mathjax-container">\(\exp(Z_{t,x_{t+1}})\)</span> 表示“真实下一个 token 在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置对应的指数化分数”；这里 <span displaypfx="inline-" class="mathjax-container">\(x_{t+1}\)</span> 是真实的下一个 token id，所以 <span displaypfx="inline-" class="mathjax-container">\(Z_{t,x_{t+1}}\)</span> 表示在位置 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 对这个真实 token 的 logit。分母 <span displaypfx="inline-" class="mathjax-container">\(\sum_{i=1}^{V}\exp(Z_{t,i})\)</span> 则把整张词表所有候选 token 的分数全部加起来做归一化。因此整个分式就是“在位置 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 预测真实下一个 token 的概率”，外面的负对数再把它变成训练损失。</p>
<p>推理时则不是直接输出标签，而是先对 <span displaypfx="inline-" class="mathjax-container">\(Z_{t,:}\)</span> 做 softmax 得到下一个 token 的条件分布。这里的 <span displaypfx="inline-" class="mathjax-container">\(Z_{t,:}\)</span> 不是一个标量，而是长度为 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 的向量，包含位置 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 对整张词表每个 token 的分数。对这整行做 softmax 后，得到的是：</p>
<span displaypfx="" class="mathjax-container">\[p(x_{t+1}=i\mid x_{\le t})=\frac{e^{Z_{t,i}}}{\sum_{j=1}^{V}e^{Z_{t,j}}},\qquad i=1,\dots,V\]</span>
<p>这条式子表示：在已经看到前缀 <span displaypfx="inline-" class="mathjax-container">\(x_{\le t}\)</span> 的条件下，下一个 token 取词表中第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个词的概率是多少。随后再配合贪心搜索（Greedy Decoding）、束搜索（Beam Search）、温度采样（Temperature Sampling）、top-k / top-p 等策略，从这组概率中选出真正生成的 token。也就是说，LM Head 负责把隐藏表示变成“词表级候选分数”，真正的文本生成还要再经过一层解码（Decoding）策略。</p>
<p>很多 LLM 还会使用权重共享（Weight Tying）：把输入嵌入表 <span displaypfx="inline-" class="mathjax-container">\(E\in\mathbb{R}^{V\;\times d_{\text{model}}}\)</span> 与输出头绑定，使 <span displaypfx="inline-" class="mathjax-container">\(W_{\text{vocab}}=E^\top\)</span>。这样一来，输入端“一个 token 的向量表示”与输出端“一个 token 作为候选答案时的原型向量”共用同一套参数空间。它通常既能减少参数量，也让输入和输出语义空间保持更强一致性。</p>
<p>从工程角度看，LM Head 往往比分类头更贵，因为它直接与词表大小 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 成正比：词表越大，最后一层的矩阵乘法、softmax 和采样都越重。因此，大模型的推理优化常会专门围绕 LM Head 展开，例如 fused softmax、采样优化、logits processor、词表裁剪或 speculative decoding 等。本质上，这些优化都在解决同一个问题：<span style="background-color: #c0c0c0;">如何更高效地从词表 logits 走到最终生成 token</span>。</p>
<div class="blog_h3"><span class="graybg">序列标注头（Token Classification）</span></div>
<p>序列标注（Sequence Labeling）/Token 分类（Token Classification）要求对每个 token 预测一个标签（例如 NER）。设主干输出 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{L\times d}\)</span>（长度 <span displaypfx="inline-" class="mathjax-container">\(L\)</span>，隐藏维 <span displaypfx="inline-" class="mathjax-container">\(d\)</span>），则逐 token 线性头为：</p>
<span displaypfx="" class="mathjax-container">\[Z=HW^\top+\mathbf{1}b^\top,\quad W\in\mathbb{R}^{C\times d},\ Z\in\mathbb{R}^{L\times C}\]</span>
<p>对第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个 token，用 softmax 得到 <span displaypfx="inline-" class="mathjax-container">\(p(y_t|x)\)</span> 并用逐 token 交叉熵训练。该做法把每个位置的标签看作条件独立，能工作，但会忽略标签之间的结构约束（例如 BIO 体系中不允许 <pre class="crayon-plain-tag">I-ORG</pre> 紧跟 <pre class="crayon-plain-tag">O</pre>）。</p>
<p>例：对 “Apple Inc. is in California” 的 NER（Named Entity Recognition），合理标签序列可能是 <pre class="crayon-plain-tag">B-ORG I-ORG O O B-LOC</pre>。若逐 token softmax 独立预测，模型可能输出不合法的组合；这类“结构错误”通常需要结构化任务头（Structured Head）来显式建模。</p>
<div class="blog_h4"><span class="graybg">条件随机场（CRF）</span></div>
<p>线性链条件随机场（Linear-chain Conditional Random Field, CRF）在 token 分类头上增加一个转移矩阵（Transition Matrix）<span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{C\times C}\)</span>，对整段标签序列做归一化建模。令发射分数（Emission Score）为 <span displaypfx="inline-" class="mathjax-container">\(s_t(y)=Z_{t,y}\)</span>，则序列 <span displaypfx="inline-" class="mathjax-container">\(y_{1:L}\)</span> 的总分为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{score}(x,y)=\sum_{t=1}^{L}\Big(A_{y_{t-1},y_t}+s_t(y_t)\Big)\]</span>
<p>并定义条件概率：</p>
<span displaypfx="" class="mathjax-container">\[p(y|x)=\frac{\exp(\mathrm{score}(x,y))}{\sum_{y'}\exp(\mathrm{score}(x,y'))}\]</span>
<p>训练最小化负对数似然（Negative Log-Likelihood）；解码用 Viterbi 算法（动态规划）求 <span displaypfx="inline-" class="mathjax-container">\(\arg\max_y \mathrm{score}(x,y)\)</span>。CRF 的收益是把“标签合法性/连贯性”学进转移项，从而显著减少结构错误。</p>
<div class="blog_h3"><span class="graybg">双仿射头（Biaffine）</span></div>
<p>双仿射（Biaffine）任务头常用于“成对打分”（Pairwise Scoring），例如依存句法（Dependency Parsing）里的“当前词应依附到哪个词”，或关系抽取（Relation Extraction）里的“两个实体之间是否存在某种关系”。它的名字可以按层级直接理解：对一个变量， <span displaypfx="inline-" class="mathjax-container">\(Wx\)</span> 是线性， <span displaypfx="inline-" class="mathjax-container">\(Wx+b\)</span> 是仿射；对两个变量， <span displaypfx="inline-" class="mathjax-container">\(h_i^\top U h_j\)</span> 是双线性（Bilinear）；在这个双线性项之外再加上线性项和偏置项，就得到双仿射（Biaffine）。因此，双仿射的本质是：<span style="background-color: #c0c0c0;">既建模两个表示之间的交互，又保留各自单独的角色偏好</span>。</p>
<span displaypfx="" class="mathjax-container">\[s(i,j)=h_i^\top U h_j + w^\top [h_i;h_j] + b\]</span>
<p>这条式子可以逐项拆开读。 <span displaypfx="inline-" class="mathjax-container">\(h_i,h_j\in\mathbb{R}^{d}\)</span> 是两个待配对对象的表示向量；在依存句法里，它们可分别表示“当前词”和“候选 head”；在关系抽取里，它们可分别表示“实体 1”和“实体 2”。 <span displaypfx="inline-" class="mathjax-container">\(U\in\mathbb{R}^{d\times d}\)</span> 是双线性参数矩阵，因此 <span displaypfx="inline-" class="mathjax-container">\(h_i^\top U h_j\)</span> 建模的是二者之间的交互强度：不是只看 <span displaypfx="inline-" class="mathjax-container">\(h_i\)</span> 自己，也不是只看 <span displaypfx="inline-" class="mathjax-container">\(h_j\)</span> 自己，而是看“这两个向量放在一起是否匹配”。把它展开后就是 <span displaypfx="inline-" class="mathjax-container">\(\sum_{p=1}^{d}\sum_{q=1}^{d}(h_i)_p\,U_{pq}\,(h_j)_q\)</span>，因此 <span displaypfx="inline-" class="mathjax-container">\(U_{pq}\)</span> 可以理解为“第 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 个 dependent 特征与第 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> 个 head 特征同时出现时，该给多少分”。</p>
<p>若任务需要一次输出多类关系分数，工程实现通常会为每个类别各放一张矩阵 <span displaypfx="inline-" class="mathjax-container">\(U^{(k)}\)</span>，或直接使用三阶参数张量。这样不同关系类型就能拥有不同的交互模式，而不必共用同一套 <span displaypfx="inline-" class="mathjax-container">\(U\)</span>。</p>
<p><span displaypfx="inline-" class="mathjax-container">\([h_i;h_j]\in\mathbb{R}^{2d}\)</span> 表示把两个向量直接拼接； <span displaypfx="inline-" class="mathjax-container">\(w^\top [h_i;h_j]\)</span> 是普通仿射项，可以进一步拆成“只依赖 <span displaypfx="inline-" class="mathjax-container">\(h_i\)</span> 的线性项 + 只依赖 <span displaypfx="inline-" class="mathjax-container">\(h_j\)</span> 的线性项”。它的作用是补充单边信息：即使暂时不看两者之间的乘性交互，某些 token 或实体本身也可能更像 head、更像 dependent，或更像某类关系的一端。最后的 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是全局偏置，给整类配对提供一个基线分数。</p>
<p>具象地看， <span displaypfx="inline-" class="mathjax-container">\(h_i^\top U h_j\)</span> 像在问“这两个人之间是否搭得上”， <span displaypfx="inline-" class="mathjax-container">\(w^\top [h_i;h_j]\)</span> 像在问“这两个人各自单独看，是否本来就带某种角色倾向”。把两部分合起来，模型既能利用配对关系，也不会丢掉单个对象自身的类型线索。这正是双仿射通常比纯双线性更稳、更有表达力的原因。</p>
<p>把 <span displaypfx="inline-" class="mathjax-container">\(s(i,j)\)</span> 对所有 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 组合都算出来，就能形成一个 <span displaypfx="inline-" class="mathjax-container">\(L\times L\)</span> 的分数矩阵；对每个位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span>，再在所有候选 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 上做 <span displaypfx="inline-" class="mathjax-container">\(\arg\max\)</span> 或 softmax 分类，就能得到“它最可能依附到谁”或“它与谁最可能存在某种关系”。这种头的关键点在于：任务监督信号作用在“对”的层面，而不是单点分类。</p>
<div class="blog_h3"><span class="graybg">解析式 NLP（Analytical NLP）任务选型</span></div>
<p>解析式 NLP（Analytical NLP）指一类“结构化输出”的语言任务：输出不是一段自由文本，而是标签序列、树或图。这类任务的工程难点通常不在 backbone，而在<span style="background-color: #c0c0c0;">输出结构约束、标注成本、以及错误后果</span>（例如信息抽取用于合规/风控）。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">任务</td>
<td style="text-align: center;">输出结构</td>
<td style="text-align: center;">常见任务头</td>
<td style="text-align: center;">数据/标注成本</td>
<td style="text-align: center;">蒸馏难度</td>
</tr>
</thead>
<tbody>
<tr>
<td>NER</td>
<td>BIO 标签序列</td>
<td>Token 分类；CRF（可选）</td>
<td>中（需要一致的标注规范）</td>
<td>低~中（教师模型能稳定给出 token-level 或 span-level 标签）</td>
</tr>
<tr>
<td>DEP（Dependency Parsing）</td>
<td>树（每 token 一个 head）</td>
<td>Biaffine / 图解析器（Parser）</td>
<td>高（标注复杂；语言差异大）</td>
<td>中~高（结构约束更强；蒸馏需覆盖长尾句式）</td>
</tr>
<tr>
<td>SDP（Semantic Dependency Parsing）</td>
<td>有向图（多 head / 多边）</td>
<td>图结构头（Graph Head）</td>
<td>很高（语义标注成本高）</td>
<td>高（输出空间更大；错误更难用局部规则修正）</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">多任务与多头</span></div>
<p>同一个 backbone 可以挂多个任务头（Multi-head / Multi-task）：共享表示学习带来数据效率，但不同任务的梯度可能冲突，需要权衡损失权重（Loss Weighting）与采样策略。工程上也常见“同任务多头”：例如同时输出 token 标签与 span 边界，或同时输出检索 embedding 与分类 logits，用不同头对齐不同指标。</p>
<div class="blog_h2"><span class="graybg">反向传播</span></div>
<p>反向传播（Backpropagation）描述的是训练阶段中“如何把损失函数对输出的误差信号传回网络内部，并进一步求出各层参数梯度”的过程。理解这一章时，可以把它拆成三件事：前向传播先把值算出来，反向传播再把梯度传回去，而链式法则与 Jacobian 则给出这件事的数学形式。</p>
<div class="blog_h3"><span class="graybg">前向传播</span></div>
<p>前向传播（Forward Propagation）是训练与推理中首先发生的计算过程：给定输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 和当前参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span>，按照网络定义从前到后依次计算各层输出、最终预测 <span displaypfx="inline-" class="mathjax-container">\(\hat y\)</span>，以及训练时对应的损失 <span displaypfx="inline-" class="mathjax-container">\(L\)</span>。若把网络写成函数复合</p>
<span displaypfx="" class="mathjax-container">\[h_1=f_0(x),\quad h_2=f_1(h_1),\quad \cdots,\quad h_L=f_{L-1}(h_{L-1}),\quad \hat y=g(h_L),\quad L=\ell(\hat y,y)\]</span>
<p>那么前向传播做的就是按 <span displaypfx="inline-" class="mathjax-container">\(x\to h_1\to h_2\to \cdots \to h_L\to \hat y \to L\)</span> 的顺序把这些值算出来。它回答的问题是：<span style="background-color: #c0c0c0;">在当前参数下，这个样本会被模型算成什么结果，以及这个结果与真实标签相差多大</span>。</p>
<p>前向传播的产物不仅是最终预测，还包括中间激活值（Activation）。这些中间量一方面构成模型当前这次推理的内部表示，另一方面也是后续反向传播计算梯度时必须依赖的节点。因此，训练过程总是先有前向传播，再有反向传播：前者负责算值，后者负责算这些值对参数的敏感度。</p>
<div class="blog_h3"><span class="graybg">反向传播</span></div>
<p>反向传播（Backpropagation）是深度学习里用于<span style="background-color: #c0c0c0;">高效计算梯度（Gradient）</span>的算法。训练神经网络的目标，是最小化损失函数（Loss Function）<span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span>；而要用梯度下降（Gradient Descent）或 Adam 之类的优化器更新参数，就必须先知道每个参数对损失的影响，也就是 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial \theta}\)</span>。</p>
<p>这个需求在深层网络里会立刻变得庞大。一个模型往往包含从数万到数十亿个参数；如果对每个参数都单独做一次数值扰动，逐个估计“它变一点时损失会怎么变”，计算代价会高得无法训练。反向传播出现的直接原因，就是要把这个原本几乎不可做的问题，变成一次前向传播（Forward Pass）加一次反向传播（Backward Pass）即可完成的梯度计算流程。</p>
<p>它的来源并不神秘：神经网络本质上是许多简单函数的复合。线性层、激活函数、归一化、注意力、损失函数，都会把前一层输出当作后一层输入。只要这些局部变换可导（Differentiable）或几乎处处可导，就可以对整条复合链应用链式法则（Chain Rule）。反向传播就是把链式法则改写成一种适合计算图（Computational Graph）执行的程序：<span style="background-color: #c0c0c0;">前向时保存必要的中间结果，反向时从最终损失出发，把局部导数一层层乘回去，并把梯度分发给每个中间变量和参数</span>。</p>
<p>因此，反向传播不是另一条独立于微积分的“神经网络专用规则”，而是链式法则在复合函数上的工程化实现。它做的事情可以概括为两步：第一步，前向传播先算出各层激活值与最终损失；第二步，从 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial L}=1\)</span> 出发，把损失敏感度沿着依赖边反向传回去，依次得到 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial h_l}\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial W_l}\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial b_l}\)</span> 等量。到这一步为止，反向传播完成的是<span style="background-color: #c0c0c0;">梯度计算</span>；参数真正如何更新，则属于优化器（Optimizer）的职责。</p>
<p>最简单的梯度下降（Gradient Descent）更新写成</p>
<span displaypfx="" class="mathjax-container">\[\theta \leftarrow \theta - \eta \nabla_{\theta} L\]</span>
<p>这里更适合写梯度符号 <span displaypfx="inline-" class="mathjax-container">\(\nabla_{\theta}L\)</span>，因为 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 往往是整个参数向量或参数集合，而不是单个标量。若只讨论某一个标量参数，才可写成 <span displaypfx="inline-" class="mathjax-container">\(\theta \leftarrow \theta - \eta \frac{\partial L}{\partial \theta}\)</span>。从计算方式看，反向传播对应自动微分（Automatic Differentiation）中的反向模式（Reverse Mode AD）。它特别适合“输入参数很多、输出损失很少”的场景；而神经网络训练恰好就是这种结构：参数维度极大，但最终通常只关心一个标量损失。因此，反向传播成为现代深度学习训练的标准机制。</p>
<div class="blog_h4"><span class="graybg">一个最小手算例子</span></div>
<p>先看一个只多加一层激活函数（Activation）的最小网络。设输入 <span displaypfx="inline-" class="mathjax-container">\(x=2\)</span>，参数为权重 <span displaypfx="inline-" class="mathjax-container">\(w=1\)</span> 与偏置 <span displaypfx="inline-" class="mathjax-container">\(b=-1\)</span>，先做仿射变换（Affine Transform）</p>
<span displaypfx="" class="mathjax-container">\[z=wx+b=1\cdot 2-1=1\]</span>
<p>再经过 ReLU 激活函数</p>
<span displaypfx="" class="mathjax-container">\[a=\mathrm{ReLU}(z)=\max(0,z)=1\]</span>
<p>把激活值当作最终预测，即 <span displaypfx="inline-" class="mathjax-container">\(\hat y=a\)</span>。若真实标签 <span displaypfx="inline-" class="mathjax-container">\(y=0\)</span>，损失取平方损失（Squared Loss）</p>
<span displaypfx="" class="mathjax-container">\[L=\frac12(\hat y-y)^2=\frac12(1-0)^2=0.5\]</span>
<p>前向传播链条现在是</p>
<span displaypfx="" class="mathjax-container">\[x\ \longrightarrow\ z\ \longrightarrow\ a=\hat y\ \longrightarrow\ L\]</span>
<p>反向传播先从损失对输出的导数开始：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial \hat y}=\hat y-y=1\]</span>
<p>由于这里 <span displaypfx="inline-" class="mathjax-container">\(\hat y=a\)</span>，所以</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial a}=1\]</span>
<p>再经过激活函数这一层。因为当前 <span displaypfx="inline-" class="mathjax-container">\(z=1&gt;0\)</span>，ReLU 的导数为</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial a}{\partial z}=\mathrm{ReLU}'(z)=1\]</span>
<p>于是梯度传回仿射层输出：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial z}=\frac{\partial L}{\partial a}\cdot\frac{\partial a}{\partial z}=1\cdot 1=1\]</span>
<p>最后再看仿射层对参数的局部导数：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial z}{\partial w}=x=2,\qquad \frac{\partial z}{\partial b}=1\]</span>
<p>因此</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w}=\frac{\partial L}{\partial z}\cdot\frac{\partial z}{\partial w}=1\cdot 2=2\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial b}=\frac{\partial L}{\partial z}\cdot\frac{\partial z}{\partial b}=1\cdot 1=1\]</span>
<p>若学习率 <span displaypfx="inline-" class="mathjax-container">\(\eta=0.1\)</span>，梯度下降更新后</p>
<span displaypfx="" class="mathjax-container">\[w\leftarrow 1-0.1\cdot 2=0.8,\qquad b\leftarrow -1-0.1\cdot 1=-1.1\]</span>
<p>这个版本比纯线性例子多了一站 <span displaypfx="inline-" class="mathjax-container">\(z\to a\)</span>，因此链式法则也多乘了一个局部导数 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial a}{\partial z}\)</span>。这正是反向传播的一般形式：<span style="background-color: #c0c0c0;">误差信号每穿过一层，就乘上这一层自己的局部导数</span>。若这里的 <span displaypfx="inline-" class="mathjax-container">\(z&lt;0\)</span>，则 ReLU 导数为 0，上游梯度也会在这一层被截断。</p>
<div class="blog_h3"><span class="graybg">链式法则与雅可比连乘</span></div>
<p>反向传播（Backpropagation）本质是链式法则（Chain Rule）在计算图（Computational Graph）上的系统化应用：把最终损失对中间变量的导数沿依赖关系向后传。</p>
<p>固定一个训练样本 <span displaypfx="inline-" class="mathjax-container">\((x,y)\)</span> 后，损失 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 最终当然可以看成参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 的函数；训练时真正要更新的也确实是权重（Weight）。但在计算图里， <span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span> 并不是一步直接从 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 跳到损失，而是先经过各层线性变换、激活值（Activation）和输出再到损失。因此，反向传播会先求 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial h_l}\)</span> 这类“损失对中间激活值的敏感度”，再由这些中间梯度继续求出 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial \theta_l}\)</span>。</p>
<p>Jacobian 一节已经给出局部线性化：若 <span displaypfx="inline-" class="mathjax-container">\(h_{l+1}=f_l(h_l)\)</span>，则在当前点附近有 <span displaypfx="inline-" class="mathjax-container">\(dh_{l+1}\approx J_l\,dh_l\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(J_l=\frac{\partial h_{l+1}}{\partial h_l}\)</span>。这条式子描述的是<span style="background-color: #c0c0c0;">输入扰动如何向前传到输出扰动</span>；反向传播关心的是相反方向的问题：输出端的损失敏感度如何传回输入端。</p>
<p>先看一层。由于损失 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 是标量，若采用与前文 Jacobian 一致的分子布局（Numerator Layout），把 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial h_l}\)</span> 记成 <span displaypfx="inline-" class="mathjax-container">\(1\;\times d_l\)</span> 的行向量，则向量链式法则写成：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_l}=\frac{\partial L}{\partial h_{l+1}}\frac{\partial h_{l+1}}{\partial h_l}=\frac{\partial L}{\partial h_{l+1}}J_l\]</span>
<p>这条式子最好按符号逐个读。先看 <span displaypfx="inline-" class="mathjax-container">\(h_l\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(h_{l+1}\)</span>：它们分别表示第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层的输入表示和该层输出表示。若 <span displaypfx="inline-" class="mathjax-container">\(h_l\)</span> 有 <span displaypfx="inline-" class="mathjax-container">\(d_l\)</span> 个分量， <span displaypfx="inline-" class="mathjax-container">\(h_{l+1}\)</span> 有 <span displaypfx="inline-" class="mathjax-container">\(d_{l+1}\)</span> 个分量，那么</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_{l+1}}=\begin{bmatrix}\frac{\partial L}{\partial h_{l+1,1}} &amp; \frac{\partial L}{\partial h_{l+1,2}} &amp; \cdots &amp; \frac{\partial L}{\partial h_{l+1,d_{l+1}}}\end{bmatrix}\in\mathbb{R}^{1\;\times d_{l+1}}\]</span>
<p>它表示：损失 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 对第 <span displaypfx="inline-" class="mathjax-container">\(l+1\)</span> 层每个输出分量分别有多敏感。这里把它写成行向量，是因为前文采用的是分子布局（Numerator Layout）。</p>
<p>再看 Jacobian：</p>
<span displaypfx="" class="mathjax-container">\[J_l=\frac{\partial h_{l+1}}{\partial h_l}\in\mathbb{R}^{d_{l+1}\;\times d_l}\]</span>
<p>其中第 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 个元素是</p>
<span displaypfx="" class="mathjax-container">\[(J_l)_{ij}=\frac{\partial h_{l+1,i}}{\partial h_{l,j}}\]</span>
<p>意思是：第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个输入分量变化一点，会让第 <span displaypfx="inline-" class="mathjax-container">\(l+1\)</span> 层第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个输出分量变化多少。因此，Jacobian 记录的是“输入各分量 <span displaypfx="inline-" class="mathjax-container">\(\to\)</span> 输出各分量”的局部影响表。</p>
<p>现在看乘法</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_l}=\frac{\partial L}{\partial h_{l+1}}J_l\]</span>
<p>左边最终应该是一个关于 <span displaypfx="inline-" class="mathjax-container">\(h_l\)</span> 各分量的梯度，所以它必须有 <span displaypfx="inline-" class="mathjax-container">\(d_l\)</span> 个分量。维度上， <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial h_{l+1}}\)</span> 是 <span displaypfx="inline-" class="mathjax-container">\(1\;\times d_{l+1}\)</span>， <span displaypfx="inline-" class="mathjax-container">\(J_l\)</span> 是 <span displaypfx="inline-" class="mathjax-container">\(d_{l+1}\;\times d_l\)</span>，两者相乘后确实得到 <span displaypfx="inline-" class="mathjax-container">\(1\;\times d_l\)</span>。</p>
<p>如果把第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个分量单独展开，这个乘法其实就是</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_{l,j}}=\sum_{i=1}^{d_{l+1}}\frac{\partial L}{\partial h_{l+1,i}}\frac{\partial h_{l+1,i}}{\partial h_{l,j}}\]</span>
<p>这时含义就完全显出来了：第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个输入分量，会通过第 <span displaypfx="inline-" class="mathjax-container">\(l+1\)</span> 层的所有输出分量共同影响损失；因此要把“损失对每个输出分量的敏感度”乘上“该输出分量对当前输入分量的局部导数”，再对所有输出分量求和。矩阵乘法只是把这组求和一次性写成了紧凑形式。</p>
<p>把这条一层公式沿网络递推展开，对深度网络 <span displaypfx="inline-" class="mathjax-container">\(h_{l+1}=f_l(h_l)\)</span> 可得：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_0}=\frac{\partial L}{\partial h_L}J_{L-1}J_{L-2}\cdots J_0,\qquad J_l=\frac{\partial h_{l+1}}{\partial h_l}\]</span>
<p>这条公式的读法非常具体：前向传播按 <span displaypfx="inline-" class="mathjax-container">\(h_0\to h_1\to\cdots\to h_L\)</span> 计算；反向传播则从最终梯度 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial h_L}\)</span> 出发，依次乘上最后一层到第一层的 Jacobian，把敏感度一层层传回去。矩阵乘法满足结合律（Associativity），因此可以逐层累积；但不满足交换律，乘法顺序不能改，因为每个 Jacobian 都对应不同层的局部坐标变换。</p>
<p>一个最小可算例可以把这件事完全写开。设输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是标量，第一层有两个参数 <span displaypfx="inline-" class="mathjax-container">\(w_{11},w_{12}\)</span>，输出二维隐藏表示：</p>
<span displaypfx="" class="mathjax-container">\[h_1=\begin{bmatrix}h_{1,1}\\h_{1,2}\end{bmatrix}=\begin{bmatrix}w_{11}x\\w_{12}x\end{bmatrix}\]</span>
<p>第二层有两个参数 <span displaypfx="inline-" class="mathjax-container">\(w_{21},w_{22}\)</span>，把二维隐藏表示读成一个标量预测：</p>
<span displaypfx="" class="mathjax-container">\[\hat y=w_{21}h_{1,1}+w_{22}h_{1,2}\]</span>
<p>损失取最简单的平方损失（Squared Loss）：</p>
<span displaypfx="" class="mathjax-container">\[L=\frac12(\hat y-y)^2\]</span>
<p>这里总参数一共四个，但反向传播不会直接去“猜” <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial w_{11}}\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial w_{12}}\)</span> 等结果，而是先沿着计算图写出中间量：</p>
<span displaypfx="" class="mathjax-container">\[x\ \longrightarrow\ h_1\ \longrightarrow\ \hat y\ \longrightarrow\ L\]</span>
<p>先看第二层的局部 Jacobian。因为 <span displaypfx="inline-" class="mathjax-container">\(\hat y\)</span> 是标量、 <span displaypfx="inline-" class="mathjax-container">\(h_1\in\mathbb{R}^2\)</span>，所以</p>
<span displaypfx="" class="mathjax-container">\[J_1=\frac{\partial \hat y}{\partial h_1}=\begin{bmatrix}w_{21}&amp;w_{22}\end{bmatrix}\]</span>
<p>再看第一层的局部 Jacobian。因为 <span displaypfx="inline-" class="mathjax-container">\(h_1\in\mathbb{R}^2\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是标量，所以</p>
<span displaypfx="" class="mathjax-container">\[J_0=\frac{\partial h_1}{\partial x}=\begin{bmatrix}w_{11}\\w_{12}\end{bmatrix}\]</span>
<p>损失对最终输出的导数最容易先算：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial \hat y}=\hat y-y\]</span>
<p>于是，损失对隐藏表示的梯度由链式法则得到：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_1}=\frac{\partial L}{\partial \hat y}\frac{\partial \hat y}{\partial h_1}=(\hat y-y)\begin{bmatrix}w_{21}&amp;w_{22}\end{bmatrix}\]</span>
<p>这一步已经把“损失对输出的敏感度”传回到了中间激活值 <span displaypfx="inline-" class="mathjax-container">\(h_1\)</span>。继续往回乘第一层 Jacobian，就得到损失对输入的梯度：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial x}=\frac{\partial L}{\partial \hat y}J_1J_0\]</span>
<span displaypfx="" class="mathjax-container">\[=(\hat y-y)\begin{bmatrix}w_{21}&amp;w_{22}\end{bmatrix}\begin{bmatrix}w_{11}\\w_{12}\end{bmatrix}\]</span>
<span displaypfx="" class="mathjax-container">\[=(\hat y-y)(w_{21}w_{11}+w_{22}w_{12})\]</span>
<p>对参数的梯度也是同样的思路。第二层参数直接作用在 <span displaypfx="inline-" class="mathjax-container">\(\hat y\)</span> 上，因此</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w_{21}}=\frac{\partial L}{\partial \hat y}\frac{\partial \hat y}{\partial w_{21}}=(\hat y-y)h_{1,1}\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w_{22}}=\frac{\partial L}{\partial \hat y}\frac{\partial \hat y}{\partial w_{22}}=(\hat y-y)h_{1,2}\]</span>
<p>第一层参数不直接连到损失，而是先影响 <span displaypfx="inline-" class="mathjax-container">\(h_1\)</span>，再影响 <span displaypfx="inline-" class="mathjax-container">\(\hat y\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(L\)</span>，所以必须经过中间激活值这一站：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w_{11}}=\frac{\partial L}{\partial h_{1,1}}\frac{\partial h_{1,1}}{\partial w_{11}}=[(\hat y-y)w_{21}]\,x\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w_{12}}=\frac{\partial L}{\partial h_{1,2}}\frac{\partial h_{1,2}}{\partial w_{12}}=[(\hat y-y)w_{22}]\,x\]</span>
<p>若取一组具体数值 <span displaypfx="inline-" class="mathjax-container">\(x=2\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(w_{11}=1\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(w_{12}=-1\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(w_{21}=0.5\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(w_{22}=2\)</span>，则前向传播先得到</p>
<span displaypfx="" class="mathjax-container">\[h_1=\begin{bmatrix}2\\-2\end{bmatrix},\qquad \hat y=0.5\cdot 2+2\cdot(-2)=-3,\qquad \frac{\partial L}{\partial \hat y}=\hat y-y=-4\]</span>
<p>于是反向传播依次得到</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial h_1}=-4\begin{bmatrix}0.5&amp;2\end{bmatrix}=\begin{bmatrix}-2&amp;-8\end{bmatrix}\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial x}=\begin{bmatrix}-2&amp;-8\end{bmatrix}\begin{bmatrix}1\\-1\end{bmatrix}=6\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w_{21}}=-4\cdot 2=-8,\qquad \frac{\partial L}{\partial w_{22}}=-4\cdot(-2)=8\]</span>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial w_{11}}=(-2)\cdot 2=-4,\qquad \frac{\partial L}{\partial w_{12}}=(-8)\cdot 2=-16\]</span>
<p>这个最小例子把反向传播的结构完整展示了出来：损失函数最终是参数的函数，但梯度并不是“绕开中间层直接对权重求”。它必须先通过 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial \hat y}\)</span>、 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial L}{\partial h_1}\)</span> 这样的中间敏感度逐步回传。激活值因此不是“额外引入的变量”，而是计算图中必经的节点。</p>
<p>若改用深度学习里更常见的列向量梯度记号 <span displaypfx="inline-" class="mathjax-container">\(\nabla_{h_l}L\in\mathbb{R}^{d_l}\)</span>，同一件事会写成</p>
<span displaypfx="" class="mathjax-container">\[\nabla_{h_l}L=J_l^\top \nabla_{h_{l+1}}L\]</span>
<p>这与上面的公式没有本质区别，只是把“左乘 Jacobian 的行向量记号”改写成了“右侧乘 Jacobian 转置的列向量记号”。工程实现里常见的 Vector-Jacobian Product，本质上就是这一步。</p>
<p>对参数的梯度也是同一个模式。若第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层含参数 <span displaypfx="inline-" class="mathjax-container">\(\theta_l\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial L}{\partial \theta_l}=\frac{\partial L}{\partial h_{l+1}}\frac{\partial h_{l+1}}{\partial \theta_l}\]</span>
<p>因此 backward 的核心不是“把前向再算一遍”，而是复用每一层的局部 Jacobian，把同一个上游梯度分别传给输入变量和参数。</p>
<div class="blog_h3"><span class="graybg">梯度消失与爆炸</span></div>
<p>梯度消失（Vanishing Gradients）与梯度爆炸（Exploding Gradients）通常来自连乘的数值尺度：若这些雅可比的有效增益（Effective Gain）的奇异值（Singular Values）长期小于 1，则梯度指数级衰减；长期大于 1 则指数级放大。</p>
<p>在 RNN 的 BPTT 中，这种现象尤其明显：同一递归矩阵在时间轴上重复相乘。粗略地看，若 <span displaypfx="inline-" class="mathjax-container">\(W_{hh}\)</span> 的谱半径（Spectral Radius）<span displaypfx="inline-" class="mathjax-container">\(\rho(W_{hh})\)</span> 明显大于 1，梯度更易爆炸；明显小于 1 更易消失。但这不是“只要 <span displaypfx="inline-" class="mathjax-container">\(\lambda&gt;1\)</span> 就一定爆炸”的二选一结论，因为实际还受到激活函数导数、归一化、残差/门控结构、以及数据分布的共同影响。</p>
<p>“梯度随训练慢慢变小是否正常？”在接近最优点时，梯度范数下降是预期现象；需要警惕的是训练早期就出现系统性消失（例如大量饱和激活导致导数接近 0）或出现不稳定爆炸（loss/梯度频繁变成 NaN/Inf）。</p>
<p>常见应对：</p>
<ul>
<li>梯度裁剪（Gradient Clipping）：抑制爆炸。</li>
<li>合理初始化（Xavier/He）、归一化（BatchNorm/LayerNorm）、残差连接（Residual）。</li>
<li>门控结构（Gated Units）：LSTM/GRU 通过门控缓解长程梯度问题。</li>
</ul>
<div class="blog_h2"><span class="graybg">权重初始化</span></div>
<p>权重初始化（Weight Initialization）的目标不是“随机给一个小数”，而是控制信号在层间传播的数值尺度。设第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层的线性部分为 <span displaypfx="inline-" class="mathjax-container">\(z^{(l)}=W^{(l)}h^{(l-1)}+b^{(l)}\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(z^{(l)}\)</span> 的二阶原点矩（Second Raw Moment）在层间持续放大，前向激活与反向梯度都会倾向爆炸；若持续衰减，则会出现梯度消失。</p>
<p>对单个神经元，写成 <span displaypfx="inline-" class="mathjax-container">\(z=\sum_{i=1}^{n}w_i x_i+b\)</span>。初始化分析里通常进一步假设 <span displaypfx="inline-" class="mathjax-container">\(w_i\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(x_i\)</span> 相互独立，且权重满足 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[w_i]=0\)</span>。这里的 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[w_i]=0\)</span> 不是在声称“训练后的权重天然应当均值为 0”，而是在分析初始化时，把权重看作从一个以 0 为中心的随机分布中抽样得到；这样做的目的，是避免网络在一开始就对某一方向产生系统性偏置，并让前向输出的均值计算更干净。基于这一初始化假设，在线性部分有</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[z]=0,\quad \mathbb{E}[z^2]=n\,\mathrm{Var}(w)\,\mathbb{E}[x^2]\]</span>
<p>当输入也近似零均值时， <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[x^2]\approx \mathrm{Var}(x)\)</span>，于是常写成 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(z)\approx n\,\mathrm{Var}(w)\,\mathrm{Var}(x)\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 就是 fan_in。因此初始化的核心约束可以概括为：让 <span displaypfx="inline-" class="mathjax-container">\(n\,\mathrm{Var}(w)\)</span> 保持在 1 附近。</p>
<p>更严格地说，深度初始化分析常跟踪的是 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{E}[h^2]\)</span>，而不是所有层都精确使用方差；因为经过 ReLU 之后，激活不再零均值，此时二阶原点矩与方差不再完全相同。但在线性层与零均值近似下，两者是一致的，因此“保持方差稳定”仍是准确的工程表述。</p>
<p>两种朴素做法都会失败：若所有权重都初始化为 0，则各神经元保持完全对称，反向传播得到相同更新，网络退化为“许多拷贝的同一个单元”；若直接令 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(w)=1\)</span>，则线性输出的尺度会随层数按 fan_in 连乘，深层网络极易数值失稳。偏置（Bias）通常初始化为 0 或很小的常数；真正决定尺度的是权重矩阵的方差结构。</p>
<p>这里的 fan_in 与 fan_out 可以直接按“这层连接了多少路信号”来理解。对某个线性层中的一个输出神经元，fan_in 是流入它的输入通道数；输入通道越多，很多随机小贡献叠加后，输出就越容易变大。对某个输入神经元，fan_out 是它连接到下一层多少个输出通道；fan_out 越大，这个输入方向上的梯度在反向传播时就会被分发到更多支路。因此，fan_in 主要约束前向激活的放大量级，fan_out 主要约束反向梯度的放大量级。</p>
<p>具象地看，一个神经元像一个汇流节点：fan_in 决定有多少根水管把信号同时灌进来，fan_out 决定这股信号会被分流到多少个下游节点。初始化如果不考虑这两个连接数，网络就会在“汇流过猛”与“分流过弱”之间失衡。Xavier 正是在前向与反向之间做折中，He 则进一步把激活函数本身带来的能量损失也纳入补偿。</p>
<div class="blog_h3"><span class="graybg">Xavier 初始化</span></div>
<p>Xavier 初始化（Xavier Initialization）也称 Glorot 初始化（Glorot Initialization），针对近似线性的激活工作区间设计，例如 tanh 与 sigmoid 在原点附近的局部线性区域。它的目标是同时控制前向激活与反向梯度的尺度，使它们在穿过每一层时不发生系统性放大或衰减。</p>
<p>若暂时忽略激活函数对二阶原点矩的额外缩放，则前向保持尺度不变要求 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(w)\approx 1/\mathrm{fan\_in}\)</span>；反向对应地要求 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(w)\approx 1/\mathrm{fan\_out}\)</span>。Glorot 给出的折中形式因此写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Var}(w)=\frac{2}{\mathrm{fan\_in}+\mathrm{fan\_out}}\]</span>
<p>常见实现包括正态版 <span displaypfx="inline-" class="mathjax-container">\(w\sim\mathcal{N}\!\left(0,\frac{2}{\mathrm{fan\_in}+\mathrm{fan\_out}}\right)\)</span>，以及均匀版 <span displaypfx="inline-" class="mathjax-container">\(w\sim U\!\left[-\sqrt{\frac{6}{\mathrm{fan\_in}+\mathrm{fan\_out}}},\sqrt{\frac{6}{\mathrm{fan\_in}+\mathrm{fan\_out}}}\right]\)</span>。</p>
<p>从直觉上看，Xavier 初始化像是在给每一层设置一个“不过度放大、也不过度压缩”的中性增益（neutral gain）。可以把一层线性变换想成一组并联的混音器：输入信号从上一层流入，经过许多权重通道混合后送到下一层。Xavier 的目标就是让这组混音器在初始状态下近似保持“总音量”不变，使信号既不会层层变得越来越吵，也不会层层变得越来越弱。</p>
<p>Xavier 的局限在于：它默认激活函数不会系统性丢失太多能量。对 ReLU 这类半波整流（Half-wave Rectification）激活，这个假设不再成立；即使线性层前后的尺度匹配，经过激活后信号的二阶原点矩仍会明显下降。</p>
<div class="blog_h3"><span class="graybg">He 初始化</span></div>
<p>He 初始化（He Initialization）也称 Kaiming 初始化（Kaiming Initialization），专门处理 ReLU 家族的整流效应。若线性输出 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 近似关于 0 对称，经过 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{ReLU}(z)=\max(0,z)\)</span> 后，大约一半的质量被截断为 0，并且</p>
<span displaypfx="" class="mathjax-container">\[\mathbb{E}[\mathrm{ReLU}(z)^2]=\frac{1}{2}\mathbb{E}[z^2]\]</span>
<p>因此，若仍使用 Xavier 量级，信号的二阶原点矩会随层数持续衰减。He 初始化通过把权重方差提高到</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Var}(w)=\frac{2}{\mathrm{fan\_in}}\]</span>
<p>来补偿这一半波损失。对应实现可写为正态版 <span displaypfx="inline-" class="mathjax-container">\(w\sim\mathcal{N}\!\left(0,\frac{2}{\mathrm{fan\_in}}\right)\)</span>，或均匀版 <span displaypfx="inline-" class="mathjax-container">\(w\sim U\!\left[-\sqrt{\frac{6}{\mathrm{fan\_in}}},\sqrt{\frac{6}{\mathrm{fan\_in}}}\right]\)</span>。</p>
<p>具象地看，ReLU 像一道只允许正值通过的闸门：一批近似对称分布的信号经过后，负半边会被直接截成 0，等于天然损失了一部分能量。He 初始化做的事情就是在闸门前把信号预先放大一些，使它通过这道“半波闸门”之后，整体尺度仍能维持在稳定区间。Xavier 假定通道基本不漏能量，He 则明确把 ReLU 的漏损补偿计入初始化方差。</p>
<p>对 Leaky ReLU/PReLU，补偿因子可推广为 <span displaypfx="inline-" class="mathjax-container">\(\frac{2}{(1+a^2)\,\mathrm{fan\_in}}\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 是负半轴斜率。实践上，ReLU 及其变体默认优先使用 He 初始化；tanh/sigmoid 更适合 Xavier。归一化层与残差连接可以进一步放宽初始化的容错区间，但不能替代合理的初始尺度控制。</p>
<div class="blog_h3"><span class="graybg">大语言模型里的初始化</span></div>
<p>Transformer / 大语言模型（Large Language Model, LLM）里的初始化通常不按“整模统一套 Xavier”或“整模统一套 He”来理解，而更像一套围绕<span style="background-color: #c0c0c0;">残差流（Residual Stream）稳定性、归一化层和深度扩展</span>设计的小方差初始化配方。原因在于：LLM 的主干不是简单的“线性层 + 单一激活函数”堆叠，而是由自注意力、残差连接、LayerNorm / RMSNorm、MLP / SwiGLU 等子结构共同组成；纯粹以某个激活函数为中心推导的 Xavier / He，只能覆盖其中一部分局部直觉。</p>
<p>工程上最常见的做法是：嵌入层与线性层权重用零均值的小方差正态分布初始化，偏置置零或省略；随后依靠 LayerNorm / RMSNorm 与残差结构维持训练初期的数值稳定。BERT、GPT-2 一类经典 Transformer 常见做法是使用标准差约为 <span displaypfx="inline-" class="mathjax-container">\(0.02\)</span> 的正态或截断正态初始化；很多更现代的 Decoder-only LLM 仍延续“小方差高斯初始化”这一主线，只是在具体投影层上再叠加按隐藏维度、层数或残差分支做缩放的配方。</p>
<p>从直觉上看，LLM 初始化更像是在给一条很深的多车道主干道设置初始车流密度。若注意力里的 <span displaypfx="inline-" class="mathjax-container">\(Q/K/V/O\)</span> 投影、FFN 的升维/降维投影以及其他会把结果写回残差流的线性层初始尺度过大，残差分支会把信号越叠越猛，训练初期容易震荡；若尺度过小，几十层上百层残差块叠起来后，真正进入有效学习区的信号又会太弱。因此，现代 LLM 初始化的重点往往不是“严格选 Xavier 还是 He”，而是<span style="background-color: #c0c0c0;">让残差支路在深层网络中既能传递信息，又不会在训练一开始就把数值尺度推离稳定区</span>。</p>
<p>具体到模块分工，也可以这样理解：MLP 内部若使用 ReLU 家族激活，He 的补偿思想仍然成立；若使用 GELU、SiLU、SwiGLU 这类更平滑或带门控的激活，则实现里往往直接采用统一的小方差正态初始化，再由归一化、残差和训练配方共同保证稳定性。换言之，LLM 并没有抛弃 Xavier / He 背后的“方差守恒”原则，而是把这套原则嵌入到了更完整的 Transformer 初始化策略里。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">方法</td>
<td style="text-align: center;">核心方差</td>
<td style="text-align: center;">适用激活</td>
<td style="text-align: center;">主要问题 / 逻辑</td>
</tr>
</thead>
<tbody>
<tr>
<td>零初始化</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(w)=0\)</span></td>
<td>无</td>
<td>破坏对称性；所有神经元学到相同特征</td>
</tr>
<tr>
<td>标准正态随机</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathrm{Var}(w)=1\)</span></td>
<td>无</td>
<td>尺度随深度快速放大；易导致爆炸</td>
</tr>
<tr>
<td>Xavier / Glorot</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{2}{\mathrm{fan\_in}+\mathrm{fan\_out}}\)</span></td>
<td>Tanh、Sigmoid</td>
<td>兼顾前向与反向尺度；假设激活近似线性</td>
</tr>
<tr>
<td>He / Kaiming</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\frac{2}{\mathrm{fan\_in}}\)</span></td>
<td>ReLU、Leaky ReLU</td>
<td>补偿 ReLU 的半波截断；保持二阶原点矩稳定</td>
</tr>
<tr>
<td>LLM 常用小方差高斯初始化</td>
<td>常取固定小标准差，或再叠加按宽度 / 深度缩放</td>
<td>Transformer 模块整体</td>
<td>服务残差流、归一化层与深层稳定训练；不是单纯按某一种激活推导</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">正则化</span></div>
<p>正则化（Regularization）在经验风险（Empirical Risk）上增加复杂度惩罚，缓解过拟合（Overfitting）。假设函数/模型 <span displaypfx="inline-" class="mathjax-container">\(f_\theta\)</span> 决定预测；样本损失 <span displaypfx="inline-" class="mathjax-container">\(\ell\)</span> 度量单样本误差；代价函数/成本函数 <span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span> 汇总训练集误差；目标函数 <span displaypfx="inline-" class="mathjax-container">\(J(\theta)\)</span> 则是 <span displaypfx="inline-" class="mathjax-container">\(L(\theta)\)</span> 加上正则化项： <span displaypfx="inline-" class="mathjax-container">\(J(\theta)=L(\theta)+\lambda\Omega(\theta)\)</span>。</p>
<span displaypfx="" class="mathjax-container">\[J(\theta)=\frac{1}{m}\sum_{i=1}^{m}\ell\!\left(f_{\theta}(x^{(i)}),y^{(i)}\right)+\lambda\Omega(\theta)\]</span>
<div class="blog_h3"><span class="graybg">L1 正则化（Lasso）</span></div>
<p>L1 正则化（L1 Regularization）使用 <span displaypfx="inline-" class="mathjax-container">\(\Omega(\theta)=\|\theta\|_1\)</span>。它倾向产生稀疏解（Sparsity），即一部分权重被直接压到 0，因此可同时做参数学习和特征选择（Feature Selection）。</p>
<p>从优化角度看，L1 的梯度是次梯度（Subgradient）：对单个参数 <span displaypfx="inline-" class="mathjax-container">\(\theta_k\)</span>，当 <span displaypfx="inline-" class="mathjax-container">\(\theta_k\ne 0\)</span> 时有 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial}{\partial \theta_k}\|\theta\|_1=\mathrm{sign}(\theta_k)\)</span>；在 0 点是一段区间 <span displaypfx="inline-" class="mathjax-container">\([-1,1]\)</span>。这使得优化过程更容易把小权重“推过 0”，形成精确稀疏。</p>
<p>在很多实现中，会用近端算子（Proximal Operator）给出更清晰的“压到 0”机制：对标量 <span displaypfx="inline-" class="mathjax-container">\(w\)</span>，软阈值化（Soft-Thresholding）是</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{soft}(w,\alpha)=\mathrm{sign}(w)\max(|w|-\alpha,0)\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(|w|\le \alpha\)</span> 时直接变为 0。</p>
<div class="blog_h3"><span class="graybg">L2 正则化（Ridge / Weight Decay）</span></div>
<p>L2 正则化（L2 Regularization）使用 <span displaypfx="inline-" class="mathjax-container">\(\Omega(\theta)=\|\theta\|_2^2\)</span>。它倾向均匀缩小参数，但通常不会把参数精确压到 0。直观上，L1 在 0 点不可导，更容易触发“阈值化”解；L2 是光滑二次惩罚，更新更连续。</p>
<p>梯度层面，L2 会在原梯度上叠加一个“拉回原点”的项：若目标为 <span displaypfx="inline-" class="mathjax-container">\(\ell(\theta)+\lambda\|\theta\|_2^2\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(\nabla_\theta=\nabla \ell(\theta)+2\lambda\theta\)</span>。工程上常把这个效果称为权重衰减（Weight Decay）：每一步都把权重按比例缩小。</p>
<p>需要区分一个常见细节：在自适应优化器（如 Adam）里，“把 <span displaypfx="inline-" class="mathjax-container">\(\lambda\|\theta\|_2^2\)</span> 加到损失里”与“直接做 weight decay”在数值上并不完全等价；因此实践中常用解耦权重衰减（Decoupled Weight Decay，典型实现是 AdamW）来获得更可控的正则化行为。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/regularization-l1-l2-geometry.png"><img class="alignnone size-full" src="https://blog.gmem.cc/wp-content/uploads/2026/03/regularization-l1-l2-geometry.png" alt="regularization-l1-l2-geometry" width="1920" height="930" /></a></p>
<p>把带惩罚的目标改写成约束形式后，几何差异会非常直观： <span displaypfx="inline-" class="mathjax-container">\(\min_\theta L(\theta)\ \mathrm{s.t.}\ \|\theta\|_1\le t\)</span> 的可行域在二维里是菱形， <span displaypfx="inline-" class="mathjax-container">\(\min_\theta L(\theta)\ \mathrm{s.t.}\ \|\theta\|_2\le t\)</span> 的可行域则是圆盘。损失等高线从外向内收缩时，第一次接触 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 边界，往往更容易落在角点；角点恰好对应某些坐标精确等于 0。 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 边界处处光滑，接触点通常只是把所有坐标一起缩小，而不是把其中一部分直接压成 0。</p>
<p>从一维更新机制看，这个差异也对应两种不同的收缩方式。 <span displaypfx="inline-" class="mathjax-container">\(L_2\)</span> 更像连续比例收缩：权重越大，拉回原点的力越强，但通常不会在有限步内变成精确 0。 <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 则对应软阈值化（Soft-Thresholding）：一旦 <span displaypfx="inline-" class="mathjax-container">\(|w|\)</span> 小于阈值，就会被直接压成 0。因此， <span displaypfx="inline-" class="mathjax-container">\(L_1\)</span> 不只是“把权重变小”，而是会主动把一部分坐标从模型中删掉，这就是稀疏性（Sparsity）的来源。</p>
<div class="blog_h3"><span class="graybg">Dropout</span></div>
<p>Dropout 通过随机屏蔽部分神经元输出，减少共适应（Co-adaptation），等价于在训练时对网络做一种随机子网络集成（Ensemble）。这里的共适应，是指若干神经元彼此形成了固定搭配：模型过度依赖它们同时出现、按特定组合共同完成判断，而不是让每个单元都学到相对独立、稳健的特征。Dropout 随机拿掉其中一部分单元后，网络不能再把能力押注在某一组固定配合上，只能把有用模式分散到更稳健的表示里。令隐藏向量为 <span displaypfx="inline-" class="mathjax-container">\(h\)</span>，mask <span displaypfx="inline-" class="mathjax-container">\(m_i\sim \mathrm{Bernoulli}(p)\)</span>，常见的 inverted dropout 写法为：</p>
<span displaypfx="" class="mathjax-container">\[\tilde h = \frac{m\odot h}{p}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 是保留概率（Keep Probability）。这样推理时可直接使用原网络（不再采样 mask），避免额外缩放。</p>
<div class="blog_h3"><span class="graybg">Batch Normalization</span></div>
<p>Batch Normalization（BatchNorm）在训练时用 mini-batch 的均值/方差做归一化，并学习缩放/平移参数。对特征维上的某个分量 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，典型形式是：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{BN}(x)=\gamma\cdot \frac{x-\mu_{\text{batch}}}{\sqrt{\sigma_{\text{batch}}^2+\epsilon}}+\beta\]</span>
<p>推理阶段通常使用训练过程累积的运行均值（Running Mean）与运行方差（Running Variance），因此训练/推理行为不同。BatchNorm 在 CNN 中极常见，因为卷积特征图在同一通道上的空间位置具有较强同质性，跨样本统计量较容易稳定；但在 Transformer 中，主流做法并不是沿 batch 维做归一化，而是使用与 batch 统计无关的 LayerNorm 或 RMSNorm。</p>
<p>原因在于 Transformer 的基本计算单元是 token 表示。序列长度常常可变，batch size 在训练与推理中也经常变化，尤其大模型训练会受到显存限制而使用较小、波动甚至分布式切分后的 micro-batch。若使用 BatchNorm，某个 token 的归一化结果会显式依赖同一 batch 中其他样本与其他位置的统计量，这会引入跨样本耦合，使训练和推理的数值语义不一致，也不利于自回归解码阶段逐 token 稳定生成。</p>
<div class="blog_h3"><span class="graybg">Layer Normalization</span></div>
<p>Layer Normalization（LayerNorm）对每个样本（或每个 token）的特征维做归一化，不依赖 batch 统计量，因此更适合变长序列与自回归推理。对向量 <span displaypfx="inline-" class="mathjax-container">\(x\in\mathbb{R}^{d}\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{LN}(x)=\gamma\odot \frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}+\beta,\quad \mu=\frac{1}{d}\sum_{i=1}^{d}x_i,\ \sigma^2=\frac{1}{d}\sum_{i=1}^{d}(x_i-\mu)^2\]</span>
<p>这里的均值 <span displaypfx="inline-" class="mathjax-container">\(\mu\)</span> 与方差 <span displaypfx="inline-" class="mathjax-container">\(\sigma^2\)</span> 都只在当前样本、当前 token 的特征维内部计算，因此每个 token 都能独立完成归一化。这个性质与 Transformer 的残差流（Residual Stream）非常匹配：无论 batch 如何变化、序列如何裁剪、推理时是否一次只输入一个 token，归一化规则都保持一致。</p>
<p>当前主流的 Transformer 归一化实践可以概括为两类。Encoder-only 与很多 Vision Transformer（ViT）架构通常沿用 LayerNorm；Decoder-only 大语言模型（Large Language Model, LLM）则大量采用 Pre-Norm 残差块，并进一步把 LayerNorm 简化为 RMSNorm。前者保留去均值与方差缩放，后者只保留尺度归一化，计算更轻，也更适合超大规模训练。</p>
<div class="blog_h3"><span class="graybg">Early Stopping</span></div>
<p>Early Stopping 用验证集指标监控训练过程，在泛化性能不再提升时提前停止，从而避免在训练集上继续拟合噪声。工程上常用“耐心（Patience）”：若验证集指标连续 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 次评估都未改善，则停止训练并回滚到最佳 checkpoint。它属于训练流程级的隐式正则化（Implicit Regularization）。</p>
<div class="blog_h2"><span class="graybg">卷积神经网络（CNN）</span></div>
<div class="blog_h3"><span class="graybg">卷积层与池化层</span></div>
<p>卷积（Convolution）在连续形式下定义为函数重叠积分：</p>
<span displaypfx="" class="mathjax-container">\[(f*g)(t)=\int_{-\infty}^{+\infty} f(\tau)\,g(t-\tau)\,d\tau\]</span>
<p>在离散二维图像中常写为：</p>
<span displaypfx="" class="mathjax-container">\[(I*K)(i,j)=\sum_m\sum_n I(i-m,j-n)\,K(m,n)\]</span>
<p>深度学习框架里多数“卷积层”实现的是互相关（Cross-Correlation）而非严格翻转核的数学卷积，但工程上沿用“卷积”命名。卷积核（Kernel）共享权重（Weight Sharing），天然利用局部性（Locality）与平移等变（Translation Equivariance）。</p>
<p>直观上，卷积层做的事情是：对每个位置取一个局部窗口，把窗口里的像素与卷积核做加权求和，得到该位置的响应。不同卷积核学习不同的局部模式（边缘、纹理、角点等）。</p>
<p>一维互相关示例：输入 <span displaypfx="inline-" class="mathjax-container">\(x=[0,0,1,1,1,0,0]\)</span>，核 <span displaypfx="inline-" class="mathjax-container">\(w=[1,0,-1]\)</span>，则在从左到右滑动时，核会对“上升沿/下降沿”给出大幅响应（本质上是比较左右两侧的差异）。这就是经典的边缘检测直觉在离散信号上的对应。</p>
<p>卷积的“系统视角”能统一理解 CNN 与信号处理：把卷积核看作响应函数（Response Kernel），输出就是对历史输入的加权累积。参见“微积分 ➡ 卷积（Convolution）”中的因果卷积与“打点滴累积药效”直觉例子。</p>
<div class="blog_h3"><span class="graybg">经典架构</span></div>
<div class="blog_h4"><span class="graybg">LeNet</span></div>
<p>LeNet 可以看作现代卷积神经网络（Convolutional Neural Network, CNN）的原型。它面对的是手写数字识别这类“局部笔画决定整体类别”的任务，因此核心思想很直接：先用卷积层提取局部边缘、角点和笔画，再用池化（Pooling）降低分辨率与局部扰动敏感性，最后把高层特征送入全连接层做分类。</p>
<p>经典 LeNet-5 的结构可以概括为“卷积 - 池化 - 卷积 - 池化 - 全连接”。前面的卷积层负责把原始像素变成越来越抽象的特征图（Feature Map），后面的全连接层负责把这些局部特征整合成类别判断。它的重要性不只是“识别数字有效”，更在于证明了三件事可以协同工作：局部感受野（Local Receptive Field）、权重共享（Weight Sharing）和层级特征提取（Hierarchical Feature Learning）。</p>
<p>卷积层里的一个典型计算是：</p>
<span displaypfx="" class="mathjax-container">\[y_{i,j}^{(k)}=\sum_{u}\sum_{v}\sum_{c} W_{u,v,c}^{(k)}\,x_{i+u,j+v,c}+b^{(k)}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是输入图像或上一层特征图， <span displaypfx="inline-" class="mathjax-container">\(W^{(k)}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个卷积核， <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 是输入通道索引， <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 是空间位置， <span displaypfx="inline-" class="mathjax-container">\(y_{i,j}^{(k)}\)</span> 是输出特征图在该位置上的响应。这个公式表达的就是：用同一个卷积核在整张图上滑动，寻找“哪里出现了我关心的局部模式”。</p>
<p>训练上，LeNet 已经体现出现代深度学习的基本闭环：前向传播得到类别 logits，损失函数衡量预测与真实标签的差距，反向传播把梯度传回卷积核和全连接层参数，再用梯度下降更新权重。它最典型的应用是 MNIST 手写数字识别，也常被用作理解 CNN 的第一块教学样板，因为结构短、计算图清晰、局部模式学习的直觉非常强。</p>
<div class="blog_h4"><span class="graybg">AlexNet</span></div>
<p>AlexNet 标志着深度卷积网络在大规模视觉识别上的突破。它面对的不是手写数字这种相对简单的灰度图，而是 ImageNet 级别的彩色自然图像，因此核心思想从“能提特征”进一步推进到“更深、更宽、更可训练”：增加网络容量，用 ReLU（Rectified Linear Unit）加快优化，用 Dropout 缓解过拟合，用数据增强提升泛化，再借助 GPU 让大模型真正训得动。</p>
<p>其典型结构是多层卷积堆叠后接全连接层，早期卷积核较大、步幅较大，用于迅速降采样并提取低层纹理；中后期卷积核变小，重点转向更细粒度的组合特征。AlexNet 还使用了局部响应归一化（Local Response Normalization, LRN）这一今天较少使用、但在当时有历史意义的设计。整体上，它把 CNN 从“可用于小型任务的模型”推向了“可以在大规模视觉基准上碾压传统方法的通用架构”。</p>
<p>它最有代表性的非线性是 ReLU：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{ReLU}(z)=\max(0,z)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 是线性层或卷积层的输出。与 sigmoid / tanh 相比，ReLU 在正半轴不饱和，梯度传播更直接，因此深层网络更容易优化。AlexNet 的历史意义之一，就是把“激活函数的选择会显著影响深网络可训练性”这件事变成了工程共识。</p>
<p>训练上，AlexNet 典型地结合随机裁剪、翻转、颜色扰动等数据增强，并在全连接层使用 Dropout。它的直接应用是大规模图像分类；更深远的影响则是推动了整个视觉领域转向“预训练 CNN + 迁移学习”的工作流。很多后续检测、分割与检索系统，都曾把 AlexNet 当作特征提取骨干网络（Backbone）。</p>
<div class="blog_h4"><span class="graybg">VGG</span></div>
<p>VGG 的核心思想是：用结构极其规整的小卷积核反复堆叠，把网络做深。它放弃了早期“大卷积核 + 大步幅”的粗放设计，转而几乎全程使用 <span displaypfx="inline-" class="mathjax-container">\(3\times 3\)</span> 卷积，通过增加层数来扩大感受野、提高非线性表达能力，并让整套架构在工程上更统一、更易复用。</p>
<p>VGG 的典型结构非常整齐：若干个 <span displaypfx="inline-" class="mathjax-container">\(3\times 3\)</span> 卷积层组成一个 stage，stage 之间通过池化层下采样，最后再接全连接分类头。它的重要工程思想是“深度本身就是能力来源之一”。相较于更杂糅的早期网络，VGG 的层次感非常强：浅层提边缘和纹理，中层提局部部件，高层提更完整的语义结构。</p>
<p>为什么反复使用 <span displaypfx="inline-" class="mathjax-container">\(3\times 3\)</span> 卷积有效，可以从感受野和参数量两方面理解。两个连续的 <span displaypfx="inline-" class="mathjax-container">\(3\times 3\)</span> 卷积，其有效感受野接近一个 <span displaypfx="inline-" class="mathjax-container">\(5\times 5\)</span> 卷积，但中间多了一次非线性变换，参数量通常还更少。若忽略通道数变化，单层 <span displaypfx="inline-" class="mathjax-container">\(5\times 5\)</span> 卷积大约有 25 个核参数，而两层 <span displaypfx="inline-" class="mathjax-container">\(3\times 3\)</span> 卷积一共是 18 个核参数。</p>
<p>训练上，VGG 比 AlexNet 更依赖较好的初始化、较强的正则化和更大的算力预算，因为它的参数量尤其在全连接部分非常大。它的直接应用是图像分类，但工程上更著名的是作为“通用视觉特征提取器”：在风格迁移、感知损失（Perceptual Loss）、检测与分割早期系统中，VGG 特征长期是强基线。它的代价也很明显：参数多、推理重、显存占用高，这直接推动了后续更高效架构的出现。</p>
<div class="blog_h4"><span class="graybg">ResNet</span></div>
<p>ResNet（Residual Network）的关键突破不是再把卷积堆得更深，而是重新设计“深层网络应该学什么”。它提出残差学习（Residual Learning）：一层或一组层不必直接学习完整映射 <span displaypfx="inline-" class="mathjax-container">\(H(x)\)</span>，而是学习相对于输入的增量 <span displaypfx="inline-" class="mathjax-container">\(F(x)=H(x)-x\)</span>。这样网络输出就写成：</p>
<span displaypfx="" class="mathjax-container">\[y=F(x)+x\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是块输入， <span displaypfx="inline-" class="mathjax-container">\(F(x)\)</span> 是若干卷积层、归一化和激活组成的残差分支输出， <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 是该残差块的最终输出。若最优映射本身就接近恒等映射（Identity Mapping），那么让网络学习“只改一点点”通常比“从零重建整个映射”更容易优化。</p>
<p>具象地看，普通深网络像要求每一层都重新写一遍完整答案；ResNet 则允许每一层在原答案旁边写批注：需要修改的地方补上增量，不需要改的地方直接走捷径。这个“捷径连接（Skip Connection）”让梯度能沿更短路径传播，因此网络深到几十层、上百层时仍可训练。ResNet 解决的核心不是表达能力不足，而是深层优化退化（Degradation）问题：层数增加后，训练误差反而上升。</p>
<p>训练上，ResNet 通常结合 BatchNorm、较深的 stage 结构和全局平均池化（Global Average Pooling）来替代庞大的全连接头。它在图像分类上大获成功后，很快成为检测、分割、关键点、视频理解等视觉任务的主流骨干网络。更深远的影响是：残差连接后来成为 Transformer、扩散模型和许多现代深网络的标准部件，因为它本质上是在为深层优化建立稳定的信息主通路。</p>
<div class="blog_h2"><span class="graybg">循环神经网络（RNN）</span></div>
<div class="blog_h3"><span class="graybg">RNN</span></div>
<p>循环神经网络（Recurrent Neural Network, RNN）按时间步（Time Step）处理序列。第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个时刻输入是 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span>，隐藏状态（Hidden State）递推为：</p>
<span displaypfx="" class="mathjax-container">\[h_t=\phi(W_{xh}x_t+W_{hh}h_{t-1}+b_h),\quad y_t=W_{hy}h_t+b_y\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(W_{xh}\)</span>（输入到隐层）与 <span displaypfx="inline-" class="mathjax-container">\(W_{hh}\)</span>（隐层到隐层）在所有时刻共享，这是 RNN 能在变长序列上泛化的关键。</p>
<p>把它展开（Unroll）到时间轴上会更直观：同一套参数在每个时间步重复使用，形成一个深度为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 的计算图。这也是 RNN 训练常说的“通过时间的反向传播（Backpropagation Through Time, BPTT）”。</p>
<p>RNN 的经典难点是梯度消失/爆炸（Vanishing/Exploding Gradients）：反向传播时会反复乘以 <span displaypfx="inline-" class="mathjax-container">\(W_{hh}\)</span> 的雅可比，从而导致梯度范数指数级衰减或增长。LSTM/GRU 通过门控（Gating）与更“线性”的记忆通道缓解这一问题。</p>
<p>Seq2Seq（Sequence-to-Sequence）是任务范式，不限定具体单元。早期 Seq2Seq 常由 RNN/LSTM/GRU 的编码器-解码器（Encoder-Decoder）实现；后续被 Transformer 大规模替代。</p>
<p>“乘法 + 加法 + 非线性”为何有效：线性变换负责特征重表达，非线性激活（Nonlinearity）提供函数逼近能力，时间递推提供记忆路径，三者叠加形成高表达力。</p>
<div class="blog_h3"><span class="graybg">LSTM</span></div>
<p>LSTM（Long Short-Term Memory）是为了解决普通 RNN 难以稳定保留长程信息的问题而设计的门控循环结构。它的核心思想不是简单把隐藏状态不断往前传，而是显式维护一个记忆单元（Cell State） <span displaypfx="inline-" class="mathjax-container">\(c_t\)</span>，并用门控决定“忘掉什么、写入什么、读出什么”。这使得序列中的关键信息可以沿着一条更接近线性的通道向后传播，而不必在每个时间步都被强非线性反复改写。</p>
<p>LSTM 的典型更新写成：</p>
<span displaypfx="" class="mathjax-container">\[f_t=\sigma(W_f x_t+U_f h_{t-1}+b_f),\quad i_t=\sigma(W_i x_t+U_i h_{t-1}+b_i)\]</span>
<span displaypfx="" class="mathjax-container">\[\tilde c_t=\tanh(W_c x_t+U_c h_{t-1}+b_c),\quad c_t=f_t\odot c_{t-1}+i_t\odot \tilde c_t\]</span>
<span displaypfx="" class="mathjax-container">\[o_t=\sigma(W_o x_t+U_o h_{t-1}+b_o),\quad h_t=o_t\odot \tanh(c_t)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 是当前输入， <span displaypfx="inline-" class="mathjax-container">\(h_{t-1}\)</span> 是前一时刻隐藏状态， <span displaypfx="inline-" class="mathjax-container">\(c_{t-1}\)</span> 是前一时刻记忆单元； <span displaypfx="inline-" class="mathjax-container">\(f_t\)</span> 是遗忘门（Forget Gate），控制旧记忆保留多少； <span displaypfx="inline-" class="mathjax-container">\(i_t\)</span> 是输入门（Input Gate），控制当前候选记忆 <span displaypfx="inline-" class="mathjax-container">\(\tilde c_t\)</span> 写入多少； <span displaypfx="inline-" class="mathjax-container">\(o_t\)</span> 是输出门（Output Gate），控制当前记忆暴露给隐藏状态多少； <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 是 sigmoid，把门值压到 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span> 区间； <span displaypfx="inline-" class="mathjax-container">\(\odot\)</span> 是逐元素乘法。</p>
<p>具象地看，LSTM 像一条带阀门的记忆水管。普通 RNN 每走一步都把旧信息和新输入一锅重拌，长距离信息容易被冲淡；LSTM 则允许旧记忆沿主管道直接往后流，同时用三个阀门分别控制“放掉旧水”“注入新水”“输出多少”。这正是它能比普通 RNN 更稳定地记住长距离依赖的原因。</p>
<p>训练上，LSTM 仍然通过时间反向传播（BPTT）学习参数，但门控结构显著改善了梯度流。它曾长期是机器翻译、语音识别、语言建模、时间序列预测和序列标注的主力模型。在 Transformer 出现之前，大量 Seq2Seq 系统都建立在双向 LSTM 或多层 LSTM 之上；在某些中小规模时序任务里，LSTM 今天仍然是强而稳的基线。</p>
<div class="blog_h3"><span class="graybg">GRU</span></div>
<p>GRU（Gated Recurrent Unit）可以看作 LSTM 的简化版本。它保留了“用门控控制信息保留与更新”的核心思想，但把记忆单元与隐藏状态合并，不再单独维护 <span displaypfx="inline-" class="mathjax-container">\(c_t\)</span>，从而用更少参数换取更紧凑的结构与更快的训练速度。</p>
<p>GRU 的一组典型公式是：</p>
<span displaypfx="" class="mathjax-container">\[z_t=\sigma(W_z x_t+U_z h_{t-1}+b_z),\quad r_t=\sigma(W_r x_t+U_r h_{t-1}+b_r)\]</span>
<span displaypfx="" class="mathjax-container">\[\tilde h_t=\tanh(W_h x_t+U_h(r_t\odot h_{t-1})+b_h)\]</span>
<span displaypfx="" class="mathjax-container">\[h_t=(1-z_t)\odot h_{t-1}+z_t\odot \tilde h_t\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(z_t\)</span> 是更新门（Update Gate），决定旧状态保留多少、新候选状态写入多少； <span displaypfx="inline-" class="mathjax-container">\(r_t\)</span> 是重置门（Reset Gate），决定在构造候选状态 <span displaypfx="inline-" class="mathjax-container">\(\tilde h_t\)</span> 时，历史信息应当被参考到什么程度。若 <span displaypfx="inline-" class="mathjax-container">\(z_t\)</span> 很小，模型更倾向保留旧记忆；若很大，模型更倾向用新信息刷新状态。</p>
<p>从直觉上看，GRU 把 LSTM 的三道阀门压缩成两道更紧凑的控制逻辑：一方面决定“要不要更新”，另一方面决定“生成候选更新时要不要忘掉旧状态的一部分”。这让它在很多任务上能以更少参数达到与 LSTM 相近的效果，尤其适合数据量不极大、模型容量受限或推理效率敏感的场景。</p>
<p>训练上，GRU 与 LSTM 一样使用 BPTT。应用上，它常见于语音、时序预测、较轻量的编码器-解码器模型，以及很多工业界的表格时间序列任务。若需要更强的显式记忆控制，LSTM 往往更稳；若更看重结构简洁和训练效率，GRU 常是更自然的选择。</p>
<div class="blog_h2"><span class="graybg">生成模型</span></div>
<p>生成模型（Generative Model）关注的核心问题不是“这个样本属于哪一类”，而是“数据本身是如何产生出来的”。更形式化地说，它试图学习数据分布 <span displaypfx="inline-" class="mathjax-container">\(p(x)\)</span>，或条件分布 <span displaypfx="inline-" class="mathjax-container">\(p(x|c)\)</span>，从而能够采样、重建、补全、去噪或按条件生成新样本。不同生成模型的主要差别在于它们选择了不同的概率建模路径：有的学隐变量，有的学博弈过程，有的学逐步去噪，有的更偏重表示压缩。</p>
<div class="blog_h3"><span class="graybg">自编码器（AE）</span></div>
<p>自编码器（Autoencoder, AE）的核心思想是“先压缩，再重建”。它把输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 通过编码器（Encoder）映射到低维或受约束的隐表示（Latent Representation） <span displaypfx="inline-" class="mathjax-container">\(z\)</span>，再通过解码器（Decoder）把 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 重建回 <span displaypfx="inline-" class="mathjax-container">\(\hat x\)</span>。如果模型在受限瓶颈下仍能较好重建输入，就说明 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 抓住了数据中的关键结构。</p>
<p>其基本形式可以写成：</p>
<span displaypfx="" class="mathjax-container">\[z=f_{\theta}(x),\qquad \hat x=g_{\phi}(z)\]</span>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{AE}}=\|x-\hat x\|_2^2\quad \text{或}\quad -\sum_i x_i\log \hat x_i\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(f_{\theta}\)</span> 是编码器， <span displaypfx="inline-" class="mathjax-container">\(g_{\phi}\)</span> 是解码器， <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 是瓶颈表示， <span displaypfx="inline-" class="mathjax-container">\(\hat x\)</span> 是重建结果。若输入是连续值，常用均方误差；若输入近似二值或归一化到概率意义，常用逐维交叉熵。这个目标迫使模型学习“怎样用更紧凑的表示保存足够重建原样本的信息”。</p>
<p>自编码器本身并不天然是强生成模型，因为它主要学会的是“给定输入怎么重建自己”，而不是如何从一个规则、可采样的潜空间中稳定地产生新样本。它更适合理解为表示学习或降维模型。通过在结构或训练目标上增加限制，例如稀疏自编码器（Sparse AE）、去噪自编码器（Denoising AE）或收缩自编码器（Contractive AE），它可以学到更稳健的隐表示。</p>
<p>应用上，AE 常用于降维、异常检测、去噪、预训练和表征学习。例如在工业异常检测里，模型只用正常样本训练，推理时若某个输入无法被良好重建，重建误差就可能提示该样本偏离了正常分布。</p>
<div class="blog_h3"><span class="graybg">变分自编码器（VAE）</span></div>
<p>变分自编码器（Variational Autoencoder, VAE）是在 AE 基础上把“隐空间可采样”这件事做成概率建模的方案。它不再把编码器输出一个确定的潜向量，而是输出一个潜变量分布 <span displaypfx="inline-" class="mathjax-container">\(q_{\phi}(z|x)\)</span>；同时假设存在生成分布 <span displaypfx="inline-" class="mathjax-container">\(p_{\theta}(x|z)\)</span>。这样，模型既能重建输入，又能从一个规则的潜空间里采样新样本。</p>
<p>VAE 的核心训练目标是证据下界（Evidence Lower Bound, ELBO）：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{VAE}}=\mathbb{E}_{q_{\phi}(z|x)}[\log p_{\theta}(x|z)]-D_{\mathrm{KL}}\big(q_{\phi}(z|x)\,\|\,p(z)\big)\]</span>
<p>其中第一项是重建项，鼓励给定 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 时能把 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 生成回来；第二项是 KL 散度（Kullback-Leibler Divergence），把编码器输出的后验近似分布 <span displaypfx="inline-" class="mathjax-container">\(q_{\phi}(z|x)\)</span> 拉向先验分布 <span displaypfx="inline-" class="mathjax-container">\(p(z)\)</span>，通常取标准高斯 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{N}(0,I)\)</span>。这一步的作用是把隐空间整理成“规则、连续、可插值、可采样”的形状。</p>
<p>训练上的关键技巧是重参数化（Reparameterization Trick）：若直接从 <span displaypfx="inline-" class="mathjax-container">\(q_{\phi}(z|x)\)</span> 采样，梯度难以回传；VAE 通常把采样写成 <span displaypfx="inline-" class="mathjax-container">\(z=\mu+\sigma\odot \epsilon\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\sim\mathcal{N}(0,I)\)</span>，而 <span displaypfx="inline-" class="mathjax-container">\(\mu,\sigma\)</span> 由编码器输出。这样随机性被转移到与参数无关的 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 上，梯度就能顺利穿过 <span displaypfx="inline-" class="mathjax-container">\(\mu,\sigma\)</span> 回传。</p>
<p>应用上，VAE 特别适合做潜空间操作，例如插值生成、属性控制、缺失补全和概率建模。与 GAN 相比，VAE 生成结果往往更平滑、更稳定，但图像清晰度常较弱；与扩散模型相比，VAE 在采样效率上更高，但生成质量上通常不是最强路线。</p>
<div class="blog_h3"><span class="graybg">生成对抗网络（GAN）</span></div>
<p>生成对抗网络（Generative Adversarial Network, GAN）的核心思想是对抗式学习：让生成器（Generator）负责“伪造样本”，让判别器（Discriminator）负责“分辨真假”，两者在博弈中共同提高。生成器学会把随机噪声变成越来越像真实数据的样本，判别器则学会识别这些样本是否来自真实分布。</p>
<p>经典 GAN 的目标写成：</p>
<span displaypfx="" class="mathjax-container">\[\min_G\max_D\ V(D,G)=\mathbb{E}_{x\sim p_{\mathrm{data}}}[\log D(x)]+\mathbb{E}_{z\sim p(z)}[\log(1-D(G(z)))] \]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 是从先验分布中采样的随机噪声， <span displaypfx="inline-" class="mathjax-container">\(G(z)\)</span> 是生成器产生的假样本， <span displaypfx="inline-" class="mathjax-container">\(D(x)\)</span> 输出输入样本为真样本的概率。判别器希望让真实样本分数高、伪样本分数低；生成器则希望骗过判别器，让 <span displaypfx="inline-" class="mathjax-container">\(G(z)\)</span> 看起来足够真实。</p>
<p>具象地看，GAN 像“伪造者”和“鉴定师”的对抗升级。鉴定师越强，伪造者就被迫学会更精细的伪造技巧；伪造者越强，鉴定师也必须学会更细致的辨别规则。理论上，这种动态会把生成分布推向真实数据分布；工程上，它带来的最大问题是训练不稳定，常见现象包括模式崩塌（Mode Collapse）、震荡和判别器过强导致生成器梯度过弱。</p>
<p>GAN 在图像生成、图像翻译、超分辨率和风格迁移里曾经极其成功，因为它特别善于生成锐利、感知上真实的图像纹理。但它对训练技巧依赖很强，后来在大规模高保真图像生成上逐渐被扩散模型压过；即便如此，GAN 在需要低步数快速生成或特定视觉变换任务中仍然很有生命力。</p>
<div class="blog_h3"><span class="graybg">扩散模型（Diffusion）</span></div>
<p>扩散模型（Diffusion Model）的核心思想是：把“直接学会生成复杂数据”拆成“先逐步加噪，再逐步去噪”这条更稳定的路径。前向过程把真实样本一步步污染成近似高斯噪声，反向过程则训练一个神经网络学会在每一步去掉一小部分噪声。最终从纯噪声出发，经过多步反向去噪，就能逐步生成结构清晰的样本。</p>
<p>记号上，前向扩散链从真实样本 <span displaypfx="inline-" class="mathjax-container">\(x_0\)</span> 出发，依次得到 <span displaypfx="inline-" class="mathjax-container">\(x_1,x_2,\dots,x_T\)</span>。因此 <span displaypfx="inline-" class="mathjax-container">\(x_0\)</span> 表示原始数据样本， <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步加噪后的样本。</p>
<p>一个常见的前向加噪过程写成：</p>
<span displaypfx="" class="mathjax-container">\[q(x_t|x_{t-1})=\mathcal{N}\!\left(x_t;\sqrt{1-\beta_t}\,x_{t-1},\beta_t I\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\beta_t\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步的噪声强度：它控制从 <span displaypfx="inline-" class="mathjax-container">\(x_{t-1}\)</span> 走到 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 时，原信号衰减多少、随机噪声注入多少。训练时常把从 <span displaypfx="inline-" class="mathjax-container">\(x_0\)</span> 到 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 的若干步合并写成</p>
<span displaypfx="" class="mathjax-container">\[x_t=\sqrt{\bar\alpha_t}\,x_0+\sqrt{1-\bar\alpha_t}\,\epsilon,\qquad \epsilon\sim\mathcal{N}(0,I)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\bar\alpha_t\)</span> 是由噪声日程（Noise Schedule）累乘得到的系数，控制到第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步时，原始信号保留了多少、噪声混入了多少。实际训练中，网络通常不直接预测 <span displaypfx="inline-" class="mathjax-container">\(x_0\)</span>，而是预测噪声 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span>，典型损失是：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{diff}}=\mathbb{E}\big[\|\epsilon-\epsilon_{\theta}(x_t,t)\|_2^2\big]\]</span>
<p>这条路径之所以有效，在于“预测一步噪声”比“直接一次生成整张复杂图片”更容易优化。训练上，扩散模型相对稳定，较少出现 GAN 那种对抗不平衡；代价是采样通常需要多步迭代，推理速度较慢。应用上，扩散模型已经成为图像生成、文生图、图像编辑、超分辨率、视频生成和分子设计的重要主线。Stable Diffusion 一类系统，本质上就是把扩散过程放到了潜空间（Latent Space）里执行，以降低像素空间扩散的计算成本。</p>
<div class="blog_h2"><span class="graybg">图神经网络（GNN）</span></div>
<p>图神经网络（Graph Neural Network, GNN）处理的对象不是规则网格上的序列或图像，而是由节点（Node）和边（Edge）组成的图（Graph）。它的核心问题是：当样本之间存在不规则连接关系时，如何让一个节点的表示同时反映“自己的特征”和“邻居结构中的上下文”。因此，GNN 的基本直觉不是滑动卷积核，也不是按时间递推，而是让节点在图上传递消息、聚合邻居信息，再更新自身表示。</p>
<div class="blog_h3"><span class="graybg">图卷积网络（GCN）</span></div>
<p>图卷积网络（Graph Convolutional Network, GCN）把卷积思想推广到图结构上。它的核心思想是：一个节点的新表示，不应只由自己决定，还应由其邻居节点的表示共同决定；但这种聚合不能简单相加，而需要根据图结构做适当归一化，否则高度节点会在信息聚合中占据过大权重。</p>
<p>GCN 的经典一层更新公式写成：</p>
<span displaypfx="" class="mathjax-container">\[H^{(l+1)}=\sigma\!\left(\tilde D^{-1/2}\tilde A\tilde D^{-1/2}H^{(l)}W^{(l)}\right)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(H^{(l)}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层所有节点的表示矩阵； <span displaypfx="inline-" class="mathjax-container">\(W^{(l)}\)</span> 是该层可学习权重； <span displaypfx="inline-" class="mathjax-container">\(\tilde A=A+I\)</span> 表示在原邻接矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 上加自环（Self-loop），让节点保留自己的信息； <span displaypfx="inline-" class="mathjax-container">\(\tilde D\)</span> 是 <span displaypfx="inline-" class="mathjax-container">\(\tilde A\)</span> 的度矩阵（Degree Matrix）； <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 是非线性激活。中间那项对邻居求和并按度做归一化，本质上是在做“平滑的邻居平均”。</p>
<p>具象地看，GCN 像在社交网络里更新一个人的画像：不仅看这个人自己填写的特征，还参考他一跳邻居的大致特征，再做归一化，避免“朋友特别多的人”把自己的表示稀释得过于严重。多层堆叠后，一个节点就能间接接触到两跳、三跳甚至更远范围的信息。</p>
<p>训练上，GCN 常用于节点分类、图分类和链路预测。它的经典应用包括引文网络分类、分子图预测和推荐系统图表示学习。局限也很典型：层数太深时，节点表示会越来越相似，出现过平滑（Oversmoothing）；大图上直接用全图邻接矩阵训练也会带来显存与计算压力。</p>
<div class="blog_h3"><span class="graybg">图注意力网络（GAT）</span></div>
<p>图注意力网络（Graph Attention Network, GAT）在 GCN 的“统一归一化邻居平均”之上进一步提出：不同邻居的重要性不应被预先固定，而应由模型动态学习。它的核心思想是把注意力机制引入图结构，使每个节点在聚合邻居时，能够自适应决定“更该听谁的话”。</p>
<p>一层 GAT 的典型计算是：</p>
<span displaypfx="" class="mathjax-container">\[e_{ij}=a(Wh_i,Wh_j),\qquad \alpha_{ij}=\frac{\exp(e_{ij})}{\sum_{k\in \mathcal{N}(i)}\exp(e_{ik})}\]</span>
<span displaypfx="" class="mathjax-container">\[h_i'=\sigma\!\left(\sum_{j\in \mathcal{N}(i)} \alpha_{ij}\,Wh_j\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(h_i\)</span> 是节点 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 的输入表示， <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 是线性变换矩阵， <span displaypfx="inline-" class="mathjax-container">\(a(\cdot,\cdot)\)</span> 是注意力打分函数， <span displaypfx="inline-" class="mathjax-container">\(e_{ij}\)</span> 是节点 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 对节点 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 的未归一化重要性分数， <span displaypfx="inline-" class="mathjax-container">\(\alpha_{ij}\)</span> 是在邻居集合 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{N}(i)\)</span> 内 softmax 归一化后的注意力权重。</p>
<p>与 GCN 相比，GAT 的关键收益是更灵活：若某个邻居特别重要，模型可以给它更高权重；若某个邻居噪声较大，则可自动抑制。训练上，GAT 仍通过监督或自监督目标学习节点表示，常用多头注意力（Multi-head Attention）稳定训练。应用上，它常见于异质关系更复杂、邻居重要性差异显著的图任务，例如社交网络分析、知识图谱局部推断和分子性质预测。</p>
<div class="blog_h3"><span class="graybg">GraphSAGE</span></div>
<p>GraphSAGE（Graph Sample and Aggregate）的核心思想是把 GNN 从“转导式（Transductive）图编码”推进到“归纳式（Inductive）图表示学习”。传统 GCN 常依赖整张训练图；GraphSAGE 则强调：即使测试时出现训练中未见过的新节点，只要能拿到它的邻居特征，也应能在线生成它的表示。</p>
<p>其典型更新形式是先采样邻居，再做聚合：</p>
<span displaypfx="" class="mathjax-container">\[h_{\mathcal{N}(v)}^{(k)}=\mathrm{AGG}^{(k)}\big(\{h_u^{(k-1)}:u\in \mathcal{N}(v)\}\big)\]</span>
<span displaypfx="" class="mathjax-container">\[h_v^{(k)}=\sigma\!\left(W^{(k)}[h_v^{(k-1)}\|h_{\mathcal{N}(v)}^{(k)}]\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(h_v^{(k)}\)</span> 是节点 <span displaypfx="inline-" class="mathjax-container">\(v\)</span> 在第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 层的表示， <span displaypfx="inline-" class="mathjax-container">\(\mathcal{N}(v)\)</span> 是其邻居集合， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{AGG}\)</span> 可以是均值、池化或 LSTM 聚合器， <span displaypfx="inline-" class="mathjax-container">\([\cdot\|\cdot]\)</span> 表示向量拼接。GraphSAGE 的关键工程点是“采样”，因为超大图中不可能每次把全部邻居完整展开。</p>
<p>具象地看，GraphSAGE 像为每个节点建立一套“从邻居摘要中构造自我画像”的规则。它不要求记住整张训练图中每个节点的专属嵌入，而是学会一套可迁移的邻域聚合函数。这正是它能处理新节点、动态图和大规模图数据的原因。</p>
<p>应用上，GraphSAGE 在推荐系统、社交网络、风控图谱和工业知识图谱中非常常见，因为这些场景经常不断出现新节点、新边，归纳式能力比单纯在固定图上做转导预测更重要。</p>
<div class="blog_h3"><span class="graybg">消息传递机制（Message Passing）</span></div>
<p>消息传递（Message Passing）不是某一个具体模型，而是理解 GNN 的统一抽象框架。无论是 GCN、GAT 还是 GraphSAGE，本质上都可以拆成两步：第一步，节点从邻居那里接收消息；第二步，把这些消息与自己的旧表示结合，更新成新的节点表示。</p>
<p>这一抽象常写成：</p>
<span displaypfx="" class="mathjax-container">\[m_v^{(l+1)}=\mathrm{AGG}\Big(\{M^{(l)}(h_v^{(l)},h_u^{(l)},e_{uv})\,:\,u\in\mathcal{N}(v)\}\Big)\]</span>
<span displaypfx="" class="mathjax-container">\[h_v^{(l+1)}=U^{(l)}\big(h_v^{(l)},m_v^{(l+1)}\big)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(h_v^{(l)}\)</span> 是节点 <span displaypfx="inline-" class="mathjax-container">\(v\)</span> 在第 <span displaypfx="inline-" class="mathjax-container">\(l\)</span> 层的表示， <span displaypfx="inline-" class="mathjax-container">\(e_{uv}\)</span> 是边特征， <span displaypfx="inline-" class="mathjax-container">\(M^{(l)}\)</span> 是消息函数，决定一条边上传递什么信息； <span displaypfx="inline-" class="mathjax-container">\(\mathrm{AGG}\)</span> 是聚合函数，如求和、均值、最大值或注意力加权和； <span displaypfx="inline-" class="mathjax-container">\(U^{(l)}\)</span> 是更新函数，把旧表示与聚合后的消息合成为新表示。</p>
<p>这个框架的重要性在于，它把看似不同的图模型放进了一套统一语言里：GCN 相当于使用归一化线性消息与均值式聚合，GAT 相当于把注意力权重写进聚合，GraphSAGE 相当于强调采样与归纳式聚合。理解了消息传递，就能把很多图模型看成“消息函数、聚合函数、更新函数”三处设计选择的不同组合。</p>
<p>训练上，消息传递式 GNN 常用于三类任务：节点级任务（例如节点分类）、边级任务（例如链路预测）、图级任务（例如分子性质预测）。它们的共同难点包括：过平滑、邻居爆炸（Neighborhood Explosion）、异质图关系复杂，以及深层堆叠后长程依赖难以稳定传播。很多现代 GNN 改进，本质上都在围绕这几个瓶颈重新设计消息传递规则。</p>
<div class="blog_h1"><span class="graybg">Transformers</span></div>
<div class="blog_h2"><span class="graybg">概述</span></div>
<p>Transformer 是现代大模型最核心的统一架构。它最初被提出用于序列到序列（Sequence-to-Sequence）任务，但很快演化成大语言模型（Large Language Model, LLM）、视觉 Transformer、多模态模型以及各类基础模型（Foundation Model）的共同骨架。它之所以重要，不只是因为“效果好”，更因为它提供了一种高度模块化、可并行扩展、易于堆叠放大的建模方式：输入被表示成一串 token，对这些 token 的关系建模主要依赖注意力（Attention），而每一层又通过前馈网络（Feed-Forward Network, FFN / MLP）继续做非线性变换与特征重组。</p>
<p>从工程角度看，Transformer 的成功来自三件事的结合：第一，注意力机制让模型能直接建模长距离依赖，而不必像循环网络那样逐步传递状态；第二，层与层之间结构统一，非常适合在 GPU / TPU 上做大规模并行训练；第三，模型规模可以沿着层数、隐藏维度、注意力头数、词表大小与训练数据量持续扩展，于是它天然适合作为“可放大”的通用架构。</p>
<p>因此，理解 Transformer 不应只停留在“注意力公式怎么写”，还要把它看成一条完整的信息处理流水线：token 如何变成向量，向量如何在注意力里彼此通信，MLP 如何重组和放大模式，残差流（Residual Stream）如何把各层计算串接起来，最后这些中间表示又如何被任务头（Task Head）读出，变成分类结果、生成 token 或其他下游输出。<a href="https://blog.gmem.cc/wp-content/uploads/2026/03/transformers.webp"><img class="alignnone size-large wp-image-41705" src="https://blog.gmem.cc/wp-content/uploads/2026/03/transformers.webp" alt="transformers" width="1" height="1" /></a></p>
<p>&nbsp;</p>
<div class="blog_h3"><span class="graybg">整体架构</span></div>
<p>Transformer 的“基本计算单元”是一个 Transformer block：把注意力子层（Attention Sublayer）与前馈子层（FFN Sublayer）串联起来，并在每个子层外包一层残差连接（Residual Connection）与归一化（Normalization）。注意力子层的输出不是最终预测，而是作为中间表示继续送入 FFN 与下一层 Transformer block，逐层构建更抽象的特征。</p>
<p>典型层结构（概念上）可以写成：</p>
<span displaypfx="" class="mathjax-container">\[H'=\mathrm{Add\&amp;Norm}(H,\ \mathrm{Attention}(H)),\quad H^{\text{next}}=\mathrm{Add\&amp;Norm}(H',\ \mathrm{FFN}(H'))\]</span>
<p>这条式子描述的是一个 Transformer block 内部最核心的两步。这里 <span displaypfx="inline-" class="mathjax-container">\(H\)</span> 表示进入当前层的隐藏状态矩阵（Hidden States），形状通常是 <span displaypfx="inline-" class="mathjax-container">\(L\;\times d_{\text{model}}\)</span>： <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 是序列长度， <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 是每个 token 的隐藏维度。 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Attention}(H)\)</span> 表示注意力子层对整段序列做一次“彼此通信”后的结果：每个 token 会结合其他位置的信息，得到新的上下文化表示。</p>
<p>第一步 <span displaypfx="inline-" class="mathjax-container">\(H'=\mathrm{Add\&amp;Norm}(H,\ \mathrm{Attention}(H))\)</span> 中，<span style="background-color: #c0c0c0;">Add</span> 表示残差相加：把原输入 <span displaypfx="inline-" class="mathjax-container">\(H\)</span> 与注意力输出相加；<span style="background-color: #c0c0c0;">Norm</span> 表示再做归一化（通常是 LayerNorm）。残差的作用是保留原始信息并让梯度更容易穿过深层网络，归一化的作用是让数值尺度更稳定。经过这一步后，得到的 <span displaypfx="inline-" class="mathjax-container">\(H'\)</span> 可以理解为“已经完成一次上下文交互”的中间表示。</p>
<p>第二步 <span displaypfx="inline-" class="mathjax-container">\(H^{\text{next}}=\mathrm{Add\&amp;Norm}(H',\ \mathrm{FFN}(H'))\)</span> 则把 <span displaypfx="inline-" class="mathjax-container">\(H'\)</span> 送入前馈网络（Feed-Forward Network, FFN）。FFN 不负责 token 之间的信息交换，而是对每个位置的向量分别做非线性变换与特征重组。它更像是在每个 token 内部重新编码：放大有用模式、抑制无关模式，并把低层线索组合成更抽象的表示。再经过一次“残差相加 + 归一化”后，输出 <span displaypfx="inline-" class="mathjax-container">\(H^{\text{next}}\)</span>，作为下一层 Transformer block 的输入。</p>
<p>因此，这个公式的阅读顺序可以概括为：先让 token 之间通过注意力交换信息，再让每个 token 自己通过 FFN 重组特征。多层堆叠之后，模型就会沿着这条路径逐层把原始输入变成越来越适合任务头读取的高层表示。</p>
<p>这里还需要区分 Pre-LN（Pre-LayerNorm）与 Post-LN（Post-LayerNorm）。它们的区别在于 <span style="background-color: #c0c0c0;">LayerNorm 放在子层计算之前，还是放在残差相加之后</span>。</p>
<p>若是 Post-LN，概念上更接近前面那条写法：先做子层计算，再与输入做残差相加，最后归一化。例如注意力子层可写成 <span displaypfx="inline-" class="mathjax-container">\(H'=\mathrm{LN}(H+\mathrm{Attention}(H))\)</span>。若是 Pre-LN，则顺序改成“先归一化，再做子层计算，再走残差”：注意力子层更接近 <span displaypfx="inline-" class="mathjax-container">\(H'=H+\mathrm{Attention}(\mathrm{LN}(H))\)</span>，FFN 子层同理。</p>
<p>两者表达的功能主线相同：信息都要经过注意力与 FFN，再靠残差流向后传递。差异主要体现在训练动力学（Training Dynamics）上。Post-LN 更贴近原始 Transformer 论文的写法，直观上像“每次子层更新完，再把结果规范一下”；Pre-LN 则让梯度更容易沿残差路径稳定传播，因此在很深的大模型里更常见。工程实现会在 Pre-LN / Post-LN 之间选择，这会影响训练稳定性、学习率可用范围以及深层可训练性，但不会改变我们对 block 主流程的理解：<span style="background-color: #c0c0c0;">注意力负责跨 token 交互，FFN 负责单 token 特征重组，残差负责让信息与梯度顺畅穿层流动</span>。</p>
<p>Transformer 这个名字源自 “Attention Is All You Need” 论文：模型不再依赖循环结构来处理序列，而是通过注意力把序列表示不断变换（Transform）为更适合预测的表征。</p>
<p>Transformer 的参数（Parameters）不是单一矩阵，而是一组可学习张量的集合，主要包括嵌入（Embedding）、注意力投影（Attention Projections）、前馈网络（FFN）以及归一化的缩放/平移参数等。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">参数组</td>
<td style="text-align: center;">符号</td>
<td style="width: 35%; text-align: center;">典型形状（Typical Shape）</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>Token Embedding</td>
<td><span displaypfx="inline-" class="mathjax-container">\(E\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{V\;\times d_{\text{model}}}\)</span></td>
<td>词表大小 <span displaypfx="inline-" class="mathjax-container">\(V\)</span>；常与输出头权重共享（Weight Tying）。</td>
</tr>
<tr>
<td>位置嵌入（Learned）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(P\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{L_{\max}\;\times d_{\text{model}}}\)</span></td>
<td>仅当使用可学习绝对位置嵌入时存在；正弦位置编码无此参数。</td>
</tr>
<tr>
<td>注意力投影</td>
<td><span displaypfx="inline-" class="mathjax-container">\(W_Q,W_K,W_V\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{d_{\text{model}}\;\times d_{\text{model}}}\)</span></td>
<td>实现上常把多头合并成一次线性投影，等价于 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{d_{\text{model}}\;\times (H d_k)}\)</span>。</td>
</tr>
<tr>
<td>注意力输出投影</td>
<td><span displaypfx="inline-" class="mathjax-container">\(W_O\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{d_{\text{model}}\;\times d_{\text{model}}}\)</span></td>
<td>对拼接后的多头输出做线性混合；并非 <span displaypfx="inline-" class="mathjax-container">\(H\;\times d_v\;\times d_{\text{model}}\)</span> 的三维张量。</td>
</tr>
<tr>
<td>FFN</td>
<td><span displaypfx="inline-" class="mathjax-container">\(W_1,W_2\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(W_1\in\mathbb{R}^{d_{\text{model}}\;\times d_{\text{ff}}},\ W_2\in\mathbb{R}^{d_{\text{ff}}\;\times d_{\text{model}}}\)</span></td>
<td>通常 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{ff}}\gg d_{\text{model}}\)</span>。</td>
</tr>
<tr>
<td>LayerNorm</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\gamma,\beta\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{d_{\text{model}}}\)</span></td>
<td>每个 LayerNorm 有一组缩放与平移参数。</td>
</tr>
<tr>
<td>输出头（LM Head）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(W_{\text{vocab}},b\)</span></td>
<td><span displaypfx="inline-" class="mathjax-container">\(W_{\text{vocab}}\in\mathbb{R}^{d_{\text{model}}\;\times V}\)</span></td>
<td>把隐藏状态映射为词表 logits；常与 <span displaypfx="inline-" class="mathjax-container">\(E\)</span> 共享权重。</td>
</tr>
</tbody>
</table>
<p>不同 Transformer 变体在维度设置上差异很大。下面列的是几类典型公开模型的常见配置，既包括中等尺寸的主流模型，也包括 2025 到 2026 年仍处前沿位置的开源大模型。它们的共同点在于：即使是“中等尺寸”的主流模型，隐藏维度、层数、头数和 FFN 宽度也已经足够大；而到了开源前沿模型阶段，参数扩展往往不再只靠加深层数，而是同时叠加更宽的隐藏维度、更大的 FFN、MoE（Mixture of Experts）和更激进的注意力/KV 设计，因此模型内部表示天然是高维、分布式且跨层叠加的。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">架构类型</td>
<td style="text-align: center;">参数规模</td>
<td style="text-align: center;">层数</td>
<td style="text-align: center;">隐藏维度 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span></td>
<td style="text-align: center;">注意力头数</td>
<td style="text-align: center;">KV 头数</td>
<td style="text-align: center;">FFN / Intermediate 维度</td>
</tr>
</thead>
<tbody>
<tr>
<td>BERT-base</td>
<td>Encoder-only</td>
<td>110M 级（dense）</td>
<td>12</td>
<td>768</td>
<td>12</td>
<td>12</td>
<td>3072</td>
</tr>
<tr>
<td>GPT-2 Small</td>
<td>Decoder-only</td>
<td>124M 级（dense）</td>
<td>12</td>
<td>768</td>
<td>12</td>
<td>12</td>
<td>3072</td>
</tr>
<tr>
<td>Mistral 7B</td>
<td>Decoder-only</td>
<td>7B 级（dense）</td>
<td>32</td>
<td>4096</td>
<td>32</td>
<td>8</td>
<td>14336</td>
</tr>
<tr>
<td>Llama 3.1 8B</td>
<td>Decoder-only</td>
<td>8B 级（dense）</td>
<td>32</td>
<td>4096</td>
<td>32</td>
<td>8</td>
<td>14336</td>
</tr>
<tr>
<td>Qwen2.5 7B</td>
<td>Decoder-only</td>
<td>7B 级（dense）</td>
<td>28</td>
<td>3584</td>
<td>28</td>
<td>4</td>
<td>18944</td>
</tr>
<tr>
<td>Qwen3-235B-A22B</td>
<td>Decoder-only + MoE</td>
<td>235B 总参 / 22B 激活</td>
<td>94</td>
<td>4096</td>
<td>64</td>
<td>4</td>
<td>12288（dense）/ 1536（per-expert）</td>
</tr>
<tr>
<td>DeepSeek-V3 系列</td>
<td>Decoder-only + MoE + MLA</td>
<td>671B 总参 / 37B 激活</td>
<td>61</td>
<td>7168</td>
<td>128</td>
<td>128</td>
<td>18432（shared）/ 2048（per-expert）</td>
</tr>
</tbody>
</table>
<p>这张表也说明了一个很重要的趋势。到 2026 年，开源前沿模型已经不再沿着“单纯加深层数”这一条路线演化，而是出现了明显分化：Qwen3-235B-A22B 把层数推到 94 层，同时保持相对克制的隐藏维度，并通过 128 个专家、每 token 激活 8 个专家来放大总容量；DeepSeek-V3 系列则维持 61 层，但把隐藏维度提升到 7168，并叠加 DeepSeekMoE 与 MLA（Multi-head Latent Attention）来同时优化容量与推理成本。也就是说，前沿模型的“强”并不只表现为更深，而更多表现为<span style="background-color: #c0c0c0;">深度、宽度、专家稀疏性与注意力工程的联合扩展</span>。</p>
<p>对闭源顶级模型的层数，外界通常拿不到可靠公开配置，因此只能做工程上的区间推断。若它们仍以 Transformer block 为主体，那么从公开开源前沿模型的尺度看，显式层数大概率仍落在<span style="background-color: #c0c0c0;">数十层到一百多层</span>这一带，而不是简单增长到几百层甚至上千层；更常见的扩展手段，是增大隐藏维度、放大 FFN、引入 MoE、延长上下文、增加训练 token，或在同等层数下叠加稀疏注意力、递归计算与工具链调用。因此，对 GPT、Claude、Gemini 这类闭源顶级模型，更稳妥的判断不是“它们一定有多少层”，而是“它们很可能已经处在百层级上下、并辅以更复杂的宽度与稀疏化设计”。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/transformers-dims.png"><img class="alignnone size-large wp-image-41435" src="https://blog.gmem.cc/wp-content/uploads/2026/03/transformers-dims.png" alt="transformers-dims" width="1" height="1" /></a>Transformer 的不可解释性（Lack of Interpretability）不是由某一个参数“导致”，而是由整体机制共同产生：表示是分布式（Distributed）且高维的，多层叠加的非线性变换把因果链条变长；注意力权重可视化能提供线索，但它不是完整解释。</p>
<div class="blog_h3"><span class="graybg">知识如何存储</span></div>
<p>大模型中的知识通常不是“某一个感知机单独存储一条事实”，而是以分布式表示（Distributed Representation）的形式分散在大量参数里。单个单元更像一个局部特征探测器（Feature Detector）：它只对某种模式敏感；许多单元级联后，网络才能把低层简单模式组合成高层抽象概念。模型规模越大、层数越深、参数越多，可被编码的模式组合也越丰富，这正是大模型具备强表达能力与“知识容量”的原因之一。</p>
<p>对于 Transformer 这样的模型，知识并不是以“一层一个概念、一神经元一事实”的方式整齐排布，而更像是沿着残差流（Residual Stream）在多层之间不断被提取、重组、放大和读出。注意力层更擅长在上下文中定位相关信息、建立 token 之间的依赖；MLP 层则更像参数化的模式变换器或记忆单元，会把某些已经被触发的模式映射成更强的语义方向，再写回主表示中。</p>
<p>从经验上看，这种知识分布有一些常见规律。较低层往往更接近词形、局部模式与浅层统计相关性；中间层更容易出现实体属性、关系模式和事实联想的组合；较高层则更接近任务相关读出，也就是更接近“最后怎样把内部表示变成具体输出”的阶段，例如下一 token 预测、答案选择或标签判别。但这更像统计趋势，而不是严格分工：同一类知识往往会跨多个层段冗余存在，并通过许多参数共同表达。</p>
<p>因此，更准确的理解不是“第几层存了什么知识”，而是“不同层在知识处理流水线里承担了什么功能”。有的层更偏检索线索，有的层更偏关系组合，有的层更偏把结果变成可供输出头使用的表示。单个 MLP 模块有时可以表现出类似键值记忆（Key-Value Memory）的行为，但真正稳定的知识通常仍然是跨层、跨参数、跨方向分布的。也正因为这种分布式编码，大模型既能表现出较强的知识容量，也会显得难以直接解释和精确定位。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/transformers-knowledge.png"><img class="alignnone size-full wp-image-41449" src="https://blog.gmem.cc/wp-content/uploads/2026/03/transformers-knowledge.png" alt="transformers-knowledge" width="848" height="1264" /></a></p>
<div class="blog_h3"><span class="graybg">编码器-解码器</span></div>
<p>编码器-解码器（Encoder–Decoder）结构对应经典 Seq2Seq：编码器先对输入序列做双向自注意力（Bidirectional Self-Attention）编码，即编码器里的每个 token 都可以直接看到源序列中的其他 token，不使用因果掩码（Causal Mask），因此更擅长形成充分的上下文化输入表示；随后解码器在自回归生成（Autoregressive Generation）时，一边做因果自注意力（Causal Self-Attention），只看已经生成的前缀，一边通过交叉注意力（Cross-Attention）读取编码器输出。于是，编码器负责把“输入内容本身”编码清楚，解码器负责在“已生成前缀 + 编码器语义表示”条件下逐步生成输出。典型用于机器翻译、摘要、问答等“输入到输出”的条件生成任务（Conditional Generation），代表模型如 T5、BART。</p>
<div class="blog_h3"><span class="graybg">仅编码器</span></div>
<p>仅编码器（Encoder-only）结构使用双向自注意力（Bidirectional Self-Attention）：位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 可以看见所有位置（不做因果屏蔽）。它更擅长做“理解与表示”（Representation Learning），常见预训练目标是掩码语言建模（Masked Language Modeling, MLM）：把输入里部分 token 替换为 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{MASK}]\)</span>，训练模型根据上下文预测被遮住的 token。代表模型如 BERT、RoBERTa；ELECTRA 则用“替换检测（Replaced Token Detection）”作为预训练任务，但架构仍是 Encoder-only。</p>
<p>注意“掩码（Mask）”在这里指的是 <span style="background-color: #c0c0c0;">MLM 的 token masking</span>，不是自回归解码里的因果 attention mask。</p>
<div class="blog_h3"><span class="graybg">仅解码器</span></div>
<p>仅解码器（Decoder-only）结构使用因果自注意力（Causal Self-Attention）：位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 只能看见 <span displaypfx="inline-" class="mathjax-container">\(j\le i\)</span> 的历史 token，通过三角形掩码避免“偷看未来”。它天然对应自回归语言建模（Causal Language Modeling, CLM）：最大化 <span displaypfx="inline-" class="mathjax-container">\(\prod_t p(x_t|x_{&lt;t})\)</span>。代表模型如 GPT 系列、LLaMA、Qwen。</p>
<p>这里的“掩码（Mask）”指的是 <span style="background-color: #c0c0c0;">attention 里的因果屏蔽</span>，与 MLM 的 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{MASK}]\)</span> token 概念不同。</p>
<div class="blog_h3"><span class="graybg">预填充</span></div>
<p>对生成式 Transformer，尤其是 Decoder-only 模型以及 Encoder–Decoder 中的解码器侧，推理过程通常可分成两个阶段：预填充（Prefill）与解码（Decode）。Prefill 先把整段已知提示词（Prompt）一次性送入模型，计算每一层的隐藏状态，并把各层的 Key / Value 写入 KV Cache；Decode 则在此基础上逐步生成新 token，每一步只新增一个位置，再与历史缓存做注意力计算。</p>
<p>Prefill 阶段虽然仍然使用因果掩码（Causal Mask），但因为整段 prompt 在进入模型时已经全部已知，所以输入处理过程中所有 token 仍可并行处理：同一层里的 Query / Key / Value 投影、矩阵乘法以及 masked attention 都可以一次性并行完成。因果掩码只负责限制“当前位置不能看未来位置”，并不会把已知 prompt 重新变回按时间步串行处理。</p>
<p>生成阶段则通常是串行的。因为每一个新输出 token 都要作为前缀的一部分，参与下一个 token 的预测，所以 token 与 token 之间存在真实的自回归依赖，不能像 Prefill 那样沿序列长度整段并行展开。此时系统仍然可以利用 batch 并行、head 并行、张量并行、专家并行和内核并行，但在“生成顺序”这一维上通常必须逐步推进。这也是为什么长 prompt 场景下常说系统先经历一次计算密集（Compute-bound）的 Prefill，而进入连续生成后，瓶颈又经常转向 KV Cache 读取、显存带宽与调度开销主导的 Decode。</p>
<p>这个两阶段视角非常重要，因为后续很多工程优化都直接对应其中一个阶段：FlashAttention 对长序列 Prefill 的收益通常最显著；KV Cache、GQA / MQA、Paged Attention、Prompt Caching 与 Speculative Decoding 等，则更多是在优化 Decode 或同时兼顾两者。理解了 Prefill 与 Decode 的分工，再看 Transformer 推理优化时，许多“为什么这里快、那里慢”的现象就会变得自然。</p>
<div class="blog_h4"><span class="graybg">主流 Decoder-only 细节差异（选型表）</span></div>
<p>Decoder-only 成为主流之后，架构创新集中在“稳定性、KV Cache 成本与训练效率”三个轴：归一化/激活影响深层训练稳定性；注意力侧的 KV 结构决定长上下文推理成本；FFN 稠密/稀疏（MoE）与训练目标改造影响单位算力的有效学习信号。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">技术点</td>
<td style="text-align: center;">常见选项</td>
<td style="text-align: center;">动机</td>
<td style="text-align: center;">影响</td>
</tr>
</thead>
<tbody>
<tr>
<td>归一化（Normalization）</td>
<td>LayerNorm / RMSNorm</td>
<td>提升深层训练稳定性</td>
<td>RMSNorm 省掉去均值，算子更简单；实际表现依赖整体配方</td>
</tr>
<tr>
<td>激活/FFN（Activation/FFN）</td>
<td>GELU / SwiGLU / GLU 变体</td>
<td>门控提升表达力与稳定性</td>
<td>通常带来更好效果，但实现与吞吐会受内核支持影响</td>
</tr>
<tr>
<td>KV Cache 压力</td>
<td>MHA / GQA / MQA</td>
<td>减少 KV heads，降低显存与带宽</td>
<td>长上下文收益显著；可能牺牲部分表示自由度</td>
</tr>
<tr>
<td>KV 压缩（Latent KV）</td>
<td>低秩/潜变量压缩（如把 KV 投影到低维潜空间）</td>
<td>进一步压缩 KV Cache</td>
<td>上下文长度与并发能力提升，但架构更复杂、实现更依赖细节</td>
</tr>
<tr>
<td>FFN 稠密 vs 稀疏</td>
<td>Dense / MoE</td>
<td>用稀疏激活扩大参数容量</td>
<td>训练更复杂（路由/负载均衡）；推理吞吐依赖专家并行与缓存</td>
</tr>
<tr>
<td>预训练目标</td>
<td>Next-token / Multi-token Prediction（MTP）</td>
<td>提升单位 token 的监督信号密度</td>
<td>MTP 可能提高训练效率，但会改变解码对齐与训练配方</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">输入处理</span></div>
<p>Transformer 并不直接处理原始字符串。文本进入模型之前，必须先经过一条输入处理流水线：文本规范化、切分为 token、映射为 token id，再查表转成向量表示。只有完成这一步，后续的注意力、FFN 和位置编码才有可计算的离散输入。输入处理决定了模型“看见世界的最小单位”是什么，因此它不仅影响参数规模与推理效率，也会影响稀有词覆盖、跨语言能力、长度利用率以及生成结果的边界质量。</p>
<div class="blog_h3"><span class="graybg">Tokenization</span></div>
<p>Tokenization 的核心任务是把连续文本切分成模型可处理的离散符号序列。这个离散化过程看似只是“切词”，实际上定义了词表（Vocabulary）、序列长度、未知词处理方式以及字符到语义表示的映射粒度。若切得过粗，词表会过大、稀有词泛化差；若切得过细，序列会变长、计算成本升高。因此，现代语言模型通常采用子词分词（Subword Tokenization）：用有限词表在“整词”和“字符”之间取得平衡。</p>
<p>分词并不只是训练前的一道预处理工序，它深度参与了模型能力边界的形成。相同一句文本，换一种 tokenizer，模型看到的 token 序列长度、常见片段分布、数字和符号的切分方式都会变化，进而影响上下文利用率、训练效率、长文本成本、代码与多语言表现，甚至影响困惑度（Perplexity）等指标的可比性。也正因为如此，跨模型比较时，若 tokenizer 不同，很多“每 token 指标”都不能直接横向解读。</p>
<p>此外，现代 tokenizer 通常不只负责“切分”，还负责一组配套约定：例如保留哪些特殊 token（Special Tokens），如何处理大小写、空格、换行、标点、表情与 Unicode 字符，以及遇到词表里没有的片段时如何回退。像 <pre class="crayon-plain-tag">[UNK]</pre> 这样的未知词标记（Unknown Token）就是早期整词分词里常见的退路：当输入片段不在词表中时，直接映射成一个统一的“未知”符号。它的问题是信息损失很大，不同未知词都会塌缩成同一个 token。子词分词与字节分词之所以重要，一个核心原因就是它们大幅减少了对 <pre class="crayon-plain-tag">[UNK]</pre> 的依赖。</p>
<p>从风格上看，分词大致可以分为四类。第一类是整词分词（Word-level Tokenization）：把单词当作基本单位，优点是语义直观，缺点是词表会迅速膨胀，且对未登录词（Out-of-Vocabulary, OOV）非常敏感。第二类是字符分词（Character-level Tokenization）：把每个字符都当作 token，几乎没有 OOV 问题，但序列会显著变长，模型需要自己学习更多组合关系。第三类是子词分词（Subword Tokenization）：用常见片段构成词表，让高频词保持完整、低频词拆成片段，这是现代 NLP 最主流的折中路线。第四类是字节分词（Byte-level Tokenization）：直接在字节层处理输入，覆盖能力最强，跨语言和特殊符号最稳，但序列通常更长，对模型容量和训练配方要求更高。</p>
<p>因此，不同分词风格的本质取舍是：<span style="background-color: #c0c0c0;">词表越大，单个 token 的语义通常越完整，但 OOV 与稀疏性越严重；词表越小，覆盖越稳，但序列越长、建模负担越重</span>。现代大模型之所以大量采用 BPE、WordPiece、SentencePiece 或 byte-level BPE，本质上都是在这条权衡曲线上寻找更合适的工程平衡点。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">分词风格</td>
<td style="text-align: center;">基本单位</td>
<td style="text-align: center;">主要优点</td>
<td style="text-align: center;">主要代价</td>
<td style="text-align: center;">常见场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>整词分词</td>
<td>单词</td>
<td>语义直观；序列较短</td>
<td>词表膨胀；OOV 严重</td>
<td>早期 NLP；规则较强的封闭词表任务</td>
</tr>
<tr>
<td>字符分词</td>
<td>字符</td>
<td>覆盖稳定；几乎无 OOV</td>
<td>序列长；组合学习负担大</td>
<td>鲁棒输入建模；字符级任务</td>
</tr>
<tr>
<td>子词分词</td>
<td>高频片段 / 子词</td>
<td>词表与序列长度折中较好</td>
<td>切分方式影响语义边界</td>
<td>BERT、T5、LLaMA 等主流文本模型</td>
</tr>
<tr>
<td>字节分词</td>
<td>字节</td>
<td>覆盖最强；特殊符号与多语言稳健</td>
<td>序列更长；训练成本更高</td>
<td>byte-level BPE、多语言与噪声文本</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">BPE</span></div>
<p>BPE（Byte Pair Encoding）从字符（或字节）开始，通过统计合并高频相邻符号对（Pair Merge）逐步构建子词（Subword）词表。它的核心收益是用有限词表覆盖开放词汇：常见词被合并成整体，罕见词被拆成更小片段，减少 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{UNK}]\)</span>。</p>
<p>BPE 的直觉可以概括为“把最常一起出现的片段逐步固化成一个 token”。例如，若训练语料里 <pre class="crayon-plain-tag">t</pre> 和 <pre class="crayon-plain-tag">h</pre> 经常相邻，就可能先合并成 <pre class="crayon-plain-tag">th</pre>；若 <pre class="crayon-plain-tag">th</pre> 与 <pre class="crayon-plain-tag">e</pre> 又高频共现，就可能继续合并成 <pre class="crayon-plain-tag">the</pre>。经过大量合并之后，词表里会同时存在完整高频词、常见词根、后缀、数字片段和标点组合。这样一来，模型既能用短序列表达常见模式，又不必为每个罕见词都预留独立词条。</p>
<p>从工程谱系上看，GPT 家族总体属于 BPE 路线的延伸：早期 GPT / GPT-2 风格 tokenizer 采用 byte-level BPE，把文本先映射到字节层，再做 BPE 合并；这种设计能更稳地覆盖任意 Unicode 文本、空格和特殊符号。对 OpenAI 当前模型生态而言，官方开发工具链中程序化分词通常使用 tiktoken；它对应的是面向具体模型的 encoding 体系，但核心思想仍然是 BPE 家族的子词压缩与高覆盖率路线。对开发者来说，更重要的实践结论是：<span style="background-color: #c0c0c0;">GPT 并不是“按词”切分，而是按 BPE 家族 tokenizer 切成子词或字节片段</span>；同一个自然语言单词，可能被切成一个 token，也可能被切成多个 token，取决于它在词表中的合并状态。</p>
<div class="blog_h4"><span class="graybg">WordPiece</span></div>
<p>WordPiece 与 BPE 同属子词分词（Subword Tokenization），但合并准则更偏向最大化语言模型似然（Likelihood）。BERT 系列常用 WordPiece，因此会看到以 <pre class="crayon-plain-tag">##</pre> 标记的子词前缀（如 <pre class="crayon-plain-tag">play</pre> + <pre class="crayon-plain-tag">##ing</pre>）。</p>
<div class="blog_h4"><span class="graybg">SentencePiece</span></div>
<p>SentencePiece 是一种分词器（Tokenizer）训练与推理框架（常见算法包括 BPE 与 Unigram LM）。它可以直接在原始文本上训练（不依赖空格分词），因此在多语言与无空格语言（如中文、日文）上更常用；LLaMA 等模型的 tokenizer 通常基于 SentencePiece。</p>
<div class="blog_h3"><span class="graybg">Token Embedding</span></div>
<p>Token Embedding 的核心是一个可训练的嵌入表（Embedding Table，也常被称为嵌入矩阵（Embedding Matrix））：</p>
<span displaypfx="" class="mathjax-container">\[E\in\mathbb{R}^{V\;\times d_{\text{model}}}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 是词表大小（Vocabulary Size），每一行对应一个 token 的向量表示。给定输入 token id 序列 <span displaypfx="inline-" class="mathjax-container">\((t_1,\dots,t_L)\)</span>，查表得到输入嵌入序列（Embedding Output）：</p>
<span displaypfx="" class="mathjax-container">\[X=\begin{bmatrix}E_{t_1}\\ \vdots\\ E_{t_L}\end{bmatrix}\in\mathbb{R}^{L\;\times d_{\text{model}}}\]</span>
<p>一些材料会把 <span displaypfx="inline-" class="mathjax-container">\(E\)</span>（参数表）和 <span displaypfx="inline-" class="mathjax-container">\(X\)</span>（某次输入的嵌入结果）都叫“嵌入矩阵”，容易混淆。区分的一个简单方式是：<span style="background-color: #c0c0c0;">E 是全词表参数，X 是当前输入的嵌入输出</span>。</p>
<p>在语言模型里，这张输入嵌入表常与输出处理中的语言模型头（LM Head）共享参数，即权重共享（Weight Tying）。这里先记住这一点即可；它的具体计算方式与工程含义放在后面的“输出处理”中展开。</p>
<div class="blog_h2"><span class="graybg">位置编码</span></div>
<p>位置编码（Positional Encoding）解决一个根本问题：注意力机制本身对输入顺序是置换不变（Permutation-Invariant）的，如果不显式注入位置信息，模型无法区分“AB”和“BA”。因此需要把“位置”以某种方式编码进每个 token 的表示。</p>
<div class="blog_h3"><span class="graybg">绝对位置编码</span></div>
<p>绝对位置编码（Absolute Positional Encoding）最常见的做法之一是学习一个位置嵌入表（Position Embedding Table）：</p>
<span displaypfx="" class="mathjax-container">\[P\in\mathbb{R}^{L_{\max}\;\times d_{\text{model}}}\]</span>
<p><span displaypfx="inline-" class="mathjax-container">\(L_{\max}\)</span> 是模型支持的最大位置索引数量（Maximum Position Index）。对长度为 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 的输入序列，取 <span displaypfx="inline-" class="mathjax-container">\(P_{0:L}\)</span>（或 <span displaypfx="inline-" class="mathjax-container">\(P_{1:L}\)</span>，取决于实现）得到当前序列的位置嵌入矩阵 <span displaypfx="inline-" class="mathjax-container">\(P_{\text{seq}}\in\mathbb{R}^{L\;\times d_{\text{model}}}\)</span>。</p>
<p>Transformer 通常用逐元素相加把 token 嵌入与位置嵌入融合：</p>
<span displaypfx="" class="mathjax-container">\[H^{(0)} = X + P_{\text{seq}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(H^{(0)}\)</span> 仍然是 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 维向量序列，不是“位置标量”。位置是否用一个标量并不重要；重要的是这种表示能让后续的线性层与注意力计算利用位置关系。高维位置向量提供了更丰富的可学习空间。</p>
<p>“相加会不会把信息混在一起、无法区分？”这个直觉常见，但对表示学习而言关键不是可逆性，而是可用性：模型不需要从 <span displaypfx="inline-" class="mathjax-container">\(H^{(0)}\)</span> 精确还原 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(P_{\text{seq}}\)</span>，只需要用它们的组合完成预测。并且在高维空间里，模型可以把“语义”和“位置”分配到近似正交（Approximately Orthogonal）的方向，使得线性变换能有效解耦。</p>
<p>一个二维玩具例子：令 token 向量 <span displaypfx="inline-" class="mathjax-container">\(x=(1,0)\)</span>，位置向量 <span displaypfx="inline-" class="mathjax-container">\(p=(0,0.1)\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(h=x+p=(1,0.1)\)</span>。如果模型的某个线性读出只看第二维（例如乘以 <span displaypfx="inline-" class="mathjax-container">\((0,10)\)</span>），就能强烈感知位置而几乎不受语义影响。真实模型在上千维空间里有更大的自由度（Degree of Freedom, DOF）。</p>
<p>把位置“拼接”（Concatenation）到额外维度也能工作，但它会改变隐藏维度，影响后续层形状与参数规模；而加法保持 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 不变，是一种参数与工程都更稳定的设计选择。</p>
<div class="blog_h4"><span class="graybg">正弦位置编码（Sinusoidal Positional Encoding）</span></div>
<p>另一类绝对位置编码是正弦位置编码（Sinusoidal Positional Encoding），它不引入可学习参数，而是用不同频率的正弦/余弦把位置 <span displaypfx="inline-" class="mathjax-container">\(\text{pos}\)</span> 映射为向量（原始 Transformer 的设计）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{PE}(\text{pos},2i)=\sin\!\left(\frac{\text{pos}}{10000^{2i/d_{\text{model}}}}\right),\quad \mathrm{PE}(\text{pos},2i+1)=\cos\!\left(\frac{\text{pos}}{10000^{2i/d_{\text{model}}}}\right)\]</span>
<p>为什么要成对使用 <span displaypfx="inline-" class="mathjax-container">\(\sin\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\cos\)</span>（而不是全用 <span displaypfx="inline-" class="mathjax-container">\(\sin\)</span>）？因为对同一频率而言，<span displaypfx="inline-" class="mathjax-container">\((\sin\phi,\cos\phi)\)</span> 组成一个二维正交基（Orthogonal Basis），位置平移 <span displaypfx="inline-" class="mathjax-container">\(\phi\mapsto \phi+\Delta\)</span> 等价于二维平面上的旋转（Rotation）：</p>
<span displaypfx="" class="mathjax-container">\[\begin{bmatrix}\sin(\phi+\Delta)\\ \cos(\phi+\Delta)\end{bmatrix}=\begin{bmatrix}\cos\Delta &amp; \sin\Delta\\ -\sin\Delta &amp; \cos\Delta\end{bmatrix}\begin{bmatrix}\sin\phi\\ \cos\phi\end{bmatrix}\]</span>
<p>这让“相对位移”变成一个固定的线性变换，从而更容易被后续线性层和点积注意力利用；如果只用 <span displaypfx="inline-" class="mathjax-container">\(\sin\)</span>，相位信息会丢失，平移不再能用线性变换稳定表达。</p>
<p>若只取一个最小的 4 维例子，即 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}=4\)</span>，那么位置编码就会具体化成：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{PE}(\text{pos})=\big[\sin(\text{pos}),\ \cos(\text{pos}),\ \sin(\text{pos}/100),\ \cos(\text{pos}/100)\big]\]</span>
<p>这时每个位置都不再是“一个编号”，而是一个 4 维向量。前两维变化很快，负责较短尺度的位置区分；后两维变化很慢，负责较长尺度的位置区分。例如 <span displaypfx="inline-" class="mathjax-container">\(\text{pos}=0\)</span> 时编码是 <span displaypfx="inline-" class="mathjax-container">\([0,1,0,1]\)</span>；<span displaypfx="inline-" class="mathjax-container">\(\text{pos}=1\)</span> 时约为 <span displaypfx="inline-" class="mathjax-container">\([0.84,0.54,0.01,1.00]\)</span>；<span displaypfx="inline-" class="mathjax-container">\(\text{pos}=2\)</span> 时约为 <span displaypfx="inline-" class="mathjax-container">\([0.91,-0.42,0.02,1.00]\)</span>。因此，不同位置会同时在多种频率刻度上留下痕迹，而不是只靠一个单调递增的数字区分。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/sinusoidal-positional-encoding-4d.png"><img class="alignnone size-full" src="https://blog.gmem.cc/wp-content/uploads/2026/03/sinusoidal-positional-encoding-4d.png" alt="sinusoidal-positional-encoding-4d" width="2322" height="1354" /></a></p>
<p>上图把这个 4 维例子拆成三种读法。左侧热力图直接列出位置 <span displaypfx="inline-" class="mathjax-container">\(0\sim 7\)</span> 在四个维度上的编码值；右上角把前两维 <span displaypfx="inline-" class="mathjax-container">\((\sin(\text{pos}),\cos(\text{pos}))\)</span> 直接当成二维平面坐标，因此可以把它理解成：<span style="background-color: #c0c0c0;">位置每增加一点，平面上的点就沿圆周往前走一步</span>；右下角则把快频对与慢频对分开画出。图里第 3、4 维之所以先前看起来几乎是平的，不是因为它们不变，而是因为在标准公式里它们对应更低频率：在 <span displaypfx="inline-" class="mathjax-container">\(\text{pos}=0\sim 12\)</span> 这样很短的区间上， <span displaypfx="inline-" class="mathjax-container">\(\sin(\text{pos}/100)\)</span> 只从 0 变化到约 0.12， <span displaypfx="inline-" class="mathjax-container">\(\cos(\text{pos}/100)\)</span> 只从 1 下降到约 0.99，必须单独放大才容易看见变化。</p>
<p>模型利用这套编码的方式，可以直接理解成“拿多把不同刻度的尺子同时量位置关系”。同一对 token 的距离，在高频维度上会表现成较快的相位差，在低频维度上会表现成较慢的相位差；于是模型看到的就不是一个孤立的位置编号，而是一组跨多个尺度同时变化的模式。对于很近的 token，高频维度会给出很敏感的区分；对于距离更远的 token，低频维度仍然能保留稳定变化，不会太快绕回去。注意力层随后并不是逐维人工判读这些数值，而是在训练中学会：某些相位差组合通常意味着“相邻修饰”“短程依赖”，另一些更慢变化的组合更像“跨句呼应”或“长程对应”。这里并不存在一个必须被显式恢复出来的“角度标量”或“距离标量”。只要位置变化能够稳定地改变表示与点积结果，后续线性层和注意力头就可以把这种差异当作可利用特征。正弦位置编码的作用正是在于把位置关系改写成一组可被模型利用的周期信号，让模型自己在不同频率上学会读出距离与相对顺序。</p>
<div class="blog_h3"><span class="graybg">相对位置编码</span></div>
<p>相对位置编码（Relative Positional Encoding）不直接编码“绝对索引”，而是让注意力更显式地依赖 token 之间的相对距离 <span displaypfx="inline-" class="mathjax-container">\(i-j\)</span>。典型做法是在注意力打分里加入相对位置偏置（Relative Position Bias）：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_{ij}\propto \exp\!\left(\frac{q_i k_j^\top}{\sqrt{d_k}} + b_{i-j}\right)\]</span>
<p>这条式子描述的是：位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 的 query 去看位置 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的 key 时，未归一化注意力权重会受到两部分共同决定。第一部分 <span displaypfx="inline-" class="mathjax-container">\(\frac{q_i k_j^\top}{\sqrt{d_k}}\)</span> 是标准内容相关性打分：其中 <span displaypfx="inline-" class="mathjax-container">\(q_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个位置的查询向量（Query Vector），<span displaypfx="inline-" class="mathjax-container">\(k_j\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个位置的键向量（Key Vector），二者点积 <span displaypfx="inline-" class="mathjax-container">\(q_i k_j^\top\)</span> 衡量“位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 当前想找的信息，与位置 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 持有的信息是否匹配”；<span displaypfx="inline-" class="mathjax-container">\(d_k\)</span> 是 key/query 的维度，除以 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{d_k}\)</span> 是为了控制数值尺度，避免维度增大后 softmax 过早饱和。</p>
<p>第二部分 <span displaypfx="inline-" class="mathjax-container">\(b_{i-j}\)</span> 是只由相对距离决定的偏置项（Bias Term）。若 <span displaypfx="inline-" class="mathjax-container">\(i-j=1\)</span>，表示当前 token 正在看它左边紧邻的位置；若 <span displaypfx="inline-" class="mathjax-container">\(i-j=10\)</span>，表示它正在看更远的上文。这个偏置可以通过查表得到：给每一种相对距离，或给若干距离分桶（bucket）后的区间，各分配一个可学习标量；也可以由一个小网络根据 <span displaypfx="inline-" class="mathjax-container">\(i-j\)</span> 生成。它的作用是把“距离本身是否重要”直接加进打分，而不必完全依赖内容向量自己去隐式学出这种规律。</p>
<p>式子左边的 <span displaypfx="inline-" class="mathjax-container">\(\alpha_{ij}\)</span> 表示位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 对位置 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的注意力权重；这里写成 <span displaypfx="inline-" class="mathjax-container">\(\propto\)</span> 而不是等号，是因为右边还只是指数化前的未归一化权重。真正的注意力概率还要在固定 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 后，对所有 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 一起做 softmax 归一化：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_{ij}=\frac{\exp\!\left(\frac{q_i k_j^\top}{\sqrt{d_k}} + b_{i-j}\right)}{\sum_{j'}\exp\!\left(\frac{q_i k_{j'}^\top}{\sqrt{d_k}} + b_{i-j'}\right)}\]</span>
<p>因此，相对位置编码的含义可以概括为：注意力不只比较“内容是否匹配”，还显式比较“这个位置离我有多远”。很多语言现象更依赖相对距离而不是绝对序号，例如局部搭配、邻近修饰、长程指代和句法依赖，因此把 <span displaypfx="inline-" class="mathjax-container">\(i-j\)</span> 直接写进打分，往往比单纯依赖绝对位置索引更贴近任务结构。</p>
<div class="blog_h3"><span class="graybg">RoPE</span></div>
<p>RoPE（Rotary Position Embedding）把位置信息以“旋转”的方式注入到 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span>/<span displaypfx="inline-" class="mathjax-container">\(K\)</span> 中。若按实数矩阵来写，就是把向量的每两维视为一个二维平面，再用角度与位置成正比的旋转矩阵作用在这两维上。对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个二维分量，令 <span displaypfx="inline-" class="mathjax-container">\(\theta_{m,i}\)</span> 表示位置 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 在该（每两维一个）频段上的旋转角，则</p>
<span displaypfx="" class="mathjax-container">\[\begin{bmatrix}x'_{2i}\\ x'_{2i+1}\end{bmatrix}=\begin{bmatrix}\cos\theta_{m,i} &amp; -\sin\theta_{m,i}\\ \sin\theta_{m,i} &amp; \cos\theta_{m,i}\end{bmatrix}\begin{bmatrix}x_{2i}\\ x_{2i+1}\end{bmatrix}\]</span>
<p>实现上，RoPE 不是“只把当前 query 旋转一下”，而是<span style="background-color: #c0c0c0;">对每个位置的 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 都各自按该位置做旋转</span>；随后不同位置之间再做点积匹配。这样一来，位置 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(Q_m\)</span> 和位置 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 的 <span displaypfx="inline-" class="mathjax-container">\(K_n\)</span> 在相遇时，二者各自携带的位置相位就会共同决定匹配结果。通常只有 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span>/<span displaypfx="inline-" class="mathjax-container">\(K\)</span> 参与这种旋转， <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 不旋转，因为位置信息的关键作用点在“如何计算注意力权重”，而不是在“被加权汇总的内容值”本身。</p>
<p>上述矩阵式在实现上是正确的，但从理解角度看仍然偏“机械”。更直接的方式是用复数视角（Complex Perspective）：把每两维 <span displaypfx="inline-" class="mathjax-container">\((x_{2i},x_{2i+1})\)</span> 看成一个复数</p>
<span displaypfx="" class="mathjax-container">\[z_i = x_{2i} + \mathrm{i}x_{2i+1}\]</span>
<p>于是 RoPE 的位置注入就可以写成一个极其紧凑的式子：</p>
<span displaypfx="" class="mathjax-container">\[z_i' = z_i \, e^{\mathrm{i} m \theta_i}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 是位置索引， <span displaypfx="inline-" class="mathjax-container">\(\theta_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个频段的基础角速度， <span displaypfx="inline-" class="mathjax-container">\(e^{\mathrm{i} m \theta_i}\)</span> 表示“在复平面上旋转 <span displaypfx="inline-" class="mathjax-container">\(m\theta_i\)</span> 角”。这时 RoPE 的直觉就变得很清楚：<span style="background-color: #c0c0c0;">同一个向量本身不变，变化的是它在不同位置上附带的相位（phase）</span>。位置越靠后，相位就继续往前转。</p>
<p>这种写法的关键价值在于：相对位置会自然地从乘法里浮现出来。若位置 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 的 query 与位置 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 的 key 都经过旋转，则它们的匹配项可写成</p>
<span displaypfx="" class="mathjax-container">\[q_m^{(i)} e^{\mathrm{i} m \theta_i}\cdot \overline{k_n^{(i)} e^{\mathrm{i} n \theta_i}} = q_m^{(i)} \overline{k_n^{(i)}} e^{\mathrm{i}(m-n)\theta_i}\]</span>
<p>这里上划线表示复共轭（Complex Conjugate）。前文“二维向量的复数表示”已经给出同一条基本关系：二维点积可以写成复共轭乘积的实部，因此把二维块写成复数后，位置相位会直接进入匹配项。最重要的结果是指数项里只剩下 <span displaypfx="inline-" class="mathjax-container">\((m-n)\theta_i\)</span>：绝对位置 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 被自动折叠成了相对位移 <span displaypfx="inline-" class="mathjax-container">\(m-n\)</span>。因此，RoPE 并不是先写出绝对位置、再额外补一个相对位置项，而是通过“给每个位置乘一个相位因子”的方式，让相对位移直接出现在注意力匹配里。</p>
<p>若用一句更通俗的话概括，RoPE 做的事情是：<span style="background-color: #c0c0c0;">给每个位置的 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span>/<span displaypfx="inline-" class="mathjax-container">\(K\)</span> 都拧上一点角度；两个位置一做点积，位置差就会体现在匹配分数里</span>。矩阵形式更像工程实现的展开式，复数形式更接近它的数学本质。模型并不需要在内部先还原出一个单独的“角度值”再决定如何注意；它只需要利用这种旋转所造成的分数差异与模式差异。只要某类相位关系稳定对应某类局部依赖、顺序关系或长程对应，训练过程就会把这些模式吸收到注意力头和后续层的参数里。也正因为这种“相对位移直接进入匹配”的结构，RoPE 在 Decoder-only 大模型中成为主流选择（例如 LLaMA 系列）。</p>
<div class="blog_h4"><span class="graybg">RoPE 长度外推（Length Extrapolation）</span></div>
<p>RoPE 的旋转角随位置线性增长。若训练阶段最大长度为 <span displaypfx="inline-" class="mathjax-container">\(L_{\text{train}}\)</span>，推理时直接扩展到 <span displaypfx="inline-" class="mathjax-container">\(L_{\text{test}}\gg L_{\text{train}}\)</span>，部分频段会出现“过快旋转”：模型开始在比训练时更长得多的位置区间上继续累积相位，而这些大角度相位组合在训练中几乎没有见过。结果是远距离 token 之间的相对相位关系超出训练分布，注意力更容易退化为近邻偏好，长上下文检索与推理准确率下降。</p>
<p>典型评测是大海捞针（Needle in a Haystack）：在很长的上下文中埋入一条关键信息（needle），要求模型在指定问题下准确复述该信息。常见现象是针落在开头/结尾时表现更好，但针落在中间位置时准确率显著下降；这通常与位置编码外推、注意力实现细节与 KV Cache 行为共同相关。</p>
<p>工程上常见的 RoPE 外推改造包括：</p>
<ul>
<li>位置插值（Position Interpolation, PI）：把推理位置按比例压缩回训练范围，相当于把 RoPE 角速度整体放慢。</li>
<li>NTK-aware 缩放（NTK-aware Scaling）：按“有效核宽度”视角调整频率谱，缓和远距离相对位移失真。</li>
<li>YaRN：对不同频段做分段/渐变缩放，尽量同时保住短程精度与长程外推。</li>
</ul>
<p>以 PI 为例，一个常用写法等价于把 RoPE 的位置 <span displaypfx="inline-" class="mathjax-container">\(\text{pos}\)</span> 映射为 <span displaypfx="inline-" class="mathjax-container">\(\text{pos}'=\text{pos}/s\)</span>（<span displaypfx="inline-" class="mathjax-container">\(s=L_{\text{test}}/L_{\text{train}}\)</span>），从而把角度压回训练范围：</p>
<span displaypfx="" class="mathjax-container">\[\theta'_{\text{pos},i}=\frac{\text{pos}/s}{\text{base}^{2i/d}},\quad s=\frac{L_{\text{test}}}{L_{\text{train}}}\]</span>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">方法</td>
<td style="text-align: center;">是否需要再训练</td>
<td style="text-align: center;">核心超参</td>
<td style="text-align: center;">优势</td>
<td style="text-align: center;">风险/备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>PI</td>
<td>建议配合长上下文继续预训练/微调</td>
<td>缩放因子 <span displaypfx="inline-" class="mathjax-container">\(s\)</span></td>
<td>实现简单；可在保持短程行为的同时扩展长度</td>
<td>若只做推理时改造，可能出现分布错配；需用 needle 测试验证“中间段”能力</td>
</tr>
<tr>
<td>NTK-aware scaling</td>
<td>可仅推理侧启用；配合微调更稳</td>
<td>频谱/基数缩放规则</td>
<td>对远距离更平滑；常用于把“可用上下文”拉长</td>
<td>不同实现差异大；需关注与 KV Cache、GQA/MQA 等工程优化的耦合</td>
</tr>
<tr>
<td>YaRN</td>
<td>通常建议配合继续预训练</td>
<td>分段/渐变缩放参数</td>
<td>兼顾短程精度与长程外推；对 needle 中段退化更友好</td>
<td>超参更多；需要系统评测（含不同位置、不同检索难度）</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">ALiBi</span></div>
<p>ALiBi（Attention with Linear Biases）直接在注意力 logits 上加一个与距离线性相关的偏置，而不改变表示维度，也不引入位置向量：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_{ij}\propto \exp\!\left(\frac{q_i k_j^\top}{\sqrt{d_k}} - m\cdot (i-j)\right),\quad j\le i\]</span>
<p>其中斜率 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 可按 head 设置。直觉上它鼓励模型更关注近邻 token，同时具备较好的长度外推（Length Extrapolation）行为。</p>
<div class="blog_h2"><span class="graybg">注意力机制</span></div>
<p>注意力机制（Attention Mechanism）是序列模型中的动态信息选择机制。对于一个由多个 token 构成的输入序列，模型不会把所有上下文位置等量混合，而是会针对当前正在计算的位置，动态判断哪些位置更相关、相关程度有多大，以及这些位置的信息应当如何组合成新的表示。这个过程本质上是一个与输入内容相关的加权汇聚：当前位置先形成查询信号，再在上下文中寻找与之匹配的位置，最后把这些位置承载的信息按权重聚合回来。</p>
<p>这种设计改变了传统序列建模的信息传递路径。循环结构主要依赖状态沿时间步逐步传递，卷积结构主要依赖固定大小的局部感受野，而注意力机制允许任意两个位置直接建立联系，并且联系强度由内容决定而不是由距离预先写死。长距离依赖（Long-Range Dependency）因此可以被更直接地建模：一个 token 可以立刻读取很远处但与当前语义高度相关的信息，而不必等待信息穿过很长的递归链条或许多层局部卷积。</p>
<p>Transformer 将注意力机制置于核心位置。自注意力（Self-Attention）让同一序列内部的各个 token 相互读取；交叉注意力（Cross-Attention）让一个序列读取另一个序列的表示；因果注意力（Causal Attention）则通过掩码限制当前位置只能访问过去的信息，从而支撑自回归生成（Autoregressive Generation）。这些形式都遵循同一条主线：先计算相关性分数，再把分数归一化为权重，最后对承载内容的向量做加权求和。其最经典、最常见的数学形式就是缩放点积注意力（Scaled Dot-Product Attention）。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/self-attention-mechanism.png"><img class="alignnone size-full wp-image-41471" src="https://blog.gmem.cc/wp-content/uploads/2026/03/self-attention-mechanism.png" alt="self-attention-mechanism" width="1920" height="1080" /></a></p>
<div class="blog_h3"><span class="graybg">Scaled Dot-Product Attention</span></div>
<p>自注意力（Self-Attention）中，输入表示 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 通过三组参数投影为：</p>
<span displaypfx="" class="mathjax-container">\[Q=XW_Q,\quad K=XW_K,\quad V=XW_V\]</span>
<p>注意力输出为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(W_Q,W_K,W_V\)</span>（以及多头里的 <span displaypfx="inline-" class="mathjax-container">\(W_O\)</span>）都是模型参数（Parameters），训练的目标不是“生成这些矩阵”，而是在损失函数（Loss）下通过梯度下降（Gradient Descent）把它们优化到能完成任务的取值。</p>
<p>把公式按 token 展开更清楚。设当前只看一个注意力头，序列长度为 <span displaypfx="inline-" class="mathjax-container">\(L\)</span>。对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 token，模型先取出它的查询向量 <span displaypfx="inline-" class="mathjax-container">\(q_i\in\mathbb{R}^{d_k}\)</span>，再与序列中每个位置 <span displaypfx="inline-" class="mathjax-container">\(j=1,\dots,L\)</span> 的键向量 <span displaypfx="inline-" class="mathjax-container">\(k_j\in\mathbb{R}^{d_k}\)</span> 做点积，得到一个标量打分：</p>
<span displaypfx="" class="mathjax-container">\[s_{ij}=q_i k_j^\top\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(d_k\)</span> 表示每个头里 Key / Query 向量的维度，也就是 <span displaypfx="inline-" class="mathjax-container">\(q_i\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(k_j\)</span> 的长度。它之所以记作 <span displaypfx="inline-" class="mathjax-container">\(d_k\)</span>，是因为这个维度首先由 Key 空间定义；而 Query 必须与 Key 处在同样维度里，才能做点积匹配。因此 <span displaypfx="inline-" class="mathjax-container">\(q_i\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(k_j\)</span> 的长度通常相同。Value 向量的维度记作 <span displaypfx="inline-" class="mathjax-container">\(d_v\)</span>；实践中常见设置是 <span displaypfx="inline-" class="mathjax-container">\(d_v=d_k\)</span>，但这不是数学上的硬要求。</p>
<p>接着，对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 query 的整行打分做缩放和 softmax，得到一组对所有位置的注意力权重：</p>
<span displaypfx="" class="mathjax-container">\[\alpha_{ij}=\mathrm{softmax}_j\!\left(\frac{s_{ij}}{\sqrt{d_k}}\right)=\frac{\exp\!\left(s_{ij}/\sqrt{d_k}\right)}{\sum_{t=1}^{L}\exp\!\left(s_{it}/\sqrt{d_k}\right)}\]</span>
<p>因此，对固定的 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 来说， <span displaypfx="inline-" class="mathjax-container">\(\alpha_{i1},\dots,\alpha_{iL}\)</span> 构成一个标量概率分布：它们都非负，且总和为 1。这个分布回答的是“第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 token 应该从整段序列的哪些位置读取多少信息”。</p>
<p>输出 <span displaypfx="inline-" class="mathjax-container">\(o_i\)</span> 则是一个向量（Vector），由所有 Value 向量 <span displaypfx="inline-" class="mathjax-container">\(v_j\in\mathbb{R}^{d_v}\)</span> 按权重加权求和得到：</p>
<span displaypfx="" class="mathjax-container">\[o_i=\sum_{j=1}^{L}\alpha_{ij} v_j\]</span>
<p>把这一步画成示意图会更直观：固定第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 query 后，先得到一组对各位置 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的注意力权重；再用这些权重去加权汇总对应的 Value 向量；右侧输出向量的每一维，都是左侧各个 Value 向量对应维度的加权和。</p>
<p>查询向量（Query）与键向量（Key）负责“匹配打分”；值向量（Value）承载被聚合的信息内容。把注意力看作“内容寻址（Content-based Addressing）”：先用 <span displaypfx="inline-" class="mathjax-container">\(QK^\top\)</span> 计算“应该看谁”，再用权重对 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 做加权求和得到“看到了什么”。</p>
<p>缩放因子 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{d_k}\)</span> 的作用是控制数值尺度。由于 <span displaypfx="inline-" class="mathjax-container">\(q_i\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(k_j\)</span> 的点积是 <span displaypfx="inline-" class="mathjax-container">\(d_k\)</span> 个乘积项的求和，若各维分量方差相近，则点积分数的方差通常会随着 <span displaypfx="inline-" class="mathjax-container">\(d_k\)</span> 增长。维度一大， <span displaypfx="inline-" class="mathjax-container">\(s_{ij}\)</span> 的绝对值就更容易变大，softmax 会更快进入饱和区：某几个位置的权重接近 1，其余位置接近 0，梯度也会变小。除以 <span displaypfx="inline-" class="mathjax-container">\(\sqrt{d_k}\)</span> 后，分数尺度被拉回更稳定的范围，不同 head 维度设置下的 softmax 行为会更可控。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/attention-softmax-saturation.png"><img class="alignnone size-full wp-image-41453" src="https://blog.gmem.cc/wp-content/uploads/2026/03/attention-softmax-saturation.png" alt="attention-softmax-saturation" width="1920" height="1112" /></a></p>
<p>理论上可以让寻址与内容共用投影（例如令 <span displaypfx="inline-" class="mathjax-container">\(V=K\)</span> 或直接取 <span displaypfx="inline-" class="mathjax-container">\(V=X\)</span>），但实践中通常把 Q/K 与 V 分开，是为了让“打分空间”和“内容表示空间”解耦，提升表示能力与训练稳定性。</p>
<p>一个极简数值例子：若某个 Query 与 3 个 Key 的相似度（未缩放）为 <span displaypfx="inline-" class="mathjax-container">\([2,1,0]\)</span>，softmax 权重大约是 <span displaypfx="inline-" class="mathjax-container">\([0.665,0.245,0.090]\)</span>，输出就是把三个 Value 按这个比例加权求和。</p>
<div class="blog_h3"><span class="graybg">Multi-Head Attention</span></div>
<p>多头注意力（Multi-Head Attention）把注意力拆成 <span displaypfx="inline-" class="mathjax-container">\(H\)</span> 个头（Heads），每个头在不同的子空间里独立做一次注意力，然后在特征维度上拼接（Concatenation）并用输出矩阵混合：</p>
<span displaypfx="" class="mathjax-container">\[\text{head}_h=\mathrm{Attention}(XW_Q^{(h)},XW_K^{(h)},XW_V^{(h)})\]</span>
<span displaypfx="" class="mathjax-container">\[\mathrm{MultiHead}(X)=\mathrm{Concat}(\text{head}_1,\dots,\text{head}_H)W_O\]</span>
<p>若 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 固定，常见做法是每个头的维度 <span displaypfx="inline-" class="mathjax-container">\(d_k=d_v=d_{\text{model}}/H\)</span>，拼接后回到 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span>。多头的收益来自“并行关注不同关系”：有的头偏向局部邻域，有的头偏向长程依赖，有的头学到语法/实体指代等不同模式。</p>
<p>这种分工在标准训练里通常不是人工指定的，而是优化过程中的自发结果。每个头都拥有各自独立的 <span displaypfx="inline-" class="mathjax-container">\(W_Q^{(h)},W_K^{(h)},W_V^{(h)}\)</span> 参数，因此即使输入相同，它们也会把表示投影到不同子空间里，形成不同的匹配规则。随机初始化首先打破了头与头之间的对称性；随后，损失函数只约束“多头合起来的整体输出”是否有利于完成任务，而不要求每个头承担同一种功能。在这种条件下，若多个头完全重复，整体表示效率往往偏低；优化更容易把不同头推向不同关系模式，于是逐渐出现局部邻近、长程依赖、分隔符、指代、句法边界等不同偏好。</p>
<p>这种功能分化并不是严格保证。实际模型里常能观察到部分头高度相似，部分头贡献很小，甚至剪掉后性能几乎不变。若希望更强地控制不同头学习不同东西，就需要额外机制，例如对不同头加入多样性正则（Diversity Regularization）、局部窗口约束、特定监督信号，或在训练后做 head pruning / head specialization 分析。</p>
<div class="blog_h3"><span class="graybg">Masked Attention</span></div>
<p>Masked Attention（因果注意力 / Causal Attention）在自回归（Autoregressive）生成中使用：通过掩码（Mask）禁止位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 看到未来位置 <span displaypfx="inline-" class="mathjax-container">\(j&gt;i\)</span>。实现上通常是在 softmax 前把被禁止位置的打分加上一个极小值（如 <span displaypfx="inline-" class="mathjax-container">\(-\infty\)</span>）。</p>
<p>若把未加掩码的打分矩阵记为 <span displaypfx="inline-" class="mathjax-container">\(S=\frac{QK^\top}{\sqrt{d_k}}\in\mathbb{R}^{L\times L}\)</span>，则因果掩码可写成一个上三角被屏蔽的矩阵 <span displaypfx="inline-" class="mathjax-container">\(M\)</span>。以 <span displaypfx="inline-" class="mathjax-container">\(L=4\)</span> 为例：</p>
<span displaypfx="" class="mathjax-container">\[M=\begin{bmatrix} 0 &amp; -\infty &amp; -\infty &amp; -\infty\\ 0 &amp; 0 &amp; -\infty &amp; -\infty\\ 0 &amp; 0 &amp; 0 &amp; -\infty\\ 0 &amp; 0 &amp; 0 &amp; 0 \end{bmatrix}\]</span>
<p>然后在 softmax 之前做逐元素相加：</p>
<span displaypfx="" class="mathjax-container">\[P=\mathrm{softmax}(S+M)\]</span>
<p>这里主对角线及其左下区域为 0，表示当前位置及其历史位置允许被访问；右上区域为 <span displaypfx="inline-" class="mathjax-container">\(-\infty\)</span>，表示未来位置被强制屏蔽。softmax 之后，这些位置的权重会变成 0，因此第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行只能在 <span displaypfx="inline-" class="mathjax-container">\(j\le i\)</span> 的范围内分配概率。</p>
<p>从矩阵形状看，这就是一个<span style="background-color: #c0c0c0;">保留下三角、屏蔽上三角</span>的结构。它保证了解码器在位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 计算注意力时，只能读取已经出现的 token，而不能偷看未来 token。</p>
<p>注意力机制在训练阶段和推理阶段都会执行。区别在于：训练时通常一次性输入整段序列（Teacher Forcing）并使用因果掩码；推理时逐 token 解码，并结合 KV Cache 避免重复计算历史。</p>
<div class="blog_h3"><span class="graybg">Cross-Attention</span></div>
<p>交叉注意力（Cross-Attention）让一个序列“去读另一个序列”。在 Encoder–Decoder Transformer 里：解码器当前状态提供 Query，编码器输出提供 Key/Value。若编码器输出为 <span displaypfx="inline-" class="mathjax-container">\(H_{\text{src}}\in\mathbb{R}^{L_{\text{src}}\times d}\)</span>，解码器输入为 <span displaypfx="inline-" class="mathjax-container">\(H_{\text{tgt}}\in\mathbb{R}^{L_{\text{tgt}}\times d}\)</span>，则</p>
<span displaypfx="" class="mathjax-container">\[Q=H_{\text{tgt}}W_Q,\quad K=H_{\text{src}}W_K,\quad V=H_{\text{src}}W_V\]</span>
<span displaypfx="" class="mathjax-container">\[\mathrm{CrossAttn}(H_{\text{tgt}},H_{\text{src}})=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V\]</span>
<p>它与自注意力（Self-Attention）的区别仅在于 <span displaypfx="inline-" class="mathjax-container">\(K,V\)</span> 来自“别的序列”，因此能把“源序列信息”按需注入到“目标序列生成”中。</p>
<p>在交叉编码器（Cross-Encoder）语境里，很多实现并不显式写 cross-attention：它们把两段文本拼接成一个序列，用全连接自注意力直接建模跨序列交互；从效果上看等价于“允许任意 token 互相注意”。</p>
<p>Decoder-only 架构本身没有 cross-attention 子层；只有在做 Seq2Seq（有 encoder 输出）或显式引入外部记忆（Memory）时，才会在解码器里加入 cross-attention。</p>
<div class="blog_h3"><span class="graybg">稀疏注意力与滑动窗口注意力</span></div>
<p>标准密集自注意力（Dense Self-Attention）会让每个位置与所有可见位置计算打分，因此在长度 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 上通常带来 <span displaypfx="inline-" class="mathjax-container">\(O(L^2)\)</span> 级别的注意力矩阵与计算压力。稀疏注意力（Sparse Attention）的核心思路，就是预先限制“每个 token 允许看哪些位置”，只保留一部分连接，从而把长上下文建模的代价降下来。</p>
<div class="blog_h4"><span class="graybg">稀疏注意力</span></div>
<p>稀疏注意力并不是单一算法，而是一类注意力连接模式的总称。它可以是局部窗口（Local Window）、块状稀疏（Block Sparse）、跨步连接（Strided Pattern）、少量全局 token（Global Tokens），也可以是这些模式的组合。Longformer、BigBird 这类长序列模型，都属于这条路线的经典代表。它的目标不是改变 softmax 注意力的基本定义，而是把原本“谁都能看谁”的全连接关系，改成一个更受约束的稀疏图。</p>
<p>从 2026 年的工程现实看，稀疏注意力仍然重要，但它已经不是通用旗舰语言模型的默认路线。它更常出现在长文档理解、超长上下文、显存/带宽受限，或专门强调长序列效率的模型中；而很多主流通用基座仍然更常采用密集因果注意力，再叠加 GQA、KV Cache、FlashAttention、KV 压缩等优化。这是因为稀疏模式虽然更省，但也会直接限制单层里可建立的依赖范围，训练与实现复杂度通常更高。</p>
<div class="blog_h4"><span class="graybg">滑动窗口注意力</span></div>
<p>滑动窗口注意力（Sliding Window Attention）是稀疏注意力里最常见、也最工程化的一种形式：位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 不是看全部历史，而只看距离自己最近的一段窗口，例如前面 <span displaypfx="inline-" class="mathjax-container">\(w\)</span> 个 token。这样单层注意力的代价就从“与整段长度线性增长的每行宽度”，压缩成“与固定窗口宽度相关”的局部计算。</p>
<p>它的优点是非常直接：局部模式、邻近依赖和短程语义通常仍能被稳定捕捉，而长上下文成本显著下降。代价是，两个相距很远的位置无法在同一层里直接交互，只能依靠多层传播，或额外引入全局层、全局 token、周期性全注意力层等机制来弥补。因此很多实际架构会采用“局部层 + 少量全局层”的混合设计，而不是把所有层都做成纯局部窗口。</p>
<p>到 2026 年，滑动窗口注意力仍然被部分主流模型持续使用，尤其是在长上下文或高性价比路线中；例如 Mistral 一类模型会显式采用 Sliding Window Attention，Gemma 2/3 一类模型也会在 local / global hybrid 结构中交替使用局部注意力层。但它并不是所有主流模型的统一默认配置。更准确的说法是：<span style="background-color: #c0c0c0;">通用“稀疏注意力”并非当代旗舰模型的普遍默认架构，而“滑动窗口注意力”则仍是今天主流工程实践里一条活跃的局部注意力路线</span>。</p>
<div class="blog_h3"><span class="graybg">KV Cache</span></div>
<p>KV Cache（Key-Value Cache）是自回归（Autoregressive）解码的关键工程优化：生成到第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步时，历史 token 的 Key/Value 已经在前序计算中得到；缓存它们可以避免每一步都重算整段历史的 K/V。</p>
<p>形式上，单层注意力在序列长度为 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 时需要缓存：</p>
<span displaypfx="" class="mathjax-container">\[K,V\in\mathbb{R}^{L\times n_{\text{kv}}\times d_k}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}\)</span> 是 KV 头数量（对标准多头注意力通常等于头数；对 GQA/MQA 通常更小），<span displaypfx="inline-" class="mathjax-container">\(d_k\)</span> 是每个 head 的维度。忽略实现细节（对齐、分块、paged layout）时，KV Cache 的显存规模近似线性增长：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Mem}_{\mathrm{KV}}\approx 2\cdot N_{\text{layers}}\cdot B\cdot L\cdot n_{\text{kv}}\cdot d_k\cdot \text{bytes}\]</span>
<p>这里前面的 2 来自同时缓存 K 与 V；<span displaypfx="inline-" class="mathjax-container">\(B\)</span> 是并发请求（batch）数；<span displaypfx="inline-" class="mathjax-container">\(\text{bytes}\)</span> 是每元素字节数（FP16/BF16 为 2）。因此 KV Cache 常成为长上下文与高并发推理的显存瓶颈。</p>
<p>KV Cache 的典型优化方向包括：</p>
<ul>
<li>减少 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}\)</span>，例如使用 GQA / MQA。</li>
<li>压缩 KV，例如 KV 量化、低秩表示、选择性缓存。</li>
<li>改进分配与复用，例如 Paged Attention、前缀缓存（Prompt Caching）。</li>
</ul>
<div class="blog_h3"><span class="graybg">FlashAttention</span></div>
<p>FlashAttention 是一种对标准注意力（Standard Attention）的高性能精确实现：它不改变 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{softmax}(QK^\top/\sqrt{d_k})V\)</span> 这个数学结果，而是通过分块（Tiling）、融合计算与在线 softmax，减少大规模中间矩阵在 HBM 与片上存储之间的来回搬运。因此，它首先是一种<span style="background-color: #c0c0c0;">注意力算子实现优化</span>，而不是新的模型结构。应用阶段上，训练与推理都可以使用 FlashAttention；在推理里，它最典型地加速的是预填充（Prefill）阶段，因为这时需要对整段输入做完整注意力计算，序列长、 <span displaypfx="inline-" class="mathjax-container">\(QK^\top\)</span> 代价高，FlashAttention 的收益最明显。到了逐 token 解码（Decode）阶段，单步 query 很短，瓶颈更常转向 KV Cache 读取、采样与调度，此时仍可使用面向解码优化的 Flash-Decoding / FlashAttention 变体，但收益模式已不同于预填充阶段。</p>
<p>从软件栈位置看，FlashAttention 可以放在<span style="background-color: #c0c0c0;">内核级别（Kernel-level）/ 后端级别（Backend-level）</span>来理解：上层框架仍然调用“注意力”这个算子，但底层并不一定走朴素的矩阵乘法 + softmax + 再乘 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 三步显式实现，而是改由高度融合的 GPU kernel 完成。是否真的启用 FlashAttention，取决于框架版本、后端实现、数据类型、head 维度、掩码形式以及硬件架构是否匹配。工程上常见支持平台是 NVIDIA 的 Ampere / Ada / Hopper，以及 AMD ROCm 生态中的部分高端 GPU；若硬件或后端条件不满足，框架通常会自动回退到 memory-efficient attention、cuDNN attention 或更普通的数学实现。</p>
<div class="blog_h4"><span class="graybg">为什么需要 FlashAttention</span></div>
<p>FlashAttention要解决的是标准注意力（Standard Attention）在长序列上的 <span style="background-color: #c0c0c0;">中间张量 IO 成本</span> 过高。标准缩放点积注意力（Scaled Dot-Product Attention）可写为：</p>
<span displaypfx="" class="mathjax-container">\[S=\frac{QK^\top}{\sqrt{d_k}},\quad P=\mathrm{softmax}(S),\quad O=PV\]</span>
<p>其中：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(Q\in\mathbb{R}^{N\times d_k}\)</span>：查询矩阵（Query Matrix），<span displaypfx="inline-" class="mathjax-container">\(N\)</span> 是序列长度， <span displaypfx="inline-" class="mathjax-container">\(d_k\)</span> 是每个 head 的查询/键维度。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(K\in\mathbb{R}^{N\times d_k}\)</span>：键矩阵（Key Matrix），与 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 做点积打分。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(V\in\mathbb{R}^{N\times d_v}\)</span>：值矩阵（Value Matrix），<span displaypfx="inline-" class="mathjax-container">\(d_v\)</span> 是每个 head 的值维度。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(S\in\mathbb{R}^{N\times N}\)</span>：注意力分数矩阵（Score Matrix），其中 <span displaypfx="inline-" class="mathjax-container">\(S_{ij}\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 query 对第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个 key 的未归一化打分。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(P\in\mathbb{R}^{N\times N}\)</span>：softmax 归一化后的注意力权重矩阵（Attention Probability Matrix）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(O\in\mathbb{R}^{N\times d_v}\)</span>：最终输出矩阵（Output Matrix）。</li>
</ul>
<p>问题集中在 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 和很多实现中的 <span displaypfx="inline-" class="mathjax-container">\(P\)</span>：它们都是 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span> 规模。序列一长，中间矩阵就会迅速膨胀。计算复杂度依然是 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(N^2)\)</span> 级别，但在 GPU 上更先撞上的往往不是算力上限，而是高带宽显存（High Bandwidth Memory, HBM）与片上共享内存 / SRAM（Static Random Access Memory, SRAM）之间的数据搬运成本。</p>
<p>传统实现通常经历三步：先算出整个 <span displaypfx="inline-" class="mathjax-container">\(S=QK^\top\)</span> 并写回显存；再把它读出来做 softmax，得到 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 并再次写回；最后再把 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 读出来与 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 相乘得到 <span displaypfx="inline-" class="mathjax-container">\(O\)</span>。这意味着真正拖慢速度的往往不是矩阵乘法本身，而是对 <span displaypfx="inline-" class="mathjax-container">\(N^2\)</span> 中间结果的反复显式物化（Materialization）与反复搬运。</p>
<p>一个直接类比是流水线工厂。普通注意力像“先把全部半成品都堆进仓库，再统一拿出来做下一道工序”；仓库本身就成了瓶颈。FlashAttention 则像“边加工边流转”的流水线：中间块只在车间里短暂停留，不建立巨大的中间仓库。</p>
<div class="blog_h4"><span class="graybg">核心思想</span></div>
<p>FlashAttention 的核心可以压缩成一句话：<span style="background-color: #c0c0c0;">分块（Tiling）+ 在线 softmax（Online Softmax）+ 融合输出（Fused Output Accumulation）</span>。</p>
<p>它并不改变注意力的数学目标，仍然精确计算同一个 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{softmax}(QK^\top/\sqrt{d_k})V\)</span>；它改变的是计算顺序与中间结果的存储方式。具体来说，FlashAttention 不再把整个 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span> 的注意力矩阵一次性算完并落到 HBM 中，而是把 <span displaypfx="inline-" class="mathjax-container">\(Q,K,V\)</span> 切成若干小块（tiles），每次只在 SRAM 中处理一小块分数、归一化和输出累加。</p>
<p>设查询块（query tile）为 <span displaypfx="inline-" class="mathjax-container">\(Q_i\in\mathbb{R}^{B_q\times d_k}\)</span>，键块和值块分别为 <span displaypfx="inline-" class="mathjax-container">\(K_j\in\mathbb{R}^{B_k\times d_k}\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(V_j\in\mathbb{R}^{B_k\times d_v}\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(B_q\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(B_k\)</span> 是 tile 大小，远小于完整序列长度 <span displaypfx="inline-" class="mathjax-container">\(N\)</span>。FlashAttention 每次只把这样的局部块搬进 SRAM，在块内完成当前 query tile 对当前 key/value tile 的全部贡献计算。</p>
<div class="blog_h4"><span class="graybg">数学本质：块级注意力与在线 softmax</span></div>
<p>对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 query 块和第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个 key/value 块，先计算块级分数矩阵：</p>
<span displaypfx="" class="mathjax-container">\[S_{ij}=\frac{Q_iK_j^\top}{\sqrt{d_k}},\qquad S_{ij}\in\mathbb{R}^{B_q\times B_k}\]</span>
<p>其中：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(S_{ij}\)</span>：当前块内的注意力打分矩阵。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(S_{ij}[r,c]\)</span>：query 块中第 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 行与 key 块中第 <span displaypfx="inline-" class="mathjax-container">\(c\)</span> 行的打分。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\sqrt{d_k}\)</span>：缩放因子，用于抑制点积随维度增长而导致的 softmax 饱和。</li>
</ul>
<p>难点在于 softmax 的分母依赖整行所有 key：对一个 query 而言，必须把它对所有位置的打分都考虑进去，才能完成归一化。FlashAttention 的关键突破是：不必先看到整行全部元素，再做 softmax；可以用在线算法维护“到目前为止的最大值、分母和分子累加量”，随着块不断读入而精确更新。</p>
<p>对当前 query 块 <span displaypfx="inline-" class="mathjax-container">\(Q_i\)</span>，FlashAttention 维护三个按行统计的状态：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{m}_i\in\mathbb{R}^{B_q},\qquad \boldsymbol{\ell}_i\in\mathbb{R}^{B_q},\qquad R_i\in\mathbb{R}^{B_q\times d_v}\]</span>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{m}_i\)</span>：每个 query 行到目前为止见过的最大分数（row-wise running max）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\boldsymbol{\ell}_i\)</span>：每个 query 行当前的 softmax 分母累加量。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(R_i\)</span>：每个 query 行对输出向量的未归一化加权和（unnormalized weighted sum）。</li>
</ul>
<p>初始时可设：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{m}_i=-\infty,\qquad \boldsymbol{\ell}_i=\mathbf{0},\qquad R_i=0\]</span>
<p>当读入第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个块时，先求该块每一行的局部最大值：</p>
<span displaypfx="" class="mathjax-container">\[\tilde{\mathbf{m}}_{ij}=\mathrm{rowmax}(S_{ij})\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rowmax}(\cdot)\)</span> 表示对矩阵每一行取最大值，因此输出是长度为 <span displaypfx="inline-" class="mathjax-container">\(B_q\)</span> 的向量。再把旧最大值和当前块最大值合并成新的全局参考点：</p>
<span displaypfx="" class="mathjax-container">\[\mathbf{m}_i^{\mathrm{new}}=\max\!\left(\mathbf{m}_i,\tilde{\mathbf{m}}_{ij}\right)\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\max\)</span> 是逐元素最大值（element-wise max），因为每个 query 行都维护自己的 softmax 参考值。</p>
<p>接着把当前块的指数项按新参考点重写：</p>
<span displaypfx="" class="mathjax-container">\[P_{ij}=\exp\!\left(S_{ij}-\mathbf{m}_i^{\mathrm{new}}\mathbf{1}^\top\right)\]</span>
<p>其中：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(P_{ij}\in\mathbb{R}^{B_q\times B_k}\)</span>：当前块中按新最大值平移后的指数权重。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}\in\mathbb{R}^{B_k}\)</span>：全 1 向量，用于把 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{m}_i^{\mathrm{new}}\)</span> 广播到块内每一列。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\exp(\cdot)\)</span>：逐元素指数函数。</li>
</ul>
<p>然后更新分母累加量：</p>
<span displaypfx="" class="mathjax-container">\[\boldsymbol{\ell}_i^{\mathrm{new}}=\exp\!\left(\mathbf{m}_i-\mathbf{m}_i^{\mathrm{new}}\right)\odot \boldsymbol{\ell}_i+\mathrm{rowsum}(P_{ij})\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\odot\)</span> 表示逐元素乘法， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{rowsum}(P_{ij})\)</span> 表示对 <span displaypfx="inline-" class="mathjax-container">\(P_{ij}\)</span> 每一行求和。这个式子的含义是：旧块已经累积的分母，先因为参考最大值改变而按 <span displaypfx="inline-" class="mathjax-container">\(\exp(\mathbf{m}_i-\mathbf{m}_i^{\mathrm{new}})\)</span> 重新缩放，再加上当前块的新贡献。</p>
<p>再更新输出分子的累加量：</p>
<span displaypfx="" class="mathjax-container">\[R_i^{\mathrm{new}}=\mathrm{Diag}\!\left(\exp\!\left(\mathbf{m}_i-\mathbf{m}_i^{\mathrm{new}}\right)\right)R_i+P_{ij}V_j\]</span>
<p>其中：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathrm{Diag}(\cdot)\)</span>：把向量放到对角线上形成对角矩阵，用于按行缩放 <span displaypfx="inline-" class="mathjax-container">\(R_i\)</span>。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(P_{ij}V_j\in\mathbb{R}^{B_q\times d_v}\)</span>：当前块对输出的新增贡献。</li>
</ul>
<p>所有 <span displaypfx="inline-" class="mathjax-container">\(K_j,V_j\)</span> 块处理完之后，当前 query 块的最终输出为：</p>
<span displaypfx="" class="mathjax-container">\[O_i=\mathrm{Diag}\!\left((\boldsymbol{\ell}_i)^{-1}\right)R_i\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\((\boldsymbol{\ell}_i)^{-1}\)</span> 表示对向量每个元素取倒数，作用是把“未归一化加权和”除以 softmax 分母，从而得到真正的注意力输出。</p>
<div class="blog_h4"><span class="graybg">为什么这仍然是精确 softmax</span></div>
<p>FlashAttention 的关键并不是“近似 softmax”，而是“换一种保持数值等价的累计方式”。设某一行已经处理过的旧分数集合为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{A}\)</span>，其旧最大值为 <span displaypfx="inline-" class="mathjax-container">\(m_{\mathrm{old}}\)</span>，旧分母为：</p>
<span displaypfx="" class="mathjax-container">\[\ell_{\mathrm{old}}=\sum_{x\in\mathcal{A}} e^{x-m_{\mathrm{old}}}\]</span>
<p>新读入一块分数集合 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{B}\)</span> 后，若新的全局最大值变成 <span displaypfx="inline-" class="mathjax-container">\(m_{\mathrm{new}}\)</span>，则旧部分相对于新参考点的分母贡献恰好变成：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{x\in\mathcal{A}} e^{x-m_{\mathrm{new}}}=e^{m_{\mathrm{old}}-m_{\mathrm{new}}}\sum_{x\in\mathcal{A}} e^{x-m_{\mathrm{old}}}=e^{m_{\mathrm{old}}-m_{\mathrm{new}}}\ell_{\mathrm{old}}\]</span>
<p>这正是在线更新公式里那一项缩放因子的来源。分子累加量 <span displaypfx="inline-" class="mathjax-container">\(R_i\)</span> 也是同样的道理：旧部分先按新参考点缩放，再加上新块贡献。因此块级处理结束后得到的 <span displaypfx="inline-" class="mathjax-container">\(O_i\)</span> 与一次性对整行做 softmax 再乘 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 的结果完全一致。</p>
<div class="blog_h4"><span class="graybg">与普通 Attention 的区别</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">维度</td>
<td style="text-align: center;">普通 Attention</td>
<td style="text-align: center;">FlashAttention</td>
</tr>
</thead>
<tbody>
<tr>
<td>数学目标</td>
<td>计算 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{softmax}(QK^\top/\sqrt{d_k})V\)</span></td>
<td>计算同一个精确结果，不改目标函数</td>
</tr>
<tr>
<td>中间矩阵</td>
<td>常显式存 <span displaypfx="inline-" class="mathjax-container">\(S\)</span>，很多实现还显式存 <span displaypfx="inline-" class="mathjax-container">\(P\)</span></td>
<td>不显式存完整 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span> 矩阵，只保留 tile 级临时块与行级累加状态</td>
</tr>
<tr>
<td>计算顺序</td>
<td>先全部算完分数，再整体 softmax，再乘 <span displaypfx="inline-" class="mathjax-container">\(V\)</span></td>
<td>边读块边更新 softmax，边把当前块对输出的贡献累加进去</td>
</tr>
<tr>
<td>显存特征</td>
<td>中间激活常呈 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(N^2)\)</span> 增长</td>
<td>额外中间存储近似降到 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(N)\)</span> 级别</td>
</tr>
<tr>
<td>性能瓶颈</td>
<td>更容易受 HBM 读写限制，属于强 memory-bound 场景</td>
<td>显著减少 HBM 往返，更接近 compute-bound</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">为什么它会快</span></div>
<p>FlashAttention 更快的根源并不是把 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(N^2)\)</span> 计算复杂度改成了更低阶，而是把 IO 模式改对了。它的核心收益主要来自四点：</p>
<ul>
<li>减少 HBM 访问：不再反复把 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 这类 <span displaypfx="inline-" class="mathjax-container">\(N^2\)</span> 中间张量写回、读回。</li>
<li>提升 SRAM 复用：一个 tile 被搬进片上后，会在同一块内连续完成分数计算、归一化和输出累加。</li>
<li>算子融合（Kernel Fusion）：原本分散的 <span displaypfx="inline-" class="mathjax-container">\(QK^\top\)</span>、softmax、<span displaypfx="inline-" class="mathjax-container">\(PV\)</span> 被压成一条更短的数据通路。</li>
<li>数值稳定：在线 softmax 仍然使用减最大值（max trick），避免指数溢出，也避免了“先大矩阵 softmax 再回写”带来的额外数值压力。</li>
</ul>
<p>因此，FlashAttention 的本质不是“更少的数学”，而是“更少的无效搬运”。从硬件视角看，它把一个明显受内存带宽制约的算子，改造成更能吃满矩阵乘法单元和 Tensor Core 的实现。</p>
<div class="blog_h4"><span class="graybg">复杂度与工程直觉</span></div>
<p>复杂度上需要严格区分“算了多少”和“存了多少”。FlashAttention 与普通注意力在算术复杂度上仍然同阶，因为每个 query 与每个 key 的交互并没有消失：</p>
<span displaypfx="" class="mathjax-container">\[\text{FLOPs: }\mathcal{O}(N^2d_k)\quad\text{vs.}\quad \mathcal{O}(N^2d_k)\]</span>
<p>但中间激活的显存复杂度发生了根本变化。若只看注意力算子额外需要保留的中间结果，则：</p>
<span displaypfx="" class="mathjax-container">\[\text{普通 Attention: }\mathcal{O}(N^2),\qquad \text{FlashAttention: }\mathcal{O}(N)\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(N)\)</span> 指的是按行维护的 softmax 统计量与输出累加量；tile 临时块的大小由 <span displaypfx="inline-" class="mathjax-container">\(B_q,B_k\)</span> 控制，不随完整序列平方增长。工程直觉可以概括为：<span style="background-color: #c0c0c0;">算力阶数没变，但仓库规模从平方级中间仓库变成了线性级流水线缓存</span>。</p>
<div class="blog_h4"><span class="graybg">一个极简伪代码</span></div>
<pre class="crayon-plain-tag">for each query tile Q_i:
    m_i = -inf
    l_i = 0
    R_i = 0
    for each key/value tile (K_j, V_j):
        S_ij = Q_i K_j^T / sqrt(d_k)
        m_new = max(m_i, rowmax(S_ij))
        P_ij = exp(S_ij - m_new)
        l_i = exp(m_i - m_new) * l_i + rowsum(P_ij)
        R_i = diag(exp(m_i - m_new)) * R_i + P_ij * V_j
        m_i = m_new
    O_i = diag(1 / l_i) * R_i</pre>
<p>这段伪代码对应的正是“边看块、边归一化、边输出”的流水线结构。与普通实现相比，最大的变化不是公式，而是调度顺序。</p>
<div class="blog_h4"><span class="graybg">反向传播（Backward）：为什么也能不存 <span displaypfx="inline-" class="mathjax-container">\(N^2\)</span></span></div>
<p>FlashAttention 的关键价值不仅在前向传播（Forward Pass），也在反向传播（Backward Pass）。训练时真正吃显存的不只是前向输出，还包括为了求梯度而保留的中间激活。如果 backward 仍然要求把完整的 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 存下来，那么前向省出来的显存会被反向阶段重新吃掉。因此，FlashAttention backward 的核心原则与前向一致：<span style="background-color: #c0c0c0;">不保存 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span> 注意力矩阵，而是在 backward 中按块重算（recompute）它们</span>。</p>
<p>设前向定义为：</p>
<span displaypfx="" class="mathjax-container">\[S=\frac{QK^\top}{\sqrt{d_k}},\qquad P=\mathrm{softmax}(S),\qquad O=PV\]</span>
<p>设损失函数为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span>，并记上游传回的输出梯度为：</p>
<span displaypfx="" class="mathjax-container">\[G=\frac{\partial \mathcal{L}}{\partial O},\qquad G\in\mathbb{R}^{N\times d_v}\]</span>
<p>这里：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}\)</span>：整个模型的标量损失（scalar loss）。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(G\)</span>：损失对注意力输出 <span displaypfx="inline-" class="mathjax-container">\(O\)</span> 的梯度，也就是 backward 进入注意力层时收到的上游信号。</li>
</ul>
<p>普通 attention 的 backward 可以按链式法则拆成四步。先对 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 求梯度：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial V}=P^\top G\]</span>
<p>这个式子表示：某个 value 向量 <span displaypfx="inline-" class="mathjax-container">\(v_j\)</span> 对多少个 query 产生了贡献，就会按相应注意力权重 <span displaypfx="inline-" class="mathjax-container">\(P_{ij}\)</span> 把这些上游梯度累加回来。</p>
<p>再对概率矩阵 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 求梯度：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial P}=GV^\top\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial P}\in\mathbb{R}^{N\times N}\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 项表示：若第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 列的注意力权重略有变化，会怎样影响损失。</p>
<p>关键一步是 softmax 的梯度。对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行，记 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span> 为第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行概率向量， <span displaypfx="inline-" class="mathjax-container">\(g_i^P\)</span> 为 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial P}\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行，则：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial s_i}=p_i\odot \left(g_i^P-\delta_i\mathbf{1}\right),\qquad \delta_i=\sum_{j=1}^{N} g_{ij}^P p_{ij}\]</span>
<p>其中：</p>
<ul>
<li><span displaypfx="inline-" class="mathjax-container">\(s_i\)</span>：分数矩阵 <span displaypfx="inline-" class="mathjax-container">\(S\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\odot\)</span>：逐元素乘法。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\delta_i\)</span>：第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行 softmax Jacobian 压缩后的标量项，用来扣掉“整行归一化”带来的耦合影响。</li>
<li><span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}\in\mathbb{R}^{N}\)</span>：全 1 向量。</li>
</ul>
<p>把所有行拼起来，可写成矩阵形式：</p>
<span displaypfx="" class="mathjax-container">\[D=\mathrm{rowsum}\!\left(\frac{\partial \mathcal{L}}{\partial P}\odot P\right),\qquad \frac{\partial \mathcal{L}}{\partial S}=P\odot \left(\frac{\partial \mathcal{L}}{\partial P}-D\mathbf{1}^\top\right)\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(D\in\mathbb{R}^{N}\)</span> 是逐行标量向量，第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量就是 <span displaypfx="inline-" class="mathjax-container">\(\delta_i\)</span>。最后再通过 <span displaypfx="inline-" class="mathjax-container">\(S=QK^\top/\sqrt{d_k}\)</span> 回传到 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(K\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial Q}=\frac{\partial \mathcal{L}}{\partial S}\frac{K}{\sqrt{d_k}},\qquad \frac{\partial \mathcal{L}}{\partial K}=\left(\frac{\partial \mathcal{L}}{\partial S}\right)^\top\frac{Q}{\sqrt{d_k}}\]</span>
<p>若直接照这些公式实现，最大问题是：看起来必须先拿到完整的 <span displaypfx="inline-" class="mathjax-container">\(P\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial P}\)</span>，而它们又都是 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span>。FlashAttention backward 的突破在于，真正必须永久保存的量远比这少。</p>
<p>第一，前向阶段只需保存每一行的 log-sum-exp 统计量（Log-Sum-Exp Statistics），而不必保存整张 <span displaypfx="inline-" class="mathjax-container">\(P\)</span>。若前向某一行的最大值为 <span displaypfx="inline-" class="mathjax-container">\(m_i\)</span>，归一化因子为 <span displaypfx="inline-" class="mathjax-container">\(\ell_i\)</span>，则可存：</p>
<span displaypfx="" class="mathjax-container">\[L_i=m_i+\log \ell_i=\log\sum_{j=1}^{N} e^{S_{ij}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(L_i\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行 softmax 分母的对数。只要 backward 时重新算出某个块的分数 <span displaypfx="inline-" class="mathjax-container">\(S_{ij}\)</span>，就可以把该块的概率精确重建为：</p>
<span displaypfx="" class="mathjax-container">\[P_{ij}=\exp\!\left(S_{ij}-L_i\mathbf{1}^\top\right)\]</span>
<p>这说明 backward 不需要读取前向保存下来的整张 <span displaypfx="inline-" class="mathjax-container">\(P\)</span>；它只需要 <span displaypfx="inline-" class="mathjax-container">\(Q\)</span>、<span displaypfx="inline-" class="mathjax-container">\(K\)</span>、行级统计量 <span displaypfx="inline-" class="mathjax-container">\(L_i\)</span>，就能按块把局部概率重新算出来。</p>
<p>第二，softmax backward 中的行级标量 <span displaypfx="inline-" class="mathjax-container">\(\delta_i\)</span> 也可以不通过整张 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial P}\)</span> 显式求和，而是用一个更紧凑的等价式：</p>
<span displaypfx="" class="mathjax-container">\[\delta_i=\sum_{j=1}^{N} g_{ij}^P p_{ij}=g_i^\top o_i\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(g_i\)</span> 是上游梯度矩阵 <span displaypfx="inline-" class="mathjax-container">\(G\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行， <span displaypfx="inline-" class="mathjax-container">\(o_i\)</span> 是前向输出 <span displaypfx="inline-" class="mathjax-container">\(O\)</span> 的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 行。这个恒等式来自：</p>
<span displaypfx="" class="mathjax-container">\[g_i^P=g_iV^\top,\qquad o_i=p_iV\]</span>
<p>于是：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{j=1}^{N} g_{ij}^P p_{ij}=\sum_{j=1}^{N}(g_i v_j^\top)p_{ij}=g_i\left(\sum_{j=1}^{N}p_{ij}v_j\right)^\top=g_i o_i^\top\]</span>
<p>这一步非常关键，因为它说明 softmax backward 所需的行级校正项 <span displaypfx="inline-" class="mathjax-container">\(\delta_i\)</span>，可以直接由前向输出 <span displaypfx="inline-" class="mathjax-container">\(O\)</span> 和上游梯度 <span displaypfx="inline-" class="mathjax-container">\(G\)</span> 得到，而不需要显式展开整个 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span> 概率矩阵。</p>
<p>因此，FlashAttention backward 的块级流程可以概括为：</p>
<ol>
<li>读取一个 query tile <span displaypfx="inline-" class="mathjax-container">\(Q_i\)</span>、对应输出 tile <span displaypfx="inline-" class="mathjax-container">\(O_i\)</span>、上游梯度 tile <span displaypfx="inline-" class="mathjax-container">\(G_i\)</span>，以及该 tile 的行级统计量 <span displaypfx="inline-" class="mathjax-container">\(L_i\)</span>。</li>
<li>逐块读取 <span displaypfx="inline-" class="mathjax-container">\(K_j,V_j\)</span>，重算当前块分数 <span displaypfx="inline-" class="mathjax-container">\(S_{ij}=Q_iK_j^\top/\sqrt{d_k}\)</span>。</li>
<li>由 <span displaypfx="inline-" class="mathjax-container">\(L_i\)</span> 重建当前块概率 <span displaypfx="inline-" class="mathjax-container">\(P_{ij}=\exp(S_{ij}-L_i\mathbf{1}^\top)\)</span>。</li>
<li>用 <span displaypfx="inline-" class="mathjax-container">\(G_iV_j^\top\)</span> 得到当前块的 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial P_{ij}}\)</span>，再结合 <span displaypfx="inline-" class="mathjax-container">\(\delta_i=g_i^\top o_i\)</span> 计算当前块的 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial S_{ij}}\)</span>。</li>
<li>把该块对 <span displaypfx="inline-" class="mathjax-container">\(dQ_i\)</span>、<span displaypfx="inline-" class="mathjax-container">\(dK_j\)</span>、<span displaypfx="inline-" class="mathjax-container">\(dV_j\)</span> 的贡献直接累加到输出梯度中。</li>
</ol>
<p>写成块级公式，就是：</p>
<span displaypfx="" class="mathjax-container">\[dV_j \mathrel{+}= P_{ij}^\top G_i\]</span>
<span displaypfx="" class="mathjax-container">\[dP_{ij}=G_iV_j^\top\]</span>
<span displaypfx="" class="mathjax-container">\[dS_{ij}=P_{ij}\odot \left(dP_{ij}-\delta_i\mathbf{1}^\top\right)\]</span>
<span displaypfx="" class="mathjax-container">\[dQ_i \mathrel{+}= dS_{ij}\frac{K_j}{\sqrt{d_k}},\qquad dK_j \mathrel{+}= dS_{ij}^\top\frac{Q_i}{\sqrt{d_k}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(dQ_i,dK_j,dV_j\)</span> 分别表示当前块对 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial Q}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial K}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial V}\)</span> 的局部累加贡献；符号 <span displaypfx="inline-" class="mathjax-container">\(\mathrel{+}=\)</span> 表示“把当前块的贡献继续累加到已有梯度里”，而不是一次性覆盖赋值。</p>
<p>这个设计的代价是：backward 需要重算部分前向中的块级分数和概率，因此算术量会比“全存中间矩阵”的朴素实现略多；但现代 GPU 上，额外矩阵乘法通常比反复读写 <span displaypfx="inline-" class="mathjax-container">\(N^2\)</span> HBM 张量便宜得多。于是 FlashAttention backward 的工程哲学可以概括为：<span style="background-color: #c0c0c0;">用少量重算换大幅节省显存与 IO</span>。</p>
<p>一个直观类比是：普通 backward 像把前向每一道工序的全部半成品都堆满仓库，等回头算梯度时再逐件取出来；FlashAttention backward 则更像保留每条流水线的关键账本和最终产物，真正需要某段中间细节时，再按原流程快速重演一小段。仓库变小了，流水线也更连贯。</p>
<div class="blog_h4"><span class="graybg">FlashAttention v1：算法层优化</span></div>
<p>FlashAttention v1 的核心贡献在于算法层：它首先把“注意力必须显式存下 <span displaypfx="inline-" class="mathjax-container">\(N\times N\)</span> 矩阵”这一默认前提打破，给出了一种精确、稳定、块级流式的注意力实现。v1 的关键词是 <span style="background-color: #c0c0c0;">memory optimization</span>：让注意力从“被中间矩阵拖慢”转向“更像一个流式矩阵核”。</p>
<p>在这个阶段，最重要的不是把 GPU 跑满，而是先证明：不物化注意力矩阵，仍然可以精确完成前向与反向计算，并把显存墙显著后移。它解决的是“能不能这样算”的问题。</p>
<div class="blog_h4"><span class="graybg">FlashAttention v2：并行层优化</span></div>
<p>FlashAttention v2 保留了 v1 的数学等价性与在线 softmax 思路，但把优化重点从“省内存”推进到“把 GPU 吃满”。它关注的是并行工作划分（Work Partitioning）：如何把 query 块、head 维度、batch 维度和线程块（Thread Block）组织得更均匀，让更多流式多处理器（Streaming Multiprocessor, SM）同时处于忙碌状态。</p>
<p>v1 的一个现实限制是：虽然显存访问已经大幅减少，但某些场景下并行粒度仍然偏粗，导致 GPU 占用率（Occupancy）不够高。v2 因此重写了 kernel 调度策略，让同一个大任务能够拆给更多线程块并行处理，同时尽量减少线程同步（Synchronization）带来的停顿。</p>
<p>从本质上看，v2 做的不是“新的注意力公式”，而是“同一公式在 GPU 上的更优任务分发”。如果说 v1 的问题是“别把中间矩阵落盘”，那么 v2 的问题就是“别让 GPU 的很多 SM 闲着”。</p>
<p>这也是 v2 在反向传播（Backward Pass）上价值很高的原因。前向只解决一半问题；训练吞吐还取决于 backward kernel 能否在不恢复 <span displaypfx="inline-" class="mathjax-container">\(N^2\)</span> 显存占用的前提下保持高并行度。v2 在这一点上比 v1 更成熟，因此更适合作为训练时的高性能默认实现。</p>
<div class="blog_h4"><span class="graybg">FlashAttention v3：硬件协同优化</span></div>
<p>FlashAttention v3 的重点进一步从并行层推进到硬件协同设计（Hardware Co-design），尤其针对 NVIDIA Hopper / H100 这类新一代 GPU。它不再只关心“块怎么切、线程怎么分”，而是进一步追问：<span style="background-color: #c0c0c0;">数据加载、矩阵计算、结果写回能否形成异步流水线</span>。</p>
<p>v3 的几个代表性关键词包括：</p>
<ul>
<li>异步流水线（Asynchronous Pipeline）：加载下一块数据时，当前块已经在计算，从而重叠 load 与 compute。</li>
<li>Warp 专职分工（Warp Specialization）：不同 warp 分别负责搬运、计算、写回，减少彼此等待。</li>
<li>Tensor Core 深度利用：tile 尺寸与数据流更贴近 Tensor Core 最擅长的矩阵乘法路径。</li>
<li>更适合低精度数据类型：如 FP16、BF16，以及面向新硬件的 FP8 路径。</li>
</ul>
<p>如果把 v1 看成“算法上不建大仓库”，把 v2 看成“让更多工人同时开工”，那么 v3 更像“把整座工厂变成不停顿的装配线”：搬运、计算、写回三条流水同时进行，尽量让每一级硬件资源都不空转。</p>
<div class="blog_h4"><span class="graybg">版本演进总结</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">版本</td>
<td style="text-align: center;">主要优化层次</td>
<td style="text-align: center;">核心目标</td>
<td style="text-align: center;">本质关键词</td>
</tr>
</thead>
<tbody>
<tr>
<td>v1</td>
<td>算法层</td>
<td>避免 <span displaypfx="inline-" class="mathjax-container">\(N^2\)</span> 中间矩阵物化</td>
<td>分块、在线 softmax、融合计算</td>
</tr>
<tr>
<td>v2</td>
<td>并行层</td>
<td>提高 Occupancy，减少同步，提升训练吞吐</td>
<td>更细粒度 work partitioning</td>
</tr>
<tr>
<td>v3</td>
<td>硬件层</td>
<td>让 load / compute / store 深度重叠</td>
<td>异步流水线、warp specialization、Tensor Core 对齐</td>
</tr>
</tbody>
</table>
<p>因此，FlashAttention 的演进可以概括为三层推进：v1 解决“能否不存矩阵”、v2 解决“如何把 GPU 跑满”、v3 解决“如何贴着新硬件的数据通路跑”。三代版本的数学目标完全一致，差异主要体现在实现层面对 IO、并行性与硬件流水的挖掘深度。</p>
<div class="blog_h3"><span class="graybg">线性注意力（Linear Attention）</span></div>
<p>线性注意力（Linear Attention）不是“更快的实现”，而是对注意力公式做近似/改写，把复杂度从 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(L^2)\)</span> 降到近似 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(L)\)</span>。一类常见思路是用核函数特征映射（Kernel Feature Map）近似 softmax kernel：把 <span displaypfx="inline-" class="mathjax-container">\(\exp(q^\top k)\)</span> 写成 <span displaypfx="inline-" class="mathjax-container">\(\phi(q)^\top \phi(k)\)</span>，从而把“先算 <span displaypfx="inline-" class="mathjax-container">\(QK^\top\)</span> 再乘 <span displaypfx="inline-" class="mathjax-container">\(V\)</span>”改写为“先聚合 <span displaypfx="inline-" class="mathjax-container">\(\phi(K)\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 再与 <span displaypfx="inline-" class="mathjax-container">\(\phi(Q)\)</span> 交互”。</p>
<p>一个典型形式（省略实现细节）是：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Attn}(Q,K,V)\approx \frac{\phi(Q)\big(\phi(K)^\top V\big)}{\phi(Q)\big(\phi(K)^\top \mathbf{1}\big)}\]</span>
<p>线性注意力更适合“极长序列下的吞吐/显存”目标，但它往往需要在表示、数值稳定性与效果之间做取舍；在通用 LLM 上，主流路径仍然是“精确注意力 + 更好的内核 + 更强的位置/缓存工程”，线性注意力更多作为特定场景或混合架构的选项。</p>
<div class="blog_h3"><span class="graybg">状态空间模型（State Space Model, SSM / Mamba）</span></div>
<p>状态空间模型（State Space Model, SSM）用“隐状态递推（State Recurrence）”建模序列：每步用一个小状态 <span displaypfx="inline-" class="mathjax-container">\(s_t\)</span> 累积历史信息，避免显式构造 <span displaypfx="inline-" class="mathjax-container">\(L\times L\)</span> 注意力矩阵。经典线性 SSM 的抽象形式是：</p>
<span displaypfx="" class="mathjax-container">\[s_{t+1}=As_t+Bx_t,\quad y_t=Cs_t+Dx_t\]</span>
<p>近年的 Mamba 等结构可理解为在此基础上引入输入依赖的选择性/门控机制（Selective / Input-dependent Dynamics），使得模型在保持线性复杂度的同时具备更强的表征能力。工程上，SSM 的优势通常体现在长序列吞吐与显存；代价是“按内容随机访问历史”的能力不如注意力直观，因此在需要强检索/对齐的任务上常见的是混合架构或与注意力模块组合使用。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">路线</td>
<td style="text-align: center;">序列复杂度</td>
<td style="text-align: center;">显存瓶颈</td>
<td style="text-align: center;">强项</td>
<td style="text-align: center;">典型代价</td>
</tr>
</thead>
<tbody>
<tr>
<td>精确注意力（FlashAttention 等）</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(L^2)\)</span></td>
<td>注意力中间张量 + KV Cache</td>
<td>强检索/对齐；通用能力稳健</td>
<td>长上下文成本陡增；需要大量工程优化（GQA/分页/缓存）</td>
</tr>
<tr>
<td>线性注意力</td>
<td>近似 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(L)\)</span></td>
<td>缓存布局与数值稳定性</td>
<td>极长序列吞吐/显存友好</td>
<td>近似误差；需要专门核函数/特征映射设计</td>
</tr>
<tr>
<td>SSM / Mamba</td>
<td><span displaypfx="inline-" class="mathjax-container">\(\mathcal{O}(L)\)</span></td>
<td>状态与算子实现</td>
<td>长序列吞吐；流式友好</td>
<td>随机访问历史不如注意力直观；常需混合架构补齐能力</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">GQA（Grouped Query Attention）</span></div>
<p>GQA（Grouped Query Attention）用“更少的 KV 头”服务“更多的 Query 头”：多个 Query heads 共享同一组 Key/Value heads，从而显著降低 KV Cache 的显存与带宽压力。极端情形 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}=1\)</span> 称为 MQA（Multi-Query Attention）。</p>
<p>对比标准多头注意力（MHA）：MHA 通常 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}=n_q\)</span>；而 GQA 让 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}\ll n_q\)</span>，注意力仍然按 head 计算，但 KV 表示被“组共享”。在长上下文推理中，它带来的收益往往比对算力的节省更关键：KV Cache 与内存带宽近似按 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}/n_q\)</span> 比例下降。</p>
<p>代价是表示自由度下降：不同 Query heads 看到的 Key/Value 空间更相似，可能带来一定质量损失；工程上通常通过更大的模型维度、更多 Query heads、或更强的 FFN 来补偿。</p>
<p>进一步降低 KV Cache 成本的路线，还包括 Latent KV / MLA 一类潜空间压缩，以及 TurboQuant 一类面向内积保真的 KV 量化压缩。它们的直接优化对象都是长上下文推理里的 KV Cache 存储与带宽，因此更适合放在后文“推理阶段优化”中统一讨论。</p>
<div class="blog_h2"><span class="graybg">前馈网络（FFN）</span></div>
<div class="blog_h3"><span class="graybg">MLP</span></div>
<p>Transformer 层里的前馈网络（Feed-Forward Network, FFN）本质上就是一个位置前馈（Position-wise）MLP：它对每个 token 的向量独立作用，不在序列维度做混合（序列维度的混合由注意力完成）。典型形式是两层线性变换加非线性：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{FFN}(x)=\sigma(xW_1+b_1)W_2+b_2\]</span>
<p>这里为了贴近工程实现，把单个 token 表示写成行向量 <span displaypfx="inline-" class="mathjax-container">\(x\in\mathbb{R}^{1\;\times d_{\text{model}}}\)</span>。若中间宽度为 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{ff}}\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(W_1\in\mathbb{R}^{d_{\text{model}}\;\times d_{\text{ff}}}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(W_2\in\mathbb{R}^{d_{\text{ff}}\;\times d_{\text{model}}}\)</span>，因此这层先把 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 维表示升到更宽的 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{ff}}\)</span> 维，再投回原宽度。很多教材把它叫“MLP 模块”，强调的是它在每层里与注意力并列构成 Transformer block 的两大子层。</p>
<div class="blog_h4"><span class="graybg">经典 FFN：先升维，再降维</span></div>
<p>“升维（Up-Projection）”不是补零，也不是把旧特征简单复制几份，而是让每一个新维度都成为输入向量的一种线性组合。若</p>
<span displaypfx="" class="mathjax-container">\[h_{\text{up}}=xW_1+b_1\in\mathbb{R}^{1\;\times d_{\text{ff}}}\]</span>
<p>则第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个中间维度满足</p>
<span displaypfx="" class="mathjax-container">\[(h_{\text{up}})_j=\sum_{i=1}^{d_{\text{model}}}x_i(W_1)_{ij}+b_{1j}\]</span>
<p>这意味着：升出来的每一维都在重新组合输入特征。有些维度更像“检测某种局部模式”，有些维度更像“混合多种语义线索”，中间宽度越大，可供模型学习的组合方式就越多。随后，非线性函数 <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span>（常见如 GELU / ReLU）对这些组合结果做逐元素变换，把线性组合提升为非线性特征。</p>
<p>“降维（Down-Projection）”也不是简单删掉多余维度，而是再做一次线性组合：</p>
<span displaypfx="" class="mathjax-container">\[h_{\text{down}}=\sigma(h_{\text{up}})W_2+b_2\in\mathbb{R}^{1\;\times d_{\text{model}}}\]</span>
<p>因此，经典 FFN 的结构可以概括成：<span style="background-color: #c0c0c0;">先在更宽的特征空间里生成大量候选特征，再把有用的那部分重新组合回模型主宽度</span>。这就是“升维—非线性—降维”的真正含义。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/ffn-glu-structure-canvas.png"><img class="alignnone size-full wp-image-41679" src="https://blog.gmem.cc/wp-content/uploads/2026/03/ffn-glu-structure-canvas.png" alt="ffn-glu-structure-canvas" width="1920" height="1080" /></a></p>
<div class="blog_h4"><span class="graybg">门控 FFN：以 SwiGLU 为例</span></div>
<p>很多现代大模型会用门控线性单元（Gated Linear Unit, GLU）的变体替代“Linear → 激活 → Linear”的经典 FFN，例如 SwiGLU（Swish-Gated Linear Unit）：把中间层拆成两路并行投影，再做逐元素门控：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{SwiGLU}(x)=\Big(\mathrm{SiLU}(xW_1)\odot (xW_3)\Big)W_2\]</span>
<p>若仍按行向量写法，则 <span displaypfx="inline-" class="mathjax-container">\(W_1,W_3\in\mathbb{R}^{d_{\text{model}}\;\times d_{\text{ff}}}\)</span>， <span displaypfx="inline-" class="mathjax-container">\(W_2\in\mathbb{R}^{d_{\text{ff}}\;\times d_{\text{model}}}\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(W_1\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(W_3\)</span> 都是“升维投影”，但角色不同： <span displaypfx="inline-" class="mathjax-container">\(xW_1\)</span> 经过 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{SiLU}\)</span> 后形成门控分支（gate branch），决定每个中间维度应当放大、通过还是抑制； <span displaypfx="inline-" class="mathjax-container">\(xW_3\)</span> 则形成内容分支（value / candidate branch），携带候选特征本身。两者做逐元素乘法 <span displaypfx="inline-" class="mathjax-container">\(\odot\)</span> 后，得到“被门控筛选过的中间表示”，最后再由 <span displaypfx="inline-" class="mathjax-container">\(W_2\)</span> 投回 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span>。</p>
<p>因此， <span displaypfx="inline-" class="mathjax-container">\(W_3\)</span> 不是额外多出来的“神秘矩阵”，而是门控 FFN 里的第二条并行升维支路。没有它，模型只有“激活后的门”，却没有“真正被门控制的候选内容”；有了它，FFN 才能表达“哪些特征值得通过、哪些特征应被压制”这一层选择机制。</p>
<p>这种“哪些维度打开、哪些维度抑制”的规则并不是人工写死的，而是通过训练从数据中学出来的。对第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个中间维度，门控值可以写成 <span displaypfx="inline-" class="mathjax-container">\(g_j=\mathrm{SiLU}((xW_1)_j)\)</span>，候选内容写成 <span displaypfx="inline-" class="mathjax-container">\(c_j=(xW_3)_j\)</span>，二者相乘后该维输出为 <span displaypfx="inline-" class="mathjax-container">\(m_j=g_j c_j\)</span>。若某类输入模式下，让这个维度更大能够降低最终损失，则反向传播会推动 <span displaypfx="inline-" class="mathjax-container">\(W_1\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(W_3\)</span> 把对应的 <span displaypfx="inline-" class="mathjax-container">\(g_j\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(c_j\)</span> 调到更有利的方向；若某个维度会带来噪声、干扰或错误特征，则梯度会推动该维在这类输入上变小，于是门控值逐渐靠近 0，内容即使存在也难以通过。</p>
<p>因此，门控学习到的不是一个离散的“开 / 关开关”，而是一组随输入变化的连续缩放系数。某些维度在数学推理样本上可能长期被放大，在闲聊样本上则被压弱；某些维度对代码括号、缩进、关键字组合更敏感，另一些维度则更偏向实体关系或长距离语义线索。门控 FFN 的本质，是让模型在更宽的中间空间里先生成大量候选特征，再由可学习的输入相关门控决定哪些特征应该被保留、哪些应被抑制。</p>
<p>门控（Gating）让 FFN 具备“按特征选择通过 / 抑制”的能力，在相近参数规模下常带来更好的效果与训练稳定性。与经典两层 FFN 相比，门控 FFN 并不是单纯“多一层”，而是把中间表示拆成“控制信号”和“候选内容”两路，再在中间宽空间里完成细粒度筛选。</p>
<p>从能力角度看，增大 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{ff}}\)</span> 会增加中间表征的自由度（Degree of Freedom, DOF）与参数量，使模型能构造更丰富的非线性特征；但“维度更高”不等于“信息一定更多”，它提供的是可学习的表示空间与容量（Capacity），是否有效取决于数据与训练目标。</p>
<div class="blog_h3"><span class="graybg">Mixture of Experts（MoE）</span></div>
<p>MoE（Mixture of Experts）把 FFN 子层替换成“多个专家网络（Experts）+ 路由器（Router/Gate）”：对每个 token，路由器只激活少数几个专家（Top-k），因此计算量近似不随专家总数线性增长，但参数容量可以大幅增加。</p>
<p>一种常见形式（概念表达）是：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{MoE}(x)=\sum_{e\in\mathrm{TopK}(x)} p_e(x)\,\mathrm{Expert}_e(x),\quad p(x)=\mathrm{softmax}(W_g x)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(p_e(x)\)</span> 是路由概率，专家通常就是不同参数的 FFN。与稠密 FFN 的区别在于：稠密 FFN 对每个 token 都执行同一套参数；MoE 则先由路由器决定“这个 token 该送去哪些专家”，再只计算被选中的少数几个专家。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/moe.png"><img class="alignnone size-full wp-image-41675" src="https://blog.gmem.cc/wp-content/uploads/2026/03/moe.png" alt="moe" width="1024" height="1024" /></a></p>
<p>专家的差异化（Specialization）来源于<span style="background-color: #c0c0c0;">路由选择、梯度暴露和训练约束共同塑造的长期分工</span>。随机初始化只负责打破完全对称；真正让专家“越学越不一样”的，是后续每个专家持续处理不同 token 子分布，并在这些子分布上反复累积参数更新。</p>
<p>这个过程通常由以下几类机制共同推动：</p>
<ul>
<li><span style="background-color: #c0c0c0;">稀疏路由</span>：每个 token 前向时通常只进入 top-k 个专家，因此反向传播时，也只有被选中的专家接收到该 token 的主要梯度。不同专家长期看到的训练样本分布因此不再相同，参数更新方向也随之分化。</li>
<li><span style="background-color: #c0c0c0;">路由—能力自增强</span>：路由器先按当前表示给专家打分；某个专家一旦更常处理一类模式，就会在这类模式上进一步拟合得更好；下一轮遇到相似 token 时，路由器又更容易把它们送回这个专家。久而久之，专家会演化成代码型、数学型、长句法型或领域词汇型等不同处理器。</li>
<li><span style="background-color: #c0c0c0;">负载均衡损失</span>：若完全放任训练，路由器容易把大量 token 都送往少数“热门专家”，其余专家几乎得不到梯度。负载均衡（Load Balancing）辅助损失会惩罚这种失衡，推动更多专家获得稳定训练信号，从而保留分工空间，而不是塌缩成少数几个超忙专家。</li>
<li><span style="background-color: #c0c0c0;">容量限制</span>：工程实现常给每个专家设置每个 batch 最多接收多少 token 的上限。热门专家一旦满载，后续 token 就必须改道到其他专家。这相当于在训练期强行制造“分流”，避免所有高频模式都被同一专家垄断。</li>
<li><span style="background-color: #c0c0c0;">路由噪声与探索</span>：训练早期常在路由分数上加入噪声（Noisy Gating / Jitter）或采用更平滑的选择策略，使模型不会过早把某些专家永久冷启动掉。它的作用类似探索机制：先让更多专家接触不同 token，后续再由训练结果把分工逐步固化。</li>
<li><span style="background-color: #c0c0c0;">Top-k 竞争结构</span>：当多个专家为同一 token 竞争有限的 top-k 名额时，路由器天然在做离散化分配。专家之间并不是同时都拿到完整梯度，而是在竞争中各自吸附不同区域的输入分布。这比稠密加权平均更容易形成明确边界。</li>
<li><span style="background-color: #c0c0c0;">专家参数独立</span>：每个专家有自己独立的 FFN 权重，因此一旦早期路由稍有偏向，后续参数更新就会沿不同轨迹不断放大差异。若专家共享大部分参数，仅保留极少差异分支，则这种专门化能力会明显减弱。</li>
<li><span style="background-color: #c0c0c0;">数据分布本身的可分性</span>：训练语料若天然包含代码、自然语言、表格、数学推导、多语种等明显子分布，专家更容易形成稳定分工；若数据分布高度均匀、模式差异很弱，则专家专门化也会更弱，更接近“多份相似 FFN”。</li>
</ul>
<p>这些机制叠加后，MoE 中“每个专家学不同东西”就不再只是参数副本的偶然漂移，而是带有明确结构约束的分工过程。与多头注意力主要依赖独立参数的自发分化不同，MoE 额外利用<span style="background-color: #c0c0c0;">显式路由、稀疏梯度、负载约束与容量分流</span>来持续放大专家之间的功能差异。</p>
<p>MoE 结构本身不必然引入随机性。若路由使用确定性的 top-k，且推理使用确定性算子，则同一输入在同一权重下输出应是确定的。训练阶段常见的随机性主要来自 dropout、路由噪声（Noisy Gating）以及硬件/并行计算的非确定性；这些会影响训练轨迹，但不等价于“模型本质随机”。</p>
<div class="blog_h2"><span class="graybg">归一化</span></div>
<div class="blog_h3"><span class="graybg">Layer Normalization</span></div>
<p>层归一化（Layer Normalization, LayerNorm）在每个 token 的特征维度上做归一化（Normalization），与 BatchNorm 不同，它不依赖 batch 统计量，因此更适合变长序列与自回归推理。对向量 <span displaypfx="inline-" class="mathjax-container">\(x\in\mathbb{R}^{d_{\text{model}}}\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{LN}(x)=\gamma\odot \frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}+\beta,\quad \mu=\frac{1}{d}\sum_{i=1}^{d}x_i,\ \sigma^2=\frac{1}{d}\sum_{i=1}^{d}(x_i-\mu)^2\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\gamma,\beta\)</span> 是可学习的缩放与平移参数（Learnable Scale/Shift），<span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 是一个很小的正数，用于数值稳定性（Numerical Stability）。归一化本质上要除以标准差或均方根；若当前 token 的各维几乎相同，分母就可能非常接近 0，进而导致输出或梯度被异常放大，甚至出现 NaN / Inf。加入 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 相当于给分母设置一个下界。它通常很小，只在“方差或 RMS 过小”时介入；若 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 取值过大，则会把分母中的真实尺度差异压平，使归一化变弱，模型对幅值变化的敏感度下降。</p>
<p>当前主流的 Transformer 几乎都采用“token 内部归一化”，而不是跨 batch 的 BatchNorm：经典 Transformer、BERT、ViT 这一路架构以 LayerNorm 为主；许多更新的 Decoder-only 大模型则把每个残差块写成 Pre-Norm 结构，并进一步用 RMSNorm 取代标准 LayerNorm。</p>
<p>Pre-LN（Pre-LayerNorm）指先做归一化，再进入 Attention 或 MLP 子层，最后与残差分支相加；其典型形式可写为：</p>
<span displaypfx="" class="mathjax-container">\[y=x+\mathrm{Sublayer}(\mathrm{LN}(x))\]</span>
<p>这种写法把归一化放进残差支路内部，有利于维持深层网络中的梯度流稳定。结合后文残差连接的分析来看，Pre-LN 的一个直接优势是：梯度更容易沿着 <span displaypfx="inline-" class="mathjax-container">\(x\to x+\cdots\)</span> 这条恒等主路向后传播，而不会在进入子层之前就先经历一次“相加后再归一化”的整体重标定。与之对应，Post-LN 会写成 <span displaypfx="inline-" class="mathjax-container">\(y=\mathrm{LN}(x+\mathrm{Sublayer}(x))\)</span>；它在早期 Transformer 中出现较多，但随着层数、上下文窗口和参数规模持续增大，Pre-LN 在大模型训练中更常见。</p>
<p>BatchNorm 很少出现在 Transformer 主干中的原因，是它要求当前表示依赖同一 batch 里其他样本的统计量。对于序列模型，这会带来几个直接问题：</p>
<ul>
<li>变长序列和 padding 会污染 batch 统计。</li>
<li>训练与推理使用的统计规则不同，自回归逐 token 生成时尤其不自然。</li>
<li>大模型训练常依赖小 batch、梯度累积和跨设备切分，batch 统计噪声更大。</li>
</ul>
<p>LayerNorm / RMSNorm 则完全避免了这些问题，因为每个 token 的归一化只依赖其自身特征。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/pre-post-norm.png"><img class="alignnone size-full wp-image-41691" src="https://blog.gmem.cc/wp-content/uploads/2026/03/pre-post-norm.png" alt="pre-post-norm" width="1024" height="1024" /></a></p>
<div class="blog_h3"><span class="graybg">RMS Normalization</span></div>
<p>RMSNorm（Root Mean Square Normalization）与 LayerNorm 的相同点是：都在每个 token 的特征维度上做归一化；不同点是 RMSNorm <span style="background-color: #c0c0c0;">不做去均值</span>，只按均方根（RMS）缩放：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{RMSNorm}(x)=\gamma\odot \frac{x}{\sqrt{\frac{1}{d}\sum_{i=1}^{d}x_i^2+\epsilon}}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\gamma\in\mathbb{R}^{d}\)</span> 是可学习缩放参数（通常不需要 <span displaypfx="inline-" class="mathjax-container">\(\beta\)</span>），<span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 则是数值稳定项（Numerical Stability Term）。当 RMS 很小时，若没有 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span>，分母会过小，微小噪声也可能被异常放大；若 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 过大，又会把不同 token 之间本应存在的尺度差异压平，使归一化变弱。RMSNorm 省掉了均值计算，算子更简单，因此在许多 Decoder-only 大模型中被广泛采用（例如 LLaMA 系列）。</p>
<p>RMSNorm 之所以使用均方根，而不是直接对各维做算术平均，是因为这里要刻画的是<span style="background-color: #c0c0c0;">向量整体有多大</span>，而不是“各维带符号求平均后的中心位置”。若直接用 <span displaypfx="inline-" class="mathjax-container">\(\frac{1}{d}\sum_i x_i\)</span>，正负分量会彼此抵消：例如 <span displaypfx="inline-" class="mathjax-container">\((10,-10)\)</span> 的算术平均是 0，但这个向量的整体幅值显然并不小。均方根先平方再平均，保留了各维对整体能量的贡献，又与二范数只差一个 <span displaypfx="inline-" class="mathjax-container">\(\sqrt d\)</span> 的常数因子，因此很适合用来刻画表示的整体尺度。</p>
<p>这也是 RMSNorm 即使不做去均值，仍然常常有效的原因。对 Transformer 主干而言，更核心的问题通常不是“特征均值是否恰好为 0”，而是<span style="background-color: #c0c0c0;">表示的整体尺度能否在深层网络中保持稳定</span>。残差流里真正容易失控的，往往是表示向量的整体幅值在层与层之间持续放大或缩小，进而影响梯度传播、残差叠加与数值稳定。RMSNorm 保留了各维之间的相对方向与相对比例，只对整体大小做统一缩放，因此不会反复改写表示基线；对深层 Transformer 来说，这种“只管尺度、不强行去中心”的处理往往已经足够，而且算子更轻，更适合大规模训练与推理。也正因为如此，归一化与残差连接通常总是一起出现：前者负责稳定尺度，后者负责保留主通路。</p>
<div class="blog_h2"><span class="graybg">残差连接</span></div>
<p>残差连接（Residual Connection）把子层输出与输入做逐元素相加：</p>
<span displaypfx="" class="mathjax-container">\[y=x+\mathrm{Sublayer}(x)\]</span>
<p>它不改变主表示的维度（Dimension）——前提是 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Sublayer}(x)\)</span> 形状相同。这个写法的核心价值，不是“把两份向量简单相加”，而是把深层网络的每一层改写成：<span style="background-color: #c0c0c0;">在已有表示上追加一小步修正，而不是每层都彻底重写整份表示</span>。</p>
<p>若没有残差，网络某一层必须直接学出从输入到输出的完整映射；有了残差后，子层只需学习增量项 <span displaypfx="inline-" class="mathjax-container">\(\Delta(x)=\mathrm{Sublayer}(x)\)</span>。当最优行为接近恒等映射时，学习“加多少修正”通常比学习“整层重新变换成什么样”更容易。这也是残差连接与恒等映射（Identity Mapping）关系紧密的原因：主通路默认保留原信息，子层负责在其上叠加必要变化。</p>
<p>从优化角度看，残差连接直接改变了梯度传播路径。若把一层的输入 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 看成 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 维向量，输出写成 <span displaypfx="inline-" class="mathjax-container">\(y=x+f(x)\)</span>，那么这里的求导就不再是标量对标量的导数，而是<span style="background-color: #c0c0c0;">向量对向量的 Jacobian 矩阵</span>。</p>
<p>先看最简单的恒等映射 <span displaypfx="inline-" class="mathjax-container">\(g(x)=x\)</span>。若 <span displaypfx="inline-" class="mathjax-container">\(x=(x_1,\dots,x_d)^\top\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(g_i(x)=x_i\)</span>。它的 Jacobian 第 <span displaypfx="inline-" class="mathjax-container">\((i,j)\)</span> 个元素是</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial g_i}{\partial x_j}=\frac{\partial x_i}{\partial x_j}=\begin{cases}1,&amp; i=j\\0,&amp; i\ne j\end{cases}\]</span>
<p>因此，向量对自身的导数不是数字 1，而是恒等矩阵：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial x}{\partial x}=I\]</span>
<p>再对残差块 <span displaypfx="inline-" class="mathjax-container">\(y=x+f(x)\)</span> 求导，就得到</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial y}{\partial x}=\frac{\partial x}{\partial x}+\frac{\partial f(x)}{\partial x}=I+\frac{\partial f(x)}{\partial x}\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(I\)</span> 正对应那条“把输入原样传过去”的恒等分支。它并不表示整层没有维度之间的交互，而只表示：在这条直连路径上，每一维对自身的导数是 1、对其他维的导数是 0。真正的维度混合、特征重组与 token 间交互，仍然由 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f(x)}{\partial x}\)</span> 负责。含义是：即使子层 <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 的局部 Jacobian 很小、很噪，或训练初期还没有学好，梯度仍然可以沿着这条恒等路径直接穿过该层，而不必完全依赖 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f(x)}{\partial x}\)</span>。深层网络因此更不容易出现梯度迅速衰减，训练也更稳定。</p>
<p>从表示角度看，残差连接建立了一条贯穿全网的主通道，这正是前面多次出现的残差流（Residual Stream）。在 Transformer 中，注意力子层负责跨 token 交换信息，MLP / FFN 负责对单个 token 做非线性重组，而它们的输出都不是“另起炉灶”的新表示，而是写回这条主通道。于是每一层都更像是在同一块工作记忆上持续读写：有的层补充局部依赖，有的层补充长程关系，有的层强化事实模式或语法结构。</p>
<p>残差连接还有一个很重要的工程意义：它允许模型在“保留已有信息”和“注入新特征”之间取得平衡。若某层子层输出很弱，网络行为就更接近恒等传递；若某层确实学到了有价值的新模式， <span displaypfx="inline-" class="mathjax-container">\(f(x)\)</span> 就会沿某些表示方向显著写回主通道。后续层不需要把两部分精确拆开，只需要继续利用这个叠加后的结果即可，因为后续线性映射、注意力和归一化会在新的坐标方向上重新组织这些信息。</p>
<p>从反向传播（Backpropagation）的角度看，残差连接的价值同样直接。若没有残差，深层网络中的梯度必须连续穿过许多子层 Jacobian，相当于做多次矩阵连乘；当这些局部导数长期偏小，梯度就容易逐层衰减，出现梯度消失（Vanishing Gradient）；当它们长期偏大，又可能造成梯度爆炸（Exploding Gradient）。加入残差后，每一层的局部导数从 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial f(x)}{\partial x}\)</span> 变成了 <span displaypfx="inline-" class="mathjax-container">\(I+\frac{\partial f(x)}{\partial x}\)</span>，于是梯度不再只能依赖子层本身，而始终保留了一条沿恒等分支传播的主路径。可以把它概括成一句话：<span style="background-color: #c0c0c0;">前向传播时保留原信息，反向传播时保留主梯度通路</span>。这正是残差连接能显著缓解深层网络优化困难的根本原因。</p>
<p>这也是为什么残差连接几乎成为现代深网络的标准部件。对于非常深的模型，真正困难的并不是“单层表达能力不够”，而是层数增加后，前向信息更容易被后续变换不断改写，反向梯度也更容易在长链路中衰减或失稳。残差连接用一条恒等主路同时缓解了这两个问题：前向上保留原信息，反向上保留主梯度通路。因此，ResNet、Transformer、扩散模型乃至许多大型序列模型，都会把它作为主干结构的一部分。</p>
<div class="blog_h2"><span class="graybg">输出处理</span></div>
<p>Transformer 主干（Backbone）本身的直接产物通常不是“类别”或“文字”，而是一组上下文化隐藏状态（Contextual Hidden States）。也就是说，在一次标准前向计算里，模型会先处理当前输入序列中的所有 token，把每个位置都编码成上下文化表示；若输入序列长度为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span>，模型宽度为 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span>，经过最后一层后常得到：</p>
<span displaypfx="" class="mathjax-container">\[H^{(L)}\in\mathbb{R}^{T\;\times d_{\text{model}}}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 是 Transformer 层数，因此 <span displaypfx="inline-" class="mathjax-container">\(H^{(L)}\)</span> 表示“经过第 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 层之后得到的隐藏状态矩阵”； <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 是当前序列的 token 数，所以这个矩阵一共有 <span displaypfx="inline-" class="mathjax-container">\(T\)</span> 行，每一行对应一个位置。若把第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置那一行单独记作 <span displaypfx="inline-" class="mathjax-container">\(h_t^{(L)}\)</span>，它表示的就是：第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个 token 在通过全部 <span displaypfx="inline-" class="mathjax-container">\(L\)</span> 层、吸收了上下文信息之后得到的最终向量表示。输出处理（Output Processing）的任务，就是把这组隐藏状态映射到具体任务所需的输出空间：可以是词表概率、类别分数、序列标签、起止位置分数，或回归数值。这里常说的读出（Readout），指的就是：<span style="background-color: #c0c0c0;">主干网络先形成内部表示，再由最后的输出层把这种表示转换成任务空间里的可解释结果</span>。更准确地说，Transformer 主干先产生整段序列的隐藏表示，再由具体任务的输出层决定如何读取这些表示：有的任务会逐位置读出，有的任务只取某个聚合位置；生成任务则在当前前缀对应的隐藏状态基础上，继续决定下一个 token 的输出。</p>
<div class="blog_h3"><span class="graybg">从隐藏状态到输出空间</span></div>
<p>最常见的输出处理是在线性读出（Linear Readout）层中，把 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span> 维隐藏状态投影到目标维度 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{out}}\)</span>。若按 token 逐位置读出，可写成：</p>
<span displaypfx="" class="mathjax-container">\[Z=HW_{\text{out}}+\mathbf{1}b^\top\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(H\in\mathbb{R}^{T\;\times d_{\text{model}}}\)</span> 是最后一层隐藏状态； <span displaypfx="inline-" class="mathjax-container">\(W_{\text{out}}\in\mathbb{R}^{d_{\text{model}}\;\times d_{\text{out}}}\)</span> 是输出投影矩阵； <span displaypfx="inline-" class="mathjax-container">\(b\in\mathbb{R}^{d_{\text{out}}}\)</span> 是偏置； <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}\in\mathbb{R}^{T}\)</span> 是全 1 列向量，用来把同一个偏置加到每个位置； <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{T\;\times d_{\text{out}}}\)</span> 则是每个位置对应的输出分数。若任务是词表预测，则 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{out}}=V\)</span>， <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 是词表大小；若任务是 token 分类，则 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{out}}=C\)</span>， <span displaypfx="inline-" class="mathjax-container">\(C\)</span> 是标签类别数。</p>
<p>并非所有任务都对每个 token 独立读出。序列分类常从整段序列中先取一个聚合表示，再做线性映射。例如 BERT 类模型常使用 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{CLS}]\)</span> 位置的隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(h_{\mathrm{CLS}}\)</span>，再输出：</p>
<span displaypfx="" class="mathjax-container">\[z=h_{\mathrm{CLS}}W_c+b_c\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(z\in\mathbb{R}^{C}\)</span> 是整句的类别 logits。跨度抽取（Span Extraction）任务则常对每个位置分别给出“作为起点”和“作为终点”的分数；序列到序列任务中，解码器则对每个时间步读出一个词表分布。</p>
<div class="blog_h3"><span class="graybg">语言模型中的输出处理</span></div>
<p>在 Decoder-only 或 Encoder-Decoder 的生成端，输出处理通常还包含最后一次归一化层（如 LayerNorm 或 RMSNorm）以及语言模型头（Language Modeling Head, LM Head）。概念上可写成：</p>
<span displaypfx="" class="mathjax-container">\[\tilde H=\mathrm{Norm}(H^{(L)}),\qquad Z=\tilde H W_{\mathrm{vocab}}+\mathbf{1}b^\top\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\tilde H\in\mathbb{R}^{T\;\times d_{\text{model}}}\)</span> 是最终归一化后的隐藏状态； <span displaypfx="inline-" class="mathjax-container">\(W_{\mathrm{vocab}}\in\mathbb{R}^{d_{\text{model}}\;\times V}\)</span> 是词表投影矩阵； <span displaypfx="inline-" class="mathjax-container">\(Z\in\mathbb{R}^{T\;\times V}\)</span> 是每个位置对整个词表的 logits。矩阵第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 行 <span displaypfx="inline-" class="mathjax-container">\(z_t\in\mathbb{R}^{V}\)</span> 描述的是：当模型已经看到当前位置之前允许访问的上下文后，当前位置对每个候选 token 的偏好分数。</p>
<p>这里最后再做一次归一化，并不是多余的重复，而是把“主干内部的表示空间”整理成更适合词表读出的数值形态。Transformer 主干中的隐藏状态一路沿着残差流（Residual Stream）传播，虽然语义信息已经形成，但向量整体尺度仍可能随着层数、上下文和激活模式发生波动。若直接把 <span displaypfx="inline-" class="mathjax-container">\(H^{(L)}\)</span> 送入 <span displaypfx="inline-" class="mathjax-container">\(W_{\mathrm{vocab}}\)</span>，这些尺度变化会被直接放大到 logits 上，使 softmax 有时过尖、有时过平，输出分布与梯度都更难稳定。</p>
<p>最后一次归一化的作用，是在进入词表空间之前先把隐藏状态重新放回一个稳定坐标系里：一方面减弱“幅值忽大忽小”对 logits 的直接干扰，另一方面让 LM Head 更专注于“当前表示朝哪个语义方向更接近某个 token”，而不是过度依赖向量长度本身。换言之，主干网络负责把内容表示出来，最后的归一化负责把这种内容整理到一个尺度可控、便于读出的状态，再交给 <span displaypfx="inline-" class="mathjax-container">\(W_{\mathrm{vocab}}\)</span> 做最终投影。这也是许多现代 Decoder-only 大模型会在输出头前保留一层 LayerNorm 或 RMSNorm 的原因。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/lmhead.png"><img class="alignnone size-large wp-image-41727" src="https://blog.gmem.cc/wp-content/uploads/2026/03/lmhead.png" alt="lmhead" width="710" height="710" /></a></p>
<div class="blog_h4"><span class="graybg">权重共享（Weight Tying）</span></div>
<p>语言模型里常把输入嵌入表 <span displaypfx="inline-" class="mathjax-container">\(E\in\mathbb{R}^{V\;\times d_{\text{model}}}\)</span> 与输出头权重绑定为同一组参数。若不共享，输出头通常写成 <span displaypfx="inline-" class="mathjax-container">\(W_{\mathrm{vocab}}\in\mathbb{R}^{d_{\text{model}}\;\times V}\)</span>；若共享，则直接令</p>
<span displaypfx="" class="mathjax-container">\[W_{\mathrm{vocab}}=E^\top\]</span>
<p>这不是把“两份数据塞进一个矩阵”，而是让同一个参数矩阵在前向计算的两个位置重复使用：输入阶段按 token id 取出 <span displaypfx="inline-" class="mathjax-container">\(E\)</span> 的某一行作为该 token 的嵌入；输出阶段则把隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(h\)</span> 与所有 token 向量做点积，得到整张词表的 logits：</p>
<span displaypfx="" class="mathjax-container">\[z=hE^\top+b\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(z\in\mathbb{R}^{V}\)</span>，第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个分量 <span displaypfx="inline-" class="mathjax-container">\(z_i=h\cdot E_i+b_i\)</span> 表示当前隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(h\)</span> 与第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个 token 向量 <span displaypfx="inline-" class="mathjax-container">\(E_i\)</span> 的匹配分数。输入嵌入回答“这个 token 进来时长什么样”，输出头回答“当前语境最像词表里的哪个 token”；Weight Tying 让这两种词向量语义共用同一个坐标系。</p>
<p>这里共享的是参数。训练时，这张矩阵同时接收两类梯度：一类来自输入查表路径，更新当前 batch 真正出现过的 token 行；另一类来自输出 softmax 路径，推动隐藏状态与目标 token 更接近、与竞争 token 拉开。自动求导会把这两部分梯度加到同一份参数上，形成联合更新。因此它通常能减少参数量、增强输入与输出语义空间的一致性，并起到一定正则化（Regularization）作用。</p>
<p>只有在输入嵌入维度与输出读出维度一致时，这种共享才最直接。若模型在读出前额外引入了投影层，使输出维度不再等于 <span displaypfx="inline-" class="mathjax-container">\(d_{\text{model}}\)</span>，则需要先做维度变换，或不共享。Weight Tying 因而是常见做法，但不是所有架构都必须采用的硬规则。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/lm-output-head.png"><img class="alignnone size-full wp-image-41479" src="https://blog.gmem.cc/wp-content/uploads/2026/03/lm-output-head.png" alt="lm-output-head" width="1920" height="1080" /></a></p>
<div class="blog_h3"><span class="graybg">从分数到最终结果</span></div>
<p>输出处理的最后一步，是把 logits 变成任务可用的结果。训练时，很多损失函数会直接接收 logits，例如交叉熵损失（Cross-Entropy Loss）内部会把 softmax 与负对数似然（Negative Log-Likelihood）合并计算，以提高数值稳定性。推理时，则通常再做显式后处理：分类任务对 logits 做 softmax 或 sigmoid 得到概率；序列标注任务可在 logits 之上接 CRF 解码；生成任务则对词表 logits 做 softmax 后，再通过贪心搜索（Greedy Decoding）、束搜索（Beam Search）、Top-k 采样或 Top-p 采样等策略选择下一个 token。</p>
<p>以生成任务为例，若当前位置的词表 logits 为 <span displaypfx="inline-" class="mathjax-container">\(z_t\in\mathbb{R}^{V}\)</span>，则先得到条件分布：</p>
<span displaypfx="" class="mathjax-container">\[p(x_{t+1}=i\mid x_{\le t})=\frac{e^{z_{t,i}}}{\sum_{j=1}^{V}e^{z_{t,j}}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(V\)</span> 是词表大小， <span displaypfx="inline-" class="mathjax-container">\(z_{t,i}\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个位置对第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个候选 token 的 logit， <span displaypfx="inline-" class="mathjax-container">\(p(x_{t+1}=i\mid x_{\le t})\)</span> 则是在当前前缀 <span displaypfx="inline-" class="mathjax-container">\(x_{\le t}\)</span> 下，下一个 token 取第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个词的概率。解码策略的区别，不在于 logits 或 softmax 公式不同，而在于：<span style="background-color: #c0c0c0;">拿到这组概率之后，究竟用什么规则选出真正输出的 token</span>。</p>
<div class="blog_h4"><span class="graybg">贪心搜索</span></div>
<p>贪心搜索（Greedy Decoding）是最直接的策略：每一步都选当前概率最大的那个 token。写成公式，就是</p>
<span displaypfx="" class="mathjax-container">\[x_{t+1}=\arg\max_{i} \ p(x_{t+1}=i\mid x_{\le t})\]</span>
<p>它的优点是速度快、实现简单、结果确定；缺点是过于短视。因为它每一步都只看“眼前概率最高”，而不考虑“当前稍差一点、但后续整体更优”的路径。于是贪心搜索很容易陷入局部最优：第一步看起来最稳的选择，不一定能导向整句概率最好的结果。</p>
<p>直觉上，它像每到路口都选眼前最宽的一条路，而不回头评估整条路线是否更通畅。因此贪心适合需要稳定、低延迟输出的场景，但在开放生成任务里往往较保守，也更容易重复。</p>
<div class="blog_h4"><span class="graybg">束搜索</span></div>
<p>束搜索（Beam Search）是在每一步同时保留多个高分候选前缀，而不是像贪心那样只保留 1 条路径。设束宽（Beam Width）为 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>，则在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步，算法会维护 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 条当前最优候选序列；每条序列再向外扩展多个 token，最后从所有扩展结果中重新筛出新的 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 条最高分路径继续前进。</p>
<p>若一条候选序列为 <span displaypfx="inline-" class="mathjax-container">\(x_{1:T}\)</span>，其常见打分方式是对数概率和：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{score}(x_{1:T})=\sum_{t=1}^{T}\log p(x_t\mid x_{&lt;t})\]</span>
<p>因为概率连乘会非常小，所以实现里通常比较对数概率之和，而不是直接比较概率乘积。有时还会加长度惩罚（Length Penalty），避免模型系统性偏爱过短序列。</p>
<p>束搜索的优点是全局性比贪心更强，常用于机器翻译、摘要等更强调整体序列质量的任务；缺点是计算量更高，而且它本质上仍是“找高分路径”的搜索，不会主动引入随机性，因此输出可能仍然偏保守、偏模板化。</p>
<div class="blog_h4"><span class="graybg">Top-k 采样</span></div>
<p>Top-k 采样（Top-k Sampling）先把概率最高的 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个 token 保留下来，其余 token 概率全部截断为 0，然后在这 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个候选里重新归一化并随机采样。设保留下来的候选集合为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{K}_k\)</span>，则采样分布可写成：</p>
<span displaypfx="" class="mathjax-container">\[p_k(i)= \begin{cases} \frac{p_i}{\sum_{j\in \mathcal{K}_k}p_j}, &amp; i\in \mathcal{K}_k\\ 0, &amp; i\notin \mathcal{K}_k \end{cases}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span> 是 softmax 后原始概率， <span displaypfx="inline-" class="mathjax-container">\(\mathcal{K}_k\)</span> 是当前概率最高的 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个 token 集合。这样做的效果是：极小概率的长尾 token 不再参与抽样，从而降低胡言乱语或离谱跳转的风险；同时又保留了随机性，不会像贪心那样永远输出同一条路径。</p>
<p>Top-k 的关键超参数是 <span displaypfx="inline-" class="mathjax-container">\(k\)</span>。 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 太小，分布会重新变得接近贪心； <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 太大，又会把很多低质量候选放回来。它本质上是在“稳定性”和“多样性”之间做硬截断式折中。</p>
<div class="blog_h4"><span class="graybg">Top-p 采样</span></div>
<p>Top-p 采样（Top-p Sampling, Nucleus Sampling）不固定保留多少个 token，而是先按概率从高到低排序，再取最小的前缀集合 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{N}_p\)</span>，使其累计概率至少达到阈值 <span displaypfx="inline-" class="mathjax-container">\(p\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{i\in \mathcal{N}_p} p_i \ge p\]</span>
<p>然后只在这个“概率核心区”里重新归一化并随机采样。与 Top-k 相比，Top-p 的保留集合大小是动态变化的：如果当前分布非常尖锐，可能只需要少数几个 token 就能覆盖 90% 或 95% 的概率质量；如果当前分布较平，保留下来的 token 数量就会自动增多。</p>
<p>这种自适应机制更贴合语言生成的实际状态：有些位置模型非常确定，例如固定短语或语法闭合，此时候选空间本来就应很小；有些位置模型不那么确定，例如开放内容展开，此时候选空间应更大。Top-p 因而通常比固定的 Top-k 更灵活，也是现代大模型推理中非常常见的采样策略。</p>
<div class="blog_h4"><span class="graybg">温度与策略取舍</span></div>
<p>温度（Temperature）常与上述采样策略配合使用。若把 logits <span displaypfx="inline-" class="mathjax-container">\(z_{t,i}\)</span> 除以温度 <span displaypfx="inline-" class="mathjax-container">\(\tau\)</span> 后再做 softmax，则有：</p>
<span displaypfx="" class="mathjax-container">\[p_\tau(i)=\frac{e^{z_{t,i}/\tau}}{\sum_{j=1}^{V}e^{z_{t,j}/\tau}}\]</span>
<p>当 <span displaypfx="inline-" class="mathjax-container">\(\tau&lt;1\)</span> 时，分布会变尖，模型更保守；当 <span displaypfx="inline-" class="mathjax-container">\(\tau&gt;1\)</span> 时，分布会变平，采样更发散。于是，解码策略的工程取舍可以概括为：</p>
<ul>
<li>贪心搜索：最快、最稳定，但最短视。</li>
<li>束搜索：更重视整句高分路径，但计算更贵、表达更保守。</li>
<li>Top-k 采样：固定候选数，简单直接，易于控制长尾噪声。</li>
<li>Top-p 采样：候选数自适应，通常更自然、更适合开放生成。</li>
</ul>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/temperature-softmax-distribution.png"><img class="alignnone size-full wp-image-41737" src="https://blog.gmem.cc/wp-content/uploads/2026/03/temperature-softmax-distribution.png" alt="temperature-softmax-distribution" width="1920" height="1785" /></a></p>
<div class="blog_h4"><span class="graybg">重复惩罚与频率控制</span></div>
<p>仅靠解码策略本身，往往还不足以避免模型进入重复、啰嗦或机械回环的状态。例如开放生成时，模型可能连续输出相同短语，或不断在几个近义表达之间打转。工程上因此常在 logits 层再加入一类后处理规则：对已经出现过的 token 施加惩罚，从而改变下一步的候选分布。</p>
<p>最常见的一类是重复惩罚（Repetition Penalty）。它的思想很直接：若某个 token 已经在当前上下文中出现过，就下调它再次被选中的倾向。实现细节在不同框架里略有差异，一种常见写法是对已出现 token 的 logit <span displaypfx="inline-" class="mathjax-container">\(z_i\)</span> 施加按符号分段的缩放：</p>
<span displaypfx="" class="mathjax-container">\[z_i'= \begin{cases} z_i / r, &amp; z_i&gt;0\\ z_i \cdot r, &amp; z_i\le 0 \end{cases},\qquad r&gt;1\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 是重复惩罚系数。这样处理的目的，是在不破坏 logit 正负号语义的前提下，整体压低“已经出现过的 token 再次被选中”的优势。 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 越大，惩罚越强；过大则可能把正常重复也压掉，使输出变得生硬。</p>
<p>另一类常见控制项是 presence penalty（出现惩罚）与 frequency penalty（频次惩罚）。它们的共同目标是抑制重复，但力度来源不同：前者只关心“出现过没有”，后者关心“已经出现了多少次”。若原始 logit 为 <span displaypfx="inline-" class="mathjax-container">\(z_i\)</span>，token <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 在当前已生成文本中的出现次数为 <span displaypfx="inline-" class="mathjax-container">\(c_i\)</span>，则一个常见抽象写法是：</p>
<span displaypfx="" class="mathjax-container">\[z_i' = z_i - \lambda_{\mathrm{pres}}\mathbf{1}[c_i&gt;0] - \lambda_{\mathrm{freq}}c_i\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\lambda_{\mathrm{pres}}\)</span> 是 presence penalty 系数， <span displaypfx="inline-" class="mathjax-container">\(\mathbf{1}[c_i&gt;0]\)</span> 是指示函数：只要 token <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 出现过至少一次，就减去一个固定惩罚； <span displaypfx="inline-" class="mathjax-container">\(\lambda_{\mathrm{freq}}\)</span> 是 frequency penalty 系数， <span displaypfx="inline-" class="mathjax-container">\(c_i\)</span> 越大，惩罚越强。因此：</p>
<ul>
<li>presence penalty 更像“出现过就提醒一次”，主要鼓励模型换新词、开新话题。</li>
<li>frequency penalty 更像“出现越多罚越重”，主要抑制机械重复和啰嗦堆叠。</li>
</ul>
<p>三者的作用位置都在 softmax 之前：先修改 logits，再重新归一化成概率。它们不是在训练阶段改变模型参数，而是在推理阶段临时改写候选分布。因此，它们更像输出控制器（Output Controller），而不是模型能力本身的一部分。</p>
<p>工程上，这些惩罚项通常与温度、Top-k、Top-p 一起调节。若目标是严谨、稳定、少跑偏的回答，常采用较低温度并只施加较轻的重复控制；若目标是创意写作或开放发散，则可能提高温度，同时保留较温和的 presence penalty，鼓励内容展开但避免原句循环。它们解决的不是“模型知不知道答案”，而是<span style="background-color: #c0c0c0;">模型在已知概率分布下，最终说话风格如何被约束</span>。</p>
<p>因此，Transformer 的“输出”需要分成两个层次理解。主干网络输出的是高维隐藏表示；真正面向任务的可解释结果，来自这些隐藏表示经过归一化、线性读出、概率映射与解码后的最终读出。输出处理连接了通用表示学习与具体任务目标，是 Transformer 从“会表示”走向“会预测、会生成、会决策”的最后一跳。</p>
<div class="blog_h1"><span class="graybg">语言模型</span></div>
<p>语言模型（Language Model）是对自然语言序列概率分布进行建模的模型。给定一段上下文，它学习并估计后续词元（token）或整个序列出现的概率，从而捕捉语言中的词法、语法、语义以及长程依赖结构。现代语言模型通常由参数化神经网络实现，其本质是把语言规律压缩进一个可计算的概率模型中。</p>
<div class="blog_h2"><span class="graybg">语言模型基础知识</span></div>
<div class="blog_h3"><span class="graybg">序列概率视角</span></div>
<p>从严格定义看，语言模型处理的对象不是无序词集合，而是有顺序的 token 序列。若把一句话写成 <span displaypfx="inline-" class="mathjax-container">\(x_1,x_2,\dots,x_T\)</span>，语言模型的核心任务就是为这段序列分配概率；等价地，它也可以被看成在每一步根据已有上下文估计下一个 token 的条件概率。</p>
<span displaypfx="" class="mathjax-container">\[p(x_1,\dots,x_T)=\prod_{t=1}^{T}p(x_t\mid x_1,\dots,x_{t-1})\]</span>
<p>这一定义说明了为什么语言模型天然关心词序、上下文依赖与条件生成。无论后续采用 n-gram 统计模型还是 Transformer，本质上都在近似这类序列概率分布。</p>
<div class="blog_h3"><span class="graybg">分词（Tokenization）</span></div>
<p>这里的分词（Tokenization）指的是：把原始文本切分成模型实际处理的 token 序列，并据此映射到词表（Vocabulary）中的离散 id。它服务于语言模型建模本身，决定模型看到的基本单位是什么。</p>
<p>这与全文检索（Full-text Retrieval）里的“分词”不是同一个概念。检索系统里的分词更强调索引构建、倒排表匹配与查询召回；语言模型里的 tokenization 更强调如何把文本编码成适合训练与推理的离散序列。一个 token 不一定等于自然语言里的“词”，它也可能是子词、单字、标点、空格片段，甚至字节。更具体的 tokenizer 类型与工程差异，见 Transformers 部分的 <pre class="crayon-plain-tag">Tokenization</pre> 小节。</p>
<div class="blog_h3"><span class="graybg">词袋模型（Bag of Words, BoW）</span></div>
<p>词袋模型（Bag of Words, BoW）本质上只做一件事：统计一段文本里各个词出现了多少次，或只记录它是否出现。它把文本表示成词表上的计数向量 <span displaypfx="inline-" class="mathjax-container">\(\mathbf{c}\in\mathbb{R}^{|\mathcal{V}|}\)</span>，其中每一维对应某个词的出现次数。除了这些词频统计之外，BoW 不保留任何顺序信息，也不建模句法结构、上下文依赖或条件概率。</p>
<p>因此，BoW 严格说并不是语言模型，而是一种早期文本表示方法。它常与朴素贝叶斯（Naive Bayes）、逻辑回归（Logistic Regression）或 TF-IDF 一起用于文本分类、检索和主题分析。它的重要性在于：它展示了“先把文本映射成向量，再交给下游模型处理”的经典思路；但由于它仅仅统计词是否出现以及出现次数，无法区分“我喜欢你”和“你喜欢我”这类序列差异，也不能承担现代语言模型那种条件生成任务。</p>
<div class="blog_h3"><span class="graybg">词嵌入（Word Embedding）</span></div>
<p>词嵌入（Word Embedding）把每个词映射到一个低维稠密向量。与 BoW 的计数统计不同，词嵌入不再把每个词看成彼此独立的离散符号，而是让共现模式或语义相近的词在向量空间里彼此接近。Word2Vec、GloVe 和 FastText 都属于这一类方法。</p>
<p>经典词嵌入通常是静态的：同一个词在任何上下文里共享同一个向量。以 <pre class="crayon-plain-tag">bank</pre> 为例，在 <pre class="crayon-plain-tag">open a bank account</pre> 中它指银行，在 <pre class="crayon-plain-tag">sit on the river bank</pre> 中它指河岸；传统词嵌入一般仍会给它同一组参数，因此无法在表示层直接区分这两种词义。这也是后来上下文化表示（Contextual Representation）变得重要的原因。</p>
<div class="blog_h3"><span class="graybg">句子嵌入（Sentence Embedding）</span></div>
<p>句子嵌入（Sentence Embedding）进一步把整句或整段文本表示为一个向量。它关注的不再是单词层面的局部语义，而是整个输入的综合语义，常用于分类、检索、匹配与聚类。</p>
<p>历史上，基于循环神经网络（Recurrent Neural Network, RNN）的序列到序列模型（Sequence-to-Sequence, Seq2Seq）曾经采用“先编码成一个固定长度向量，再由解码器生成输出”的路径。Sutskever、Vinyals 和 Le 在 2014 年提出的 Seq2Seq 工作，就用深层 LSTM 把输入序列压缩为固定维度向量；这种单向量压缩在长句上容易形成信息瓶颈，而 RNN 本身的串行计算方式也限制了并行效率，并使长程依赖建模变得困难。Bahdanau、Cho 和 Bengio 在 2014 年提出的注意力机制（Attention Mechanism）开始缓解这一问题：解码器在每一步都能直接参考输入序列的不同位置，而不必把整句信息全部压缩进单一向量中。</p>
<p>真正的结构转折点来自 2017 年的 <span class="lang:none">Attention Is All You Need</span>。这篇论文提出了 Transformer 架构，用自注意力（Self-Attention）替代 RNN 的递归路径，使模型更擅长并行训练，也更有效地建模长距离依赖。当前主流句子嵌入方法，通常都建立在 Transformer 之上：无论是编码单句得到表示的 Encoder-only 模型，还是用于检索的双编码器（Bi-Encoder）结构，如 SBERT、E5、BGE 和 text-embedding 系列，都属于这一路线的延伸。</p>
<div class="blog_h3"><span class="graybg">稠密向量（Dense Vector）</span></div>
<p>从表示形式看，无论词嵌入还是句子嵌入，本质上都属于稠密向量：用较低维的实值向量承载词、句子或文档的信息。向量的每一维不再直接对应某个具体词，而是由训练过程自动学习得到；因此，模型可以把共现模式、语义相似性以及部分上下文规律压缩进连续向量空间。</p>
<p>这也是后文嵌入模型（Embedding Model）、Word2Vec、Sentence-BERT 和 text-embedding 系列的共同基础。它们的差异在于：表示对象是词、句子还是文档，训练目标是预测上下文、对比学习还是任务特化微调；但核心思想一致，都是让语义结构在向量空间中变得可计算。</p>
<p>上述内容回答的是“文本如何被表示”。回到语言模型本身，还需要进一步区分模型究竟在学什么、输出什么、内部结构如何组织，以及它在工程系统中承担什么角色。</p>
<div class="blog_h2"><span class="graybg">语言模型分类</span></div>
<p>理解语言模型时，至少需要区分四个互相独立但彼此关联的维度：第一，模型在预训练时学的是什么；第二，模型最终主要输出什么；第三，模型内部的信息流结构如何组织；第四，模型在工程上是通用基座、指令对齐模型，还是任务特定模型。它们回答的是四个不同问题，因此一个模型完全可以同时拥有多重身份。例如，BERT 可以同时被描述为“掩码语言模型（Masked Language Model, MLM）+ 表示模型（Representation Model）+ Encoder-only 模型”；GPT / Qwen / LLaMA 则通常是“自回归语言模型（Autoregressive Language Model）+ 生成模型（Generative Model）+ Decoder-only 模型”。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">分类维度</td>
<td style="text-align: center;">它回答的问题</td>
<td style="text-align: center;">典型类别</td>
</tr>
</thead>
<tbody>
<tr>
<td>按预训练目标</td>
<td>模型在预训练阶段究竟被要求预测什么</td>
<td>掩码语言模型、自回归语言模型、替换检测、去噪重建</td>
</tr>
<tr>
<td>按输出与用途</td>
<td>模型最终主要产出向量、表示还是可直接生成的文本</td>
<td>嵌入模型、表示模型、生成模型</td>
</tr>
<tr>
<td>按架构信息流</td>
<td>模型内部如何读取上下文、如何组织编码与生成</td>
<td>Encoder-only、Decoder-only、Encoder–Decoder</td>
</tr>
<tr>
<td>按工程形态</td>
<td>模型在实际系统里扮演什么角色</td>
<td>通用预训练基座、指令对齐模型、任务特定模型</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">按预训练目标分类</span></div>
<p>按预训练目标分类，关注的是模型在大规模无标注文本上被要求完成什么自监督任务。这一维决定了模型最初学会的信息组织方式，但不直接等价于它最终能做什么任务。最经典的两类是掩码语言模型与自回归语言模型。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center;">核心训练目标</td>
<td style="text-align: center;">上下文可见性</td>
<td style="text-align: center;">典型模型</td>
<td style="text-align: center;">更常见优势</td>
</tr>
</thead>
<tbody>
<tr>
<td>掩码语言模型（MLM）</td>
<td>遮住部分 token，再根据其余上下文恢复被遮住内容</td>
<td>通常可双向看左右文</td>
<td>BERT、RoBERTa、DeBERTa</td>
<td>表示学习强；适合理解、分类、匹配、序列标注</td>
</tr>
<tr>
<td>自回归语言模型（CLM / ARLM）</td>
<td>根据前文预测下一个 token</td>
<td>因果约束，只看历史上下文</td>
<td>GPT、LLaMA、Qwen、Mistral</td>
<td>生成自然；统一接口强；适合对话、续写、代码生成</td>
</tr>
</tbody>
</table>
<p>两者的差异首先体现在条件概率分解方式上。自回归语言模型直接建模整段文本的联合概率：</p>
<span displaypfx="" class="mathjax-container">\[p(x_1,\dots,x_T)=\prod_{t=1}^{T}p(x_t\mid x_{&lt;t})\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个 token， <span displaypfx="inline-" class="mathjax-container">\(x_{&lt;t}\)</span> 表示它之前所有 token；模型在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 步输出的是“下一个 token 的词表分布”。掩码语言模型则不是按固定顺序展开整句，而是在输入中随机挑出若干位置 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 做遮蔽，训练目标可写成：</p>
<span displaypfx="" class="mathjax-container">\[\max \sum_{i\in M}\log p(x_i\mid x_{\setminus M})\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(M\)</span> 是被遮住的位置集合， <span displaypfx="inline-" class="mathjax-container">\(x_{\setminus M}\)</span> 表示其余未遮住 token。它学到的是“给定上下文，如何恢复缺失信息”，而不是“如何一步步把整段文本续写出来”。因此，MLM 天然更偏表示学习；ARLM 天然更偏生成建模。</p>
<p>这一维并不只有两类。ELECTRA 的替换检测（Replaced Token Detection, RTD）不直接恢复 mask，而是判断 token 是否被替换；T5、BART 的去噪重建（Denoising Reconstruction）则通过破坏输入再让模型恢复原文。因此，“掩码 vs 自回归”是最核心的一条主线，但不是全部可能性。</p>
<div class="blog_h3"><span class="graybg">按输出与用途分类</span></div>
<p>按输出与用途分类，关注的是模型最终主要产出什么，以及这些产出在工程系统里被如何使用。这里最容易混淆的是“嵌入模型”和“表示模型”。两者都能产出向量，但优化目标和默认使用方式并不相同。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center;">主要输出</td>
<td style="text-align: center;">优化重点</td>
<td style="text-align: center;">典型用途</td>
<td style="text-align: center;">典型代表</td>
</tr>
</thead>
<tbody>
<tr>
<td>嵌入模型（Embedding Model）</td>
<td>固定维度向量</td>
<td>让语义相近样本在向量空间里更近</td>
<td>检索、聚类、召回、语义匹配</td>
<td>Word2Vec、SBERT、BGE、E5、text-embedding 系列</td>
</tr>
<tr>
<td>表示模型（Representation Model）</td>
<td>上下文化隐藏表示</td>
<td>学到可迁移的中间表示，再交给任务头读出</td>
<td>分类、序列标注、匹配、判别式 NLU</td>
<td>BERT、RoBERTa、DeBERTa、ModernBERT</td>
</tr>
<tr>
<td>生成模型（Generative Model）</td>
<td>逐步生成的 token 分布与文本序列</td>
<td>最大化生成质量、上下文延续性与指令跟随能力</td>
<td>对话、写作、摘要、翻译、代码生成、结构化输出</td>
<td>GPT、Qwen、LLaMA、T5、BART</td>
</tr>
</tbody>
</table>
<p>嵌入模型的关键特征是：它的向量空间本身就是最终产品。用户真正拿来用的是向量之间的距离、余弦相似度或最近邻结构。表示模型则更像通用特征提取器：它输出的隐藏状态通常还要再接一个任务头（Task Head）或额外池化层，才能变成分类分数、序列标签或其他任务结果。生成模型的最终输出则是一个条件词表分布，经过解码后形成文本或结构化序列。</p>
<p>同一底座模型有时可以被改造成不同用途。例如，BERT 原本是表示模型，但经过对比学习和专门池化后可以变成句向量嵌入模型；Decoder-only 大模型原本是生成模型，但也可以通过取隐藏状态做 embedding。不过从默认训练目标与最强项看，这三类仍然应当区分。</p>
<div class="blog_h3"><span class="graybg">按架构与信息流分类</span></div>
<p>按架构分类，关注的是模型内部如何读取上下文，以及输入和输出是如何在网络中流动的。这一维对应的是结构设计，而不是预训练目标本身。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">架构类别</td>
<td style="text-align: center;">信息流特征</td>
<td style="text-align: center;">注意力方式</td>
<td style="text-align: center;">典型任务</td>
<td style="text-align: center;">典型代表</td>
</tr>
</thead>
<tbody>
<tr>
<td>Encoder-only</td>
<td>把输入编码成上下文化表示，不直接负责逐步生成</td>
<td>通常是双向自注意力</td>
<td>分类、检索、匹配、序列标注</td>
<td>BERT、RoBERTa、DeBERTa、ELECTRA</td>
</tr>
<tr>
<td>Decoder-only</td>
<td>按时间步自回归地产生输出</td>
<td>因果自注意力</td>
<td>对话、续写、代码生成、开放式问答</td>
<td>GPT、LLaMA、Qwen、Mistral、DeepSeek</td>
</tr>
<tr>
<td>Encoder–Decoder</td>
<td>先编码输入，再由解码器条件生成输出</td>
<td>编码器双向自注意力 + 解码器因果自注意力 + 交叉注意力</td>
<td>翻译、摘要、改写、条件生成</td>
<td>T5、BART</td>
</tr>
</tbody>
</table>
<p>这一维与前两维经常联动，但不是一一对应。Encoder-only 模型常与 MLM 或 RTD 结合，Decoder-only 模型常与自回归目标结合，Encoder–Decoder 模型则常与去噪或条件生成目标结合；但它们分别回答的是“结构长什么样”和“训练时学什么”的两个不同问题。</p>
<div class="blog_h3"><span class="graybg">按工程形态分类</span></div>
<p>在工程落地中，还需要区分模型处于哪种产品化形态。通用预训练基座（Base Model）强调语言知识与可迁移能力；指令对齐模型（Instruction-tuned Model）强调遵循人类指令、对话风格与格式约束；任务特定模型（Task-specific Model）则围绕某个明确监督目标继续微调，并常配合专门任务头工作。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">工程形态</td>
<td style="text-align: center;">核心特点</td>
<td style="text-align: center;">适合场景</td>
<td style="text-align: center;">典型例子</td>
</tr>
</thead>
<tbody>
<tr>
<td>通用预训练基座</td>
<td>保留通用语言知识，强调可迁移性与再训练空间</td>
<td>继续预训练、SFT、LoRA、蒸馏</td>
<td>BERT base、LLaMA base、Qwen base</td>
</tr>
<tr>
<td>指令对齐模型</td>
<td>通过指令微调与偏好优化提升对话和任务遵循能力</td>
<td>问答助手、Agent、工具调用、结构化生成</td>
<td>ChatGPT 类、Qwen-Instruct、LLaMA-Instruct</td>
</tr>
<tr>
<td>任务特定模型</td>
<td>围绕明确任务输出继续微调，并常接专门任务头</td>
<td>NER、分类、匹配、排序、信息抽取</td>
<td>DeBERTa-CRF、BERT 分类器、LLM + PEFT</td>
</tr>
</tbody>
</table>
<p>把四个维度合在一起看，模型的定位会变得清晰得多：</p>
<ul>
<li>BERT：更接近 MLM + 表示模型 + Encoder-only + 常作为任务特定模型底座。</li>
<li>SBERT 或 BGE：更接近 表示 / 对比学习 + 嵌入模型 + 常为双编码检索结构。</li>
<li>GPT / Qwen / LLaMA：更接近 自回归 + 生成模型 + Decoder-only + 常见指令对齐形态。</li>
<li>T5 / BART：更接近 去噪或 text-to-text 目标 + 生成模型 + Encoder–Decoder。</li>
</ul>
<div class="blog_h2"><span class="graybg">主流表示型语言模型</span></div>
<p>到 2026 年，表示型语言模型（Representation Model）的工程格局已经明显分成两条主线。第一条是以 Encoder-only 为核心的判别式表示模型，主要服务于分类、自然语言推断（Natural Language Inference, NLI）、命名实体识别（Named Entity Recognition, NER）、抽取式问答等理解任务；第二条是专门为句向量、检索、聚类和召回设计的嵌入模型。前者仍沿着 BERT 家族演化，后者则越来越多地直接采用 BGE、E5、Qwen3-Embedding、jina-embeddings 这类专门路线。因此，讨论“更好的表示模型”时，必须先区分目标到底是任务头微调，还是直接产出高质量向量表示。</p>
<div class="blog_h3"><span class="graybg">BERT</span></div>
<p>BERT（Bidirectional Encoder Representations from Transformers）是典型的 Encoder-only 模型：用双向注意力做表示学习，预训练目标以掩码语言建模（Masked Language Modeling, MLM）为主。原始 BERT 还包含下一句预测（Next Sentence Prediction, NSP）任务：输入通常写成句段 A [SEP] 句段 B，模型需要根据最终的 <pre class="crayon-plain-tag">[CLS]</pre> 表示判断 B 是否真的是 A 在原语料中的下一句，而不是随机抽来的另一句。与 Word2Vec 常见的负采样训练不同，BERT 的核心预训练是在被遮住的位置上直接做词表预测；而 NSP 则为句级判别额外提供了一条监督路径。输入序列开头通常会加入一个特殊的 <pre class="crayon-plain-tag">[CLS]</pre> token，用来聚合整段输入的信息；经过编码后，这个位置的输出隐藏状态常被当作整个序列的语义摘要，并接到分类头上用于文本分类、自然语言推断（NLI）等下游任务。</p>
<p>但这里必须把两件事分开。<pre class="crayon-plain-tag">[CLS]</pre> 很适合做<span style="background-color: #c0c0c0;">任务读出位置（readout position）</span>，却不天然等于“最好的通用句向量”。它之所以能被拿来接分类头，首先是因为它在结构上位于序列最前面，经过每一层双向自注意力后，都可以从整句其它 token 汇聚信息；其次是因为原始 BERT 的预训练里确实给过它专门监督：在下一句预测（Next Sentence Prediction, NSP）任务中，最终的分类就是直接读 <pre class="crayon-plain-tag">[CLS]</pre> 的输出隐藏状态，再接一个二分类头。也就是说，<pre class="crayon-plain-tag">[CLS]</pre> 从一开始就被当成“适合给任务头读取”的位置来训练。正因为如此，原始 BERT 的 <pre class="crayon-plain-tag">[CLS]</pre> 既是首位置隐藏状态，也是被句级判别任务直接塑形过的读出接口。</p>
<p>不过，<pre class="crayon-plain-tag">[CLS]</pre> 的训练目标并不是“把整句压缩成一个适合做余弦相似度的几何向量”。在 MLM（Masked Language Modeling）中，直接受监督的是被 mask 的 token 位置，而不是整句的句向量质量；<pre class="crayon-plain-tag">[CLS]</pre> 只会通过多层自注意力间接参与这些预测。在 NSP 里，它学到的是“这一对句子是否连续”这种特定判别目标，而不是“语义相近的句子在向量空间中应彼此靠近”。因此，<pre class="crayon-plain-tag">[CLS]</pre> 更像是为分类器准备的汇总接口，而不是为检索、聚类或最近邻搜索专门对齐过的句表示。</p>
<p>这也是它不能自然代替显式池化（Explicit Pooling）的根本原因。显式池化会把所有 token 的隐藏状态用平均、最大值或加权汇聚的方式整合成句向量，例如平均池化可写成</p>
<span displaypfx="" class="mathjax-container">\[e(x)=\frac{1}{n}\sum_{i=1}^{n} h_i\]</span>
<p>这里每个 token 的最终表示都会直接进入句向量构造过程；而 <pre class="crayon-plain-tag">[CLS]</pre> 路线则把整句压缩任务隐含地交给某一个特殊位置去完成，相当于要求模型把所有句级信息都写入单个状态向量中。对分类任务，这种单点读出通常足够，因为后面还有任务头继续适配；但对通用句嵌入，这种“单位置承担全部汇总”的方式往往不如显式池化稳定，也更容易受到预训练目标偏置的影响。</p>
<p>从几何上看，这个差异会进一步表现为表示各向异性（Anisotropy）：原始 BERT 的 <pre class="crayon-plain-tag">[CLS]</pre> 向量常常集中在高维空间的少数主方向上，不同句子的向量分布会显得过于拥挤，余弦相似度缺乏足够区分度。显式池化本身并不能自动解决所有问题，但它至少把“句向量由哪些 token 共同构成”这件事写成了可控、透明的操作；一旦再叠加 Sentence-BERT 这类句对监督或对比学习目标，模型就会直接围绕池化后的句向量去优化距离结构，而不是依赖 <pre class="crayon-plain-tag">[CLS]</pre> 在预训练阶段顺带形成的间接汇总能力。</p>
<p>BERT 以及其他表示型语言模型通常先在海量通用语料上做预训练，从而学到词法模式、句法结构、语义关系以及一定程度的世界知识。正因为这些知识不是为某一个具体任务单独学习出来的，它们非常适合作为通用特征提取器（General-purpose Feature Extractor）：在迁移学习框架下，只需接上分类头、序列标注头或匹配头，并在目标任务数据上继续微调，就可以把通用表示快速适配到具体自然语言处理任务。</p>
<p>BERT系列“是否支持中文”关键在词表与分词器（Tokenizer）。英文 BERT-base 的 WordPiece 词表主要覆盖英文子词；对中文文本可能会大量落到 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{UNK}]\)</span> 或被切成极碎片段，效果通常不理想。要做中文任务更常用中文 BERT、mBERT（multilingual BERT）或以 SentencePiece 为主的多语模型。</p>
<div class="blog_h3"><span class="graybg">RoBERTa</span></div>
<p>RoBERTa（Robustly Optimized BERT Approach）延续 BERT 的 Encoder-only 架构，但通过更大规模数据与训练配方改进（例如更长训练、更大 batch、动态 masking、移除或弱化 NSP 等）显著提升表示质量。它的工程意义在于：<span style="background-color: #c0c0c0;">在不改变基本架构的前提下，训练细节足以带来可观收益</span>。</p>
<p>RoBERTa 证明了一个重要事实：Encoder-only 模型的性能上限，不完全取决于“是否换了新架构”，训练数据规模、batch 策略、masking 方式和目标设计本身就足以显著改变表示质量。</p>
<div class="blog_h3"><span class="graybg">ELECTRA</span></div>
<p>ELECTRA（Efficiently Learning an Encoder that Classifies Token Replacements Accurately）仍属于 Encoder-only 模型，但它不再让主模型只做“把被遮住的词猜出来”的掩码语言建模（Masked Language Modeling, MLM）。它先用一个较小的生成器替换部分 token，再让主模型判断每个位置上的 token 是原词还是替换词，这就是替换检测（Replaced Token Detection, RTD）。这种训练方式让模型几乎在每个 token 上都获得监督信号，因此在相近预训练预算下通常比纯 MLM 更高效。</p>
<div class="blog_h3"><span class="graybg">DeBERTa / DeBERTa-V3</span></div>
<p>DeBERTa（Decoding-enhanced BERT with Disentangled Attention）可以看作对 BERT / RoBERTa 的一次结构级强化。它仍然是典型的判别式 Encoder-only 语言模型，因此特别适合文本分类、自然语言推断（Natural Language Inference, NLI）、命名实体识别（Named Entity Recognition, NER）和情感分析等理解类任务；但它不满足于只靠“更大数据、更长训练”来变强，而是直接对注意力机制本身做了精细改造。</p>
<p>它最核心的突破是解耦注意力（Disentangled Attention）。在传统 BERT 中，词的内容信息与位置信息通常会较早融合；DeBERTa 则把“这个 token 是什么”和“这个 token 在哪里”分开处理。对位置 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 的注意力打分，可直观写成三部分：</p>
<span displaypfx="" class="mathjax-container">\[A_{i,j}=\underbrace{C_iC_j^\top}_{\text{内容-内容}}+\underbrace{C_iP_{j|i}^\top}_{\text{内容-相对位置}}+\underbrace{P_{i|j}C_j^\top}_{\text{相对位置-内容}}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(C_i\)</span> 表示第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个位置的内容表示（Content Representation），<span displaypfx="inline-" class="mathjax-container">\(P_{j|i}\)</span> 和 <span displaypfx="inline-" class="mathjax-container">\(P_{i|j}\)</span> 表示相对位置表示（Relative Position Representation）。这个拆分的意义在于：模型不必把“词义”和“位置”混成一个整体再去匹配，而可以更精细地学习“哪个内容会关注什么位置关系”。对于短语修饰、依存关系和实体边界判定这类任务，这种改造往往更有利。</p>
<p>DeBERTa 的第二个关键设计是增强型掩码解码器（Enhanced Mask Decoder, EMD）。它的动机是：相对位置固然重要，但在掩码预测时，绝对位置有时也能提供额外信息。因此 DeBERTa 在接近输出端的位置再次显式注入绝对位置信息，让模型在做 MLM 预测时同时利用相对与绝对位置信号。这相当于在保持主体编码过程解耦的前提下，在最后的“读答案”阶段补上一层位置提醒。</p>
<p>从工程结果看，DeBERTa 长期是判别式 NLU 的强基线之一。它的重要性不只在于“分数更高”，还在于它说明了一个事实：<span style="background-color: #c0c0c0;">Encoder-only 模型的上限不只是由数据和算力决定，内容表示、位置表示和解码方式本身的结构设计也会显著影响表示质量</span>。后续的 DeBERTa-V3 又把替换检测（RTD）这类更高效的预训练目标引入进来，进一步改善了训练效率与效果，使其在很多精细理解任务里依然保持很强竞争力。</p>
<div class="blog_h3"><span class="graybg">ModernBERT</span></div>
<p>ModernBERT 代表的是 BERT 风格双向编码器在 2025 年之后的一次现代化重写。它仍然属于 Encoder-only，但不再停留在 2018 年 BERT 的上下文长度、注意力实现和推理路径上，而是把长上下文支持与现代高效实现直接纳入底座设计。其官方模型卡给出的关键信息包括：预训练规模达到 2T tokens，原生上下文长度扩展到 8192 token，并引入 RoPE、本地-全局交替注意力（Local-Global Alternating Attention）、unpadding 和 Flash Attention 等现代工程机制。</p>
<p>与原始 BERT 还有一个重要区别在于 <pre class="crayon-plain-tag">[CLS]</pre> 的默认训练来源。ModernBERT 官方定位是纯粹的掩码语言模型（Masked Language Model, MLM）：预训练时默认存在的是 MLM head，而不是像原始 BERT 那样再额外叠加 NSP 这种句级二分类目标。这意味着 ModernBERT 的 <pre class="crayon-plain-tag">[CLS]</pre> 主要是作为首位置隐藏状态在 MLM 路线下间接形成的可读出表示，而不是被预训练阶段的句级分类任务直接塑形过的“现成分类接口”。因此，ModernBERT 当然仍然适合做分类，但更准确的理解应是：它提供了一个现代化、长上下文、可高效微调的编码底座；真正的分类头通常仍要在下游任务里显式接上并训练出来。</p>
<p>这类改造的意义非常直接。传统 BERT 家族最舒服的输入长度通常仍停留在几百 token 到一两千 token 量级，而 ModernBERT 这一路则把长文档分类、长文本检索、代码检索和大段上下文理解一并纳入可用范围。于是，在英文或以英文 / 代码为主的判别式任务里，ModernBERT 往往比 DeBERTa-V3 更接近 2026 年的默认新基线；DeBERTa-V3 仍然是高质量经典强基线，但 ModernBERT 明显更符合当前对长上下文与吞吐效率的要求。</p>
<p>它的交替局部-全局注意力也进一步改变了人们对 <pre class="crayon-plain-tag">[CLS]</pre> 的直觉。经典 BERT 可以把 <pre class="crayon-plain-tag">[CLS]</pre> 看成每层都在做全局汇总的位置；ModernBERT 则只在周期性的全局层里进行真正的全局信息混合，局部层更强调效率与长序列处理能力。因此，把 ModernBERT 的 <pre class="crayon-plain-tag">[CLS]</pre> 直接理解成“天然全局句向量”会更加不准确；它更像一个可被下游读取的首位置表示，而不是默认就已经为通用句嵌入优化好的几何向量。</p>
<div class="blog_h3"><span class="graybg">多语表示模型</span></div>
<p>多语表示模型的选型逻辑与英文单语场景并不完全相同。XLM-R（XLM-RoBERTa）仍然是经典多语 Encoder-only 基线之一：其预训练覆盖 100 种语言，核心价值是把多语文本映射到共享表示空间，再用于序列分类、序列标注和问答等下游任务。mDeBERTa-V3 则把 DeBERTa-V3 的结构与训练目标迁移到多语场景，在跨语言自然语言推断等零样本迁移任务上，相比 XLM-R-base 给出更强的官方基线结果。因此，若目标是稳定、成熟、适合任务头微调的多语编码器，XLM-R 与 mDeBERTa-V3 依然是 2026 年非常现实的主流选择。</p>
<p>更前沿的一支则沿着 ModernBERT 的方向继续推进。2025 年发布的 mmBERT 直接把 ModernBERT 的现代编码器路线扩展到大规模多语场景，官方介绍强调其训练覆盖 3T 以上 token 和 1800 多种语言，并把它定位为首个在性能和速度上同时明显超过 XLM-R 的新一代多语编码器。这说明多语表示学习也正在从“经典 XLM-R 世代”向“ModernBERT 世代”迁移，只是就生态成熟度与工程复用性而言，XLM-R / mDeBERTa-V3 仍然更稳，mmBERT 更像下一阶段的高端新底座。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">架构</td>
<td style="text-align: center;">预训练目标</td>
<td style="text-align: center;">特点</td>
<td style="text-align: center;">常见用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>BERT</td>
<td>Encoder-only</td>
<td>MLM（+ NSP 变体）</td>
<td>经典通用基线；生态成熟</td>
<td>分类、匹配、序列标注</td>
</tr>
<tr>
<td>RoBERTa</td>
<td>Encoder-only</td>
<td>MLM</td>
<td>更强训练配方；经典英文 NLU 强基线</td>
<td>理解类任务强基线</td>
</tr>
<tr>
<td>ELECTRA</td>
<td>Encoder-only</td>
<td>替换检测（Replaced Token Detection）</td>
<td>训练效率高；对小算力友好</td>
<td>理解类任务、低成本预训练</td>
</tr>
<tr>
<td>DeBERTa-V3</td>
<td>Encoder-only</td>
<td>MLM / RTD</td>
<td>解耦注意力 + 更高效预训练；经典高精度 NLU 底座</td>
<td>高精度 NLU、NER、文本分类</td>
</tr>
<tr>
<td>ModernBERT</td>
<td>Encoder-only</td>
<td>MLM</td>
<td>2T tokens 预训练、8192 上下文、现代高效实现</td>
<td>长文档分类、长文本理解、代码检索、现代英文编码任务</td>
</tr>
<tr>
<td>XLM-R</td>
<td>多语 Encoder-only</td>
<td>MLM</td>
<td>100 语言经典共享表示空间；多语生态成熟</td>
<td>多语分类、NER、问答、跨语言迁移</td>
</tr>
<tr>
<td>mDeBERTa-V3</td>
<td>多语 Encoder-only</td>
<td>MLM / RTD</td>
<td>DeBERTa-V3 的多语延伸；零样本迁移更强</td>
<td>多语 NLU、多语分类、跨语言推断</td>
</tr>
<tr>
<td>Qwen3-Embedding / BGE-M3 / multilingual-e5 / jina-embeddings-v3</td>
<td>专用表示模型</td>
<td>对比学习 / 指令化检索 / 检索特化目标</td>
<td>不以任务头分类为首要目标，而是直接优化向量空间质量</td>
<td>语义检索、聚类、召回、RAG、跨语言匹配</td>
</tr>
</tbody>
</table>
<p>因此，到 2026 年若任务是分类、序列标注、NLI 或抽取式问答，主流候选通常已经变成 DeBERTa-V3、ModernBERT，以及多语场景下的 XLM-R / mDeBERTa-V3；若任务本质上是检索、聚类、向量召回或 RAG，则应直接转向后文的主流嵌入模型，而不是继续把通用 Encoder-only 分类底座当作最优句向量来源。</p>
<div class="blog_h2"><span class="graybg">主流生成式语言模型</span></div>
<div class="blog_h3"><span class="graybg">GPT 系列</span></div>
<p>GPT 系列是典型 Decoder-only：以 CLM（自回归 next-token）为主目标，把各种任务统一为“续写”。工程上其优势来自统一接口与强 in-context learning；代价是推理成本高（尤其长上下文与高并发场景）。</p>
<div class="blog_h3"><span class="graybg">LLaMA</span></div>
<p>LLaMA 系列是开源 Decoder-only 基座的重要代表，强调稳定的训练配方与生态可用性（tokenizer、推理支持、微调社区）。它常作为“可控、可复现”的研究/工程基线，用于 SFT、LoRA、RAG 与本地部署。</p>
<div class="blog_h3"><span class="graybg">Qwen</span></div>
<p>Qwen 系列同属开源 Decoder-only 生态，中文与多语言能力通常更受关注；在工具调用、代码与多模态等方向也有较多衍生版本。工程上，它常被用作中文业务与多语言场景的基座候选。</p>
<div class="blog_h3"><span class="graybg">Mistral</span></div>
<p>Mistral 系列强调“更高性价比的推理与训练”：通过架构与工程优化在相近成本下获得更强的生成质量与吞吐表现，常见于高并发推理与轻量级部署场景。</p>
<div class="blog_h3"><span class="graybg">DeepSeek</span></div>
<p>DeepSeek 系列更强调训练与推理效率的极致：在注意力 KV 压缩、稀疏结构（MoE）与训练目标（如 Multi-Token Prediction）等方向探索更激进的工程取舍，目标是在同等算力预算下提升有效容量与上下文能力。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">家族</td>
<td style="text-align: center;">定位</td>
<td style="text-align: center;">常见优势</td>
<td style="text-align: center;">常见代价</td>
</tr>
</thead>
<tbody>
<tr>
<td>LLaMA</td>
<td>稳健开源基座</td>
<td>生态成熟；微调与推理支持广</td>
<td>配置与版本较多，需选对上下文/推理配方</td>
</tr>
<tr>
<td>Qwen</td>
<td>多语言/中文友好基座</td>
<td>中文场景覆盖好；衍生模型多</td>
<td>需关注 tokenizer/指令对齐数据分布</td>
</tr>
<tr>
<td>Mistral</td>
<td>高性价比推理</td>
<td>吞吐与质量兼顾；工程落地友好</td>
<td>不同版本/配方差异会影响最佳实践</td>
</tr>
<tr>
<td>DeepSeek</td>
<td>效率优先（MoE/压缩）</td>
<td>在算力/显存约束下追求更强能力</td>
<td>架构复杂度更高；推理与部署依赖实现细节</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">主流嵌入模型</span></div>
<div class="blog_h3"><span class="graybg">Word2Vec（CBOW / Skip-gram）</span></div>
<p>Word2Vec 用一个简单的自监督目标（Self-supervised Objective）学习词向量（Word Embeddings）：监督信号来自文本的共现上下文（Context），而不是人工标签。</p>
<p>它可以理解为学习两个嵌入表：输入词向量 <span displaypfx="inline-" class="mathjax-container">\(V\in\mathbb{R}^{|{\cal V}|\times d}\)</span> 与输出词向量 <span displaypfx="inline-" class="mathjax-container">\(U\in\mathbb{R}^{|{\cal V}|\times d}\)</span>（<span displaypfx="inline-" class="mathjax-container">\(|{\cal V}|\)</span> 为词表大小）。训练结束后，常用 <span displaypfx="inline-" class="mathjax-container">\(V\)</span>（或 <span displaypfx="inline-" class="mathjax-container">\((V+U)/2\)</span>）作为词向量。</p>
<p>Word2Vec 有两种经典训练方式：CBOW（Continuous Bag of Words）根据上下文预测中心词，Skip-gram 则根据中心词预测上下文。二者的预测方向相反，但都利用局部共现关系学习词向量。下面以 Skip-gram 为例说明它最核心的训练机制。</p>
<p>Word2Vec 的样本不是人工标注出来的，而是通过<span style="background-color: #c0c0c0;">滑动窗口（Sliding Window）</span>在语料上自动生成。设窗口半径为 <span displaypfx="inline-" class="mathjax-container">\(m\)</span>，当滑动窗口扫到位置 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 时，中心词 <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span> 会与它左右 <span displaypfx="inline-" class="mathjax-container">\(m\)</span> 个位置内的上下文词组成正样本对；越靠近句首句尾，可用上下文自然会变少。对单个中心词，Skip-gram 的局部目标可写成：</p>
<span displaypfx="" class="mathjax-container">\[\sum_{-m\le j\le m,\ j\ne 0}\log p(x_{t+j}\mid x_t)\]</span>
<p>这意味着：窗口每向前滑动一步，模型就把“中心词与邻近词共同出现”当作新的监督信号。CBOW 则反过来，把窗口内多个上下文词聚合起来预测中心词；但两者的训练样本都来自同一套滑动窗口机制。</p>
<p>在 Skip-gram 中，给定中心词（center word）<span displaypfx="inline-" class="mathjax-container">\(w_c\)</span>，预测窗口内的上下文词（context word）<span displaypfx="inline-" class="mathjax-container">\(w_o\)</span>。若用 full softmax：</p>
<span displaypfx="" class="mathjax-container">\[p(w_o|w_c)=\frac{\exp\left(u_{w_o}^\top v_{w_c}\right)}{\sum_{w\in{\cal V}}\exp\left(u_{w}^\top v_{w_c}\right)}\]</span>
<p>如果保留这个完整 softmax，模型会被迫在整个词表上做归一化比较；真实上下文概率升高时，其他词的概率就必须相应下降。但如果改成更便宜的“只学习哪些词对是真的”而又只提供正样本，模型就会立刻出现投机空间：它完全可以把几乎所有词对都打成高分，因为目标里从来没有人告诉它哪些配对是假的。换句话说，<span style="background-color: #c0c0c0;">只基于正样本训练一个二分类式共现目标，最容易学到的不是语义结构，而是“永远预测真”</span>。</p>
<p>因此，实践里常用负采样（Negative Sampling）：对每个正样本对 <span displaypfx="inline-" class="mathjax-container">\((w_c,w_o)\)</span>，再随机采样 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个噪声词 <span displaypfx="inline-" class="mathjax-container">\(w_k\)</span>，把 <span displaypfx="inline-" class="mathjax-container">\((w_c,w_k)\)</span> 当作负样本，并最大化</p>
<span displaypfx="" class="mathjax-container">\[\log\sigma(u_{w_o}^\top v_{w_c})+\sum_{k=1}^{K}\log\sigma(-u_{w_k}^\top v_{w_c})\]</span>
<p>第一项要求真实共现词对的内积更大，第二项要求随机噪声词对的内积更小。这样模型学到的就不再是“所有词都彼此相似”，而是“哪些词在局部上下文里更可能共同出现”。这里的“模型参数”主要就是这些词向量；模型输入通常是 token id（或 one-hot）经查表得到的 <span displaypfx="inline-" class="mathjax-container">\(v_{w_c}\)</span>。</p>
<p>Word2Vec 的负样本通常也不需要过度精心设计。经典做法就是按词频分布的 <span displaypfx="inline-" class="mathjax-container">\(0.75\)</span> 次方做随机采样，让高频词仍然更常被抽到，但不会垄断全部负样本。它的核心目标不是构造“最难反例”，而是持续给模型提供足够多的噪声对照，让真实共现和随机拼接之间产生可学习的区分。后来的对比学习常会显式挖掘 hard negatives，但在经典 Word2Vec 里，简单而稳定的随机负采样通常已经足够有效。</p>
<div class="blog_h3"><span class="graybg">GloVe</span></div>
<p>GloVe（Global Vectors for Word Representation）用全局共现统计学习词向量：目标是让词向量的内积拟合共现概率（或其对数）。与 Word2Vec 相比，它更强调全局统计的一致性；但二者都属于“静态词向量”（Static Embedding），无法像 Transformer 那样根据上下文动态改变词义表示。</p>
<div class="blog_h3"><span class="graybg">Sentence-BERT</span></div>
<p>Sentence-BERT（SBERT）是文本嵌入（Text Embedding）中的经典双编码器（Bi-Encoder / Dual Encoder）范式。它与后面的 text-embedding 系列并不是两种不同任务；二者都把文本映射为向量，用于相似度计算、检索、聚类与召回。区别主要在于：SBERT 更像一条开源方法路线，而 text-embedding 系列更像近年的通用嵌入模型或 API 产品家族。</p>
<div class="blog_h4"><span class="graybg">交叉编码（Cross-Encoder）</span></div>
<p>在 SBERT 之前，句子嵌入任务通常沿用<span style="background-color: #c0c0c0;">交叉编码器（Cross-Encoder）+ BERT</span> 的范式实现相似度建模：把两个句子同时输入 Transformer 网络，常见形式是将句子 A 与句子 B 拼接成单个序列，中间用分隔符隔开，然后在原始 BERT 顶部增加分类头或回归头，直接输出这一对句子的相似度分数。这种架构的优势在于两个句子的 token 可以在同一次自注意力计算中充分交互，因此非常擅长做细粒度匹配判断；但它输出的是“句对分数”，而不是两个可独立复用的句向量。</p>
<p>这会直接带来大规模计算问题。若要在一个包含 10000 个句子的集合中找出相似度最高的匹配对，交叉编码器原则上需要对几乎所有句对分别做一次联合编码，计算量约为 <span displaypfx="inline-" class="mathjax-container">\(\frac{10000\times 9999}{2}=49{,}995{,}000\)</span> 次前向比较。也就是说，问题规模从“编码 10000 个句子”膨胀成了“编码近五千万个句对”。由于每个候选句子都必须与其他句子重新拼接、重新过一遍 BERT，这类方法几乎无法承担大规模语义检索、聚类或召回的第一阶段计算。SBERT 的关键突破，正是在保留 BERT 语义建模能力的同时，把句子表示改造成可预先编码、可缓存、可直接做余弦相似度比较的独立向量。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/cross-encoder.jpg"><img class="alignnone size-full wp-image-41927" src="https://blog.gmem.cc/wp-content/uploads/2026/03/cross-encoder.jpg" alt="cross-encoder" width="1024" height="1024" /></a></p>
<div class="blog_h4"><span class="graybg">双编码（Bi-Encoder）与孪生网络</span></div>
<p>因此，SBERT 的核心价值首先体现在计算结构的改变上：它把原本“对句对打分”的问题，改写成“分别编码句子，再比较向量距离”的问题。这样一来，候选句子可以预先编码并缓存，检索阶段只需要做向量相似度计算，而不必让每一对候选都重新经过一次完整的 BERT 联合编码。</p>
<p>它与 Cross-Encoder 的关键差异在于计算结构：</p>
<ul>
<li>双编码器：两个输入分别编码，可离线预计算候选向量，适合大规模检索（ANN）。</li>
<li>交叉编码器（Cross-Encoder）：把两个输入拼接后一起编码，匹配更精细但无法离线索引，适合重排序（Reranking）。</li>
</ul>
<p>在结构上，SBERT 的核心是<span style="background-color: #c0c0c0;">孪生架构（Siamese Architecture）</span>：两侧使用一模一样的编码器，参数完全共享，但分别接收一个句子作为输入。训练时，句子对会分别经过这两个共享权重的编码塔，各自得到固定维度的句向量，再基于余弦相似度、三元组损失（Triplet Loss）或对比损失（Contrastive Loss）优化距离关系。共享权重保证了两个句子被投射到同一个表示空间中，因此向量之间的距离才具有可比较性。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/sbert.png"><img class="alignnone size-full wp-image-41879" src="https://blog.gmem.cc/wp-content/uploads/2026/03/sbert.png" alt="sbert" width="1280" height="1280" /></a></p>
<div class="blog_h4"><span class="graybg">训练方式</span></div>
<p>SBERT 的训练过程可以拆成两个步骤。第一步是<span style="background-color: #c0c0c0;">把每个句子单独编码成向量</span>。设句子 <span displaypfx="inline-" class="mathjax-container">\(x=(t_1,\dots,t_n)\)</span>，经过共享编码器后得到逐 token 的隐藏状态 <span displaypfx="inline-" class="mathjax-container">\(H=[h_1,\dots,h_n]\)</span>；随后用池化（Pooling）把它压缩成句向量 <span displaypfx="inline-" class="mathjax-container">\(e(x)\in\mathbb{R}^d\)</span>。经典实现最常用平均池化：</p>
<span displaypfx="" class="mathjax-container">\[e(x)=\frac{1}{n}\sum_{i=1}^{n} h_i\]</span>
<p>有些实现还会继续做 L2 归一化，得到 <span displaypfx="inline-" class="mathjax-container">\(\hat e(x)=e(x)/\|e(x)\|_2\)</span>，以便后续直接用余弦相似度比较方向。这里的关键点是：左右两侧并不是两套不同模型，而是同一组参数共享的编码器分别处理句子 A 和句子 B，最终得到 <span displaypfx="inline-" class="mathjax-container">\(\hat e_a\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\hat e_b\)</span> 两个可独立复用的句向量。</p>
<p>训练语料的形状也必须与损失函数匹配。最常见的几类数据形式如下：</p>
<ul>
<li><span style="background-color: #c0c0c0;">带连续分数的句对</span>：形如 <span displaypfx="inline-" class="mathjax-container">\((x_a,x_b,y)\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 是 0 到 1、或 0 到 5 再归一化后的语义相似度分数。这类数据最适合 STS（Semantic Textual Similarity）任务，监督信号不是“是不是同类”，而是“到底有多像”。</li>
<li><span style="background-color: #c0c0c0;">二元正负句对</span>：形如 <span displaypfx="inline-" class="mathjax-container">\((x_a,x_b,y)\)</span>，其中 <span displaypfx="inline-" class="mathjax-container">\(y\in\{0,1\}\)</span>。这里 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span> 表示复述句、同义问法、相关 query-document 或其它正样本对；<span displaypfx="inline-" class="mathjax-container">\(y=0\)</span> 表示语义无关、错误匹配或人工拒绝的负样本对。</li>
<li><span style="background-color: #c0c0c0;">检索式正配对</span>：形如 <span displaypfx="inline-" class="mathjax-container">\((q,p)\)</span>，只显式给出 query 与其正确匹配的正样本，不单独列出负样本。训练时，通常把同一 batch 中其它 <span displaypfx="inline-" class="mathjax-container">\(p_j\)</span> 当作 <span displaypfx="inline-" class="mathjax-container">\(q_i\)</span> 的负例，这就是批内负样本（In-batch Negatives）的基本数据组织方式。</li>
<li><span style="background-color: #c0c0c0;">三元组</span>：形如 <span displaypfx="inline-" class="mathjax-container">\((a,p,n)\)</span>，其中锚点 <span displaypfx="inline-" class="mathjax-container">\(a\)</span> 与正样本 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 应靠近，而与负样本 <span displaypfx="inline-" class="mathjax-container">\(n\)</span> 应拉开距离。这类数据天然适合排序与检索，因为它直接表达了“哪个比哪个更相关”。</li>
</ul>
<p>因此，SBERT 训练并不要求数据必须带精确分数；它既可以吃连续相似度打分，也可以吃正负标签，甚至只需要 query-positive 配对或三元组。真正关键的是，训练样本必须能清楚告诉模型：哪些句子应该靠近，哪些句子应该远离，以及这种关系是绝对打分还是相对排序。</p>
<p>第二步是<span style="background-color: #c0c0c0;">根据标注关系定义损失</span>。若训练数据给的是连续相似度分数，例如 0 到 1 之间的语义相似度标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>，最直接的做法是先计算两向量的余弦相似度</p>
<span displaypfx="" class="mathjax-container">\[s(\hat e_a,\hat e_b)=\cos(\hat e_a,\hat e_b)=\frac{\hat e_a^\top \hat e_b}{\|\hat e_a\|_2\|\hat e_b\|_2}\]</span>
<p>再让模型输出的相似度逼近人工标签，例如最简单的回归式目标可以写成</p>
<span displaypfx="" class="mathjax-container">\[L=(s(\hat e_a,\hat e_b)-y)^2\]</span>
<p>这时，相似句子的标签 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 更高，损失会推动两向量余弦相似度上升；不相似句子的标签更低，损失则推动相似度下降。原始 SBERT 在 STS（Semantic Textual Similarity）类任务上，常用的正是这种“句对回归到相似度分数”的训练方式。</p>
<p>若训练数据只有二元标签，即“相似”或“不相似”，则更常见的是对比式损失（Contrastive Loss）。设距离 <span displaypfx="inline-" class="mathjax-container">\(d(\hat e_a,\hat e_b)\)</span> 可以取欧氏距离，也可以取 <span displaypfx="inline-" class="mathjax-container">\(1-\cos(\hat e_a,\hat e_b)\)</span>，则典型形式为</p>
<span displaypfx="" class="mathjax-container">\[L=y\cdot d(\hat e_a,\hat e_b)^2+(1-y)\cdot \max(0,m-d(\hat e_a,\hat e_b))^2\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(y=1\)</span> 表示正样本对，损失会逼迫距离变小；<span displaypfx="inline-" class="mathjax-container">\(y=0\)</span> 表示负样本对，损失会要求两句至少相隔一个 margin <span displaypfx="inline-" class="mathjax-container">\(m\)</span>。这就把“相似句子拉近，不相似句子推远”写成了显式几何约束。</p>
<p>若任务更接近检索或召回，现代实践更常用批内负样本（In-batch Negatives）或多负样本排序损失（Multiple Negatives Ranking Loss）。设一个 batch 中第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个查询句子的正样本是 <span displaypfx="inline-" class="mathjax-container">\(p_i\)</span>，其余 <span displaypfx="inline-" class="mathjax-container">\(p_j\)</span> 都视为负例，则可写成</p>
<span displaypfx="" class="mathjax-container">\[L_i=-\log \frac{\exp(s(\hat e_{q_i},\hat e_{p_i})/\tau)}{\sum_{j=1}^{B}\exp(s(\hat e_{q_i},\hat e_{p_j})/\tau)}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\tau\)</span> 是温度（Temperature）参数。这个目标并不需要显式为每个查询单独准备大量负样本，而是直接把同一 batch 里的其它正样本当作自己的负样本；于是优化方向很清楚：正确配对的句子相似度必须高于 batch 中所有错误配对。许多现代 embedding 模型，包括大量 SBERT 派生模型，都是沿着这条路线训练出来的。</p>
<p>另一类经典形式是三元组损失（Triplet Loss）。它不再只看一对句子，而是同时给出锚点 <span displaypfx="inline-" class="mathjax-container">\(a\)</span>、正样本 <span displaypfx="inline-" class="mathjax-container">\(p\)</span> 和负样本 <span displaypfx="inline-" class="mathjax-container">\(n\)</span>，要求锚点与正样本的距离小于锚点与负样本的距离，且至少留出一个 margin：</p>
<span displaypfx="" class="mathjax-container">\[L=\max\bigl(0,\ d(\hat e_a,\hat e_p)-d(\hat e_a,\hat e_n)+m\bigr)\]</span>
<p>它表达的仍然是同一个原则：相似句子靠近，不相似句子远离；只是监督信号从“单对标签”变成了“相对排序关系”。</p>
<p>前文已经提到，原始 BERT 的 <pre class="crayon-plain-tag">[CLS]</pre> 表示更适合接分类头，而不是直接充当高质量通用句向量；同样地，若未经句向量任务优化就简单平均最后一层表示，效果通常也不理想。SBERT 的关键改动，正是在共享权重的双编码训练框架中，把池化和句对监督显式写入训练过程，使生成出来的 <span displaypfx="inline-" class="mathjax-container">\(e(x)\)</span> 不再只是“顺手拿来的中间表示”，而是被专门优化成适合做余弦相似度、最近邻检索与聚类的句向量。其改进原因之一，正是缓解了原始 BERT 表示的各向异性（Anisotropy）问题：向量不再高度挤在高维空间的少数方向上，余弦相似度也因此更有区分度。</p>
<p>这里也需要区分 Sentence-BERT 与 sentence-transformers。前者更严格地指 2019 年提出的 SBERT 方法路线：在 BERT、RoBERTa 等编码器基础上，通过孪生网络（Siamese Network）/三元组网络（Triplet Network）或后续对比式训练，把原本不适合作为通用句向量的表示空间改造成更适合相似度计算的句向量空间。后者则主要指围绕这一路线发展出来的开源框架与模型生态，用于统一训练、评测、发布和调用各类句向量模型。因此，sentence-transformers 不是与 SBERT 并列的另一类基础模型，而更像 SBERT 方法在工程上的延伸与集合。</p>
<p>从工程角度看，SBERT 仍然有明确实用价值：它特别适合私有化部署、领域微调、本地低延迟语义检索，以及“双编码器召回 + Cross-Encoder 重排”的两阶段检索流水线。它不是被现代 embedding 模型淘汰，而是在开源可控、可微调、可离线部署这些约束下依然非常常用。</p>
<div class="blog_h3"><span class="graybg">text-embedding 系列</span></div>
<p>如果把 SBERT 看作“如何训练和使用句向量”的经典范式，那么 text-embedding 系列代表的就是近年的通用嵌入模型实现。BGE、E5、GTE、OpenAI 的 text-embedding、Cohere Embed 等，本质上都属于同一任务族：生成可用于相似度、检索、聚类、召回的向量表示。它们的主要差异不在“是否属于 embedding”，而在训练数据、模型规模、多语言能力、上下文长度、向量维度压缩策略，以及是开源模型还是托管 API 服务。</p>
<p>通用嵌入模型（General-purpose Embedding Model）的目标通常是“语义相似近、语义无关远”，因此天然适配检索与聚类。也可以把嵌入模型微调成“任务特化嵌入”（Task-specialized Embedding）：用监督标签构造正/负样本对（同类为正、异类为负），用对比学习目标把同类拉近、异类推远，然后用最近邻/类原型（Prototype）实现分类。</p>
<p>与“表示模型 + 分类头（Representation Model + Classifier Head）”相比，二者取舍通常是：</p>
<ul>
<li>特化嵌入：推理时只算一次向量 + 相似度，便于大规模检索/多标签扩展；但输出是距离分数，概率校准与细粒度判别能力通常不如专门的分类头。</li>
<li>分类头微调：直接最小化分类损失，闭集分类效果与可解释的概率输出更强；但对大规模候选检索不友好，且不同任务往往需要不同 head。</li>
</ul>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center;">代表模型/服务</td>
<td style="text-align: center;">特点</td>
<td style="text-align: center;">适用场景</td>
<td style="text-align: center;">备注</td>
</tr>
</thead>
<tbody>
<tr>
<td>开源通用嵌入（General-purpose）</td>
<td>BGE / E5 / GTE</td>
<td>部署可控；可做领域微调</td>
<td>私有化 RAG；向量检索；聚类</td>
<td>选型关注多语言、长度与 license</td>
</tr>
<tr>
<td>商用 API 嵌入</td>
<td>text-embedding-3 / Cohere Embed</td>
<td>效果稳定；无需运维</td>
<td>快速上线；跨团队复用</td>
<td>成本与数据合规是主约束</td>
</tr>
<tr>
<td>领域特化嵌入（Task-specialized）</td>
<td>对比学习微调后的 embedding</td>
<td>对业务分布拟合更强</td>
<td>垂直领域检索；闭集分类</td>
<td>需要高质量正/负样本构造</td>
</tr>
<tr>
<td>多向量/late interaction</td>
<td>ColBERT 类</td>
<td>token-level 匹配更细</td>
<td>高精度检索；精排候选压缩</td>
<td>索引与存储成本更高</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">主流 Encoder–Decoder 模型</span></div>
<div class="blog_h3"><span class="graybg">T5</span></div>
<p>T5（Text-to-Text Transfer Transformer）是典型 Encoder–Decoder：把所有任务统一为“文本到文本”的生成问题（text-to-text）。它既可用于摘要、翻译等生成任务，也可用于分类，此时模型直接生成类别名对应的 token 序列。</p>
<p>在预训练阶段，T5采用的是掩码式去噪目标（Denoising Objective），但它不是只遮住单个 token 再逐个恢复，而是会对连续的 token span 做遮掩（Span Corruption）。输入中的若干片段会被替换为哨兵标记（Sentinel Token），模型则在解码端按顺序生成这些被遮住的片段。这样的训练方式既保留了“根据上下文恢复缺失内容”的掩码语言建模思想，又让模型从一开始就以 Encoder–Decoder 的生成方式学习条件重建。</p>
<p>在后续适配阶段，T5 延续了统一的 text-to-text 框架：翻译、摘要、问答、分类等任务都写成文本输入到文本输出的形式。沿着这条路线继续发展后，研究者又引入了更大规模的多任务指令微调（Instruction Tuning）：把大量带自然语言任务描述的监督任务混合起来训练，迫使模型学习“读懂任务说明，再按说明生成答案”。FLAN-T5 就是这一路线的代表，即在 T5 底座之上经过 FLAN 指令微调得到的系列模型；它相比原始 T5 更强调零样本（Zero-shot）和少样本（Few-shot）泛化能力。</p>
<div class="blog_h3"><span class="graybg">BART</span></div>
<p>BART（Bidirectional and Auto-Regressive Transformers）同属 Encoder–Decoder，但预训练更强调去噪自编码（Denoising Autoencoding）：对输入做扰动（mask、shuffle、delete 等），让模型恢复原文。它在摘要、生成式改写与条件生成任务上常用作强基线。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模型</td>
<td style="text-align: center;">架构</td>
<td style="text-align: center;">预训练直觉</td>
<td style="text-align: center;">强项</td>
</tr>
</thead>
<tbody>
<tr>
<td>T5</td>
<td>Encoder–Decoder</td>
<td>统一 text-to-text</td>
<td>任务统一；文本生成与分类都自然</td>
</tr>
<tr>
<td>BART</td>
<td>Encoder–Decoder</td>
<td>去噪重建</td>
<td>摘要与生成式改写强基线</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">任务特定语言模型</span></div>
<div class="blog_h3"><span class="graybg">概述</span></div>
<p>任务特定语言模型（Task-specific Language Model）指在通用预训练模型基础上，围绕某个明确监督任务附加任务头（Task Head）并继续微调的模型。常见任务包括句段级分类、token 级序列标注、文本匹配、排序与信息抽取。这里“句段级”指分类对象是一段独立文本，可以是单句，也可以是能够整体输入模型的段落。工程上它并不是一类全新架构，而是“预训练主干 + 任务头 + 对应损失函数”的组合：同样是 BERT、DeBERTa、T5 或大语言模型（Large Language Model, LLM），接什么头、优化什么目标，决定了它最终服务什么任务。</p>
<p>对 BERT 类 Encoder-only 模型，多分类通常采用“句向量 + 线性分类头（Linear Classification Head）”的形式。设句子表示为 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span>，类别数为 <span displaypfx="inline-" class="mathjax-container">\(K\)</span>，则分类头输出 logits：</p>
<span displaypfx="" class="mathjax-container">\[z=Wh+b,\quad W\in\mathbb{R}^{K\times d},\quad b\in\mathbb{R}^{K}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(h\)</span> 是主干模型抽取出的整句语义表示，常取自 <pre class="crayon-plain-tag">[CLS]</pre> 对应隐藏状态或池化结果；<span displaypfx="inline-" class="mathjax-container">\(W\)</span> 把 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 维表示映射到 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 个类别；<span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是偏置项；<span displaypfx="inline-" class="mathjax-container">\(z_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 类的原始分数（logit）。若需要概率，可再经过 softmax：</p>
<span displaypfx="" class="mathjax-container">\[p(y=k|x)=\frac{e^{z_k}}{\sum_{j=1}^{K}e^{z_j}}\]</span>
<p>训练时通常直接把 logits <span displaypfx="inline-" class="mathjax-container">\(z\)</span> 输入交叉熵（Cross-Entropy）损失，softmax 与对数运算一般由损失函数内部完成，以获得更好的数值稳定性；推理时若需要概率分布、阈值决策或置信度排序，再显式做 softmax。实践中还常在分类头前加入 Dropout，以降低小样本场景下的过拟合风险。</p>
<p>命名实体识别（Named Entity Recognition, NER）等 token 级任务的输出结构不同。设序列长度为 <span displaypfx="inline-" class="mathjax-container">\(T\)</span>，第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个 token 的隐藏状态为 <span displaypfx="inline-" class="mathjax-container">\(h_t\)</span>，则每个位置的标签打分可写成：</p>
<span displaypfx="" class="mathjax-container">\[z_t=Wh_t+b,\quad t=1,\dots,T\]</span>
<p>此时分类头不再输出“整句一个类别”，而是为每个 token 输出一组 BIO / BIOES 标签 logits。若任务更强调标签转移的一致性，还可在 token 分类头后叠加条件随机场（Conditional Random Field, CRF），显式约束标签序列的合法转移。</p>
<p>因此，任务头的输出形式始终取决于任务本身：句子分类输出 <span displaypfx="inline-" class="mathjax-container">\(\mathbb{R}^{K}\)</span> 上的一组 logits，token 分类为每个位置输出一组 logits，匹配/排序任务常输出单个相关性分数，生成任务则在词表维度输出下一 token 的 logits。主干负责提供中间表示（Intermediate Representation），任务头负责把这种表示投影成可直接优化的任务空间。</p>
<p>到 2026 年，任务特定语言模型的工程选型已经稳定分化。对高并发、闭集标签、边界清晰且标注数据相对充足的文本分类与 NER，DeBERTa、ModernBERT 一类 Encoder-only 模型仍然具有极高性价比：延迟低、吞吐高、概率校准更稳、部署成本更可控。这里的输入并不只限于单句；传统 Encoder-only 模型常见有效长度大约在 512 tokens，而较新的长上下文 Encoder-only 模型已经普遍扩展到 8K tokens，因此数百字的段落在 2026 年通常也仍属于可直接处理的范围。对样本极少、语义规则复杂、输出结构开放，或需要“理解 + 生成”一体化的任务，LLM 配合参数高效微调（Parameter-Efficient Fine-Tuning, PEFT）通常更有优势。</p>
<p>在这类 LLM 任务里，LoRA 仍然是默认起点：它最适合指令跟随、风格迁移、格式约束、轻量领域适配等大多数常规需求。若显存是第一约束，QLoRA 往往是最自然的落点；若任务要求更接近全参数微调的表达力，例如深领域知识吸收、复杂推理、困难边界判别或更强的稳定性，则可优先考虑 DoRA 或 Q-DoRA。全参数微调仍然保留在少数高门槛场景：领域迁移极深、训练数据极大，或需要改动词表、上下文长度、位置编码等底层结构时，它仍然提供最高上限。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">任务形态</td>
<td style="text-align: center;">更常见方案</td>
<td style="text-align: center;">主要原因</td>
<td style="text-align: center;">典型模型</td>
</tr>
</thead>
<tbody>
<tr>
<td>闭集文本分类</td>
<td>Encoder-only + 分类头</td>
<td>判别边界清晰；推理便宜；概率输出稳定</td>
<td>BERT / RoBERTa / DeBERTa / ModernBERT</td>
</tr>
<tr>
<td>标准 NER / 序列标注</td>
<td>Encoder-only + token head（可叠加 CRF）</td>
<td>天然适配 token 级标签；边界学习直接</td>
<td>BERT-CRF / DeBERTa-CRF</td>
</tr>
<tr>
<td>低资源复杂分类</td>
<td>LLM + LoRA / QLoRA</td>
<td>预训练知识丰富；少样本泛化强；显存门槛低</td>
<td>Qwen / Llama / Gemma + LoRA</td>
</tr>
<tr>
<td>复杂结构抽取 / JSON 输出</td>
<td>LLM + PEFT 或指令微调</td>
<td>可同时完成理解、抽取、归纳与结构化生成</td>
<td>Qwen / Llama / Mistral 系列</td>
</tr>
<tr>
<td>高难推理 / 深领域适配</td>
<td>LLM + DoRA / Q-DoRA</td>
<td>比普通 LoRA 更接近全参数微调；适合高质量知识注入</td>
<td>Qwen / Llama + DoRA</td>
</tr>
<tr>
<td>极致吞吐线上服务</td>
<td>LLM 标注或蒸馏，Encoder-only 上线</td>
<td>兼顾数据质量、速度与运维成本</td>
<td>LLM 教师 + DeBERTa 学生</td>
</tr>
<tr>
<td>超大规模深度迁移 / 底层结构改造</td>
<td>全参数微调</td>
<td>需要改动表示空间本身，低秩适配容量不足</td>
<td>领域基座继续训练或全量 SFT</td>
</tr>
</tbody>
</table>
<p>工业系统中也常采用混合路线：先用强 LLM 做数据清洗、弱标注、难例发现或标签体系归并，再把任务蒸馏到更轻的 Encoder-only 模型上线。这种做法利用了 LLM 的语义泛化能力，也保留了小模型在延迟、吞吐与稳定性上的优势。</p>
<div class="blog_h3"><span class="graybg">基于嵌入的推荐系统</span></div>
<p>推荐系统也经常以“任务特定模型”的方式落地，尤其是在召回（Recall）阶段。若每首歌曲都能通过歌词、标题、风格标签、歌手简介或多模态信息编码成一个向量 <span displaypfx="inline-" class="mathjax-container">\(e_i\)</span>，那么系统就可以把用户已经选择、收藏或反复播放的歌曲向量聚合成一个用户兴趣表示 <span displaypfx="inline-" class="mathjax-container">\(u\)</span>。一个最常见的做法是把若干已选歌曲的嵌入做平均或加权平均：</p>
<span displaypfx="" class="mathjax-container">\[u=\frac{1}{N}\sum_{i=1}^{N} e_i\]</span>
<p>随后，对候选歌曲 <span displaypfx="inline-" class="mathjax-container">\(s_j\)</span> 计算它与用户兴趣向量的相似度，例如余弦相似度（Cosine Similarity）：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{score}(u,s_j)=\cos(u,e_j)\]</span>
<p>得分越高，说明该歌曲与用户已选择歌曲在嵌入空间里越接近，也就越可能在风格、主题、情绪或语义上相似。于是，如果用户最近连续选择了几首节奏轻快、独立流行、以失恋叙事为主题的歌曲，系统就会优先召回在向量空间中靠近这些歌曲的其它曲目，而不是只依赖“同歌手”或“同标签”的硬规则。</p>
<p>这类推荐的关键不是显式分类，而是<span style="background-color: #c0c0c0;">把“用户喜欢什么”和“歌曲像什么”统一表示到同一嵌入空间，再用最近邻搜索完成相似歌曲推荐</span>。工程上它通常对应双塔模型（Two-Tower Model）或文本/多模态嵌入模型：一侧编码用户历史，一侧编码候选歌曲，训练时用点击、收藏、完整播放等行为构造正样本，再配合负采样或对比学习把用户喜欢的歌曲拉近、不感兴趣的歌曲推远。这样得到的推荐结果，本质上是基于语义相关性而不是基于精确关键词匹配。</p>
<div class="blog_h3"><span class="graybg">基于表示模型的分类</span></div>
<p>一条常见路线是把 BERT、RoBERTa、DeBERTa 这类表示模型（Representation Model）当作固定特征提取器（Feature Extractor）使用：先用预训练基座把输入文本编码成一个向量表示，再在其上训练一个轻量分类器，而不继续更新基座模型参数。这种做法的重点不是“让语言模型重新学习任务”，而是直接利用其已经学到的通用语义表示。</p>
<p>工程上，一个典型流程是：先冻结（Freeze）BERT 系列底座的全部参数，只保留前向编码功能；然后对每条输入文本提取句级表示 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span>，这个表示可以取自 <pre class="crayon-plain-tag">[CLS]</pre> 位置，也可以取池化后的整句向量；最后在 <span displaypfx="inline-" class="mathjax-container">\(h\)</span> 之上训练一个分类器。若用逻辑回归（Logistic Regression）或等价的线性 softmax 分类头，则可写成：</p>
<span displaypfx="" class="mathjax-container">\[z=Wh+b,\quad p(y=k\mid x)=\frac{e^{z_k}}{\sum_{j=1}^{K}e^{z_j}}\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(b\)</span> 是新训练的分类器参数，而 BERT 底座参数保持不变。这样做的优点是训练成本低、显存压力小、过拟合风险更可控，也便于把同一个表示底座复用到多个小任务上。代价是分类边界完全依赖预训练表示的可分性：如果任务与通用语料差距较大，或标签语义非常细，冻结底座通常不如继续微调主干模型灵活。</p>
<div class="blog_h3"><span class="graybg">基于嵌入模型的分类</span></div>
<p>与此相近的还有基于嵌入模型（Embedding Model）的分类。它的做法更直接：先用 SBERT、BGE、E5、GTE 或 text-embedding 一类嵌入模型，把整段文本映射成一个句向量 <span displaypfx="inline-" class="mathjax-container">\(e(x)\in\mathbb{R}^{d}\)</span>；然后直接在这个嵌入向量后面接一个分类器，例如逻辑回归、线性分类器或一个很小的 MLP，学习从嵌入空间到类别空间的映射。</p>
<p>若使用最简单的线性分类器，这条路线在形式上与前面的表示模型分类并没有本质区别，仍然可以写成</p>
<span displaypfx="" class="mathjax-container">\[z=We(x)+b\]</span>
<p>不同之处不在“后面接不接分类器”，而在于前面的底座向量是怎么来的：表示模型通常是为理解类任务设计的上下文化表示；嵌入模型则更强调在向量空间中保持语义距离结构，使相似文本彼此接近、不相似文本彼此远离。因此，基于嵌入模型的分类与基于表示模型的分类，主要区别在底座优化目标而不是分类头形式。表示模型更偏向判别式理解，输出表示通常更适合直接服务闭集分类、序列标注和细粒度 NLU；嵌入模型更偏向语义检索与相似度结构，优势在于跨任务复用、向量检索兼容性和“分类 + 召回”一体化。</p>
<p>工程上可以把两者概括成一句话：<span style="background-color: #c0c0c0;">表示模型分类强调“先得到适合判别的表示，再做分类”；嵌入模型分类强调“先得到适合度量的向量，再在其上学习分类边界”</span>。如果任务是标准闭集分类、标签边界清晰、追求最高分类精度，BERT 一类表示模型通常更自然；如果系统本身已经以 embedding 为中心，例如既要分类又要相似检索、聚类、召回或原型匹配，那么直接在嵌入模型后接分类器会更统一，也更容易复用同一套向量基础设施。</p>
<div class="blog_h3"><span class="graybg">基于嵌入模型的聚类</span></div>
<p>嵌入模型（Embedding Model）的另一条典型用途是文本聚类（Text Clustering）。代表性方法如 BERTopic：先用句向量模型把文档映射到嵌入空间，再做降维与聚类，最后从每个簇中抽取代表词或主题词。这里真正决定簇结构的并不只有嵌入模型；降维模型与聚类模型本身仍然是经典机器学习方法。</p>
<p>一个通用流程通常分成三步：</p>
<ol>
<li>使用嵌入模型将文档转换为向量表示，例如把每篇文档编码为 <span displaypfx="inline-" class="mathjax-container">\(e(x_i)\in\mathbb{R}^{d}\)</span>。</li>
<li>使用降维模型把高维向量压缩到更适合聚类的低维空间。</li>
<li>使用聚类模型在降维后的表示上得到簇标签，或进一步识别离群点。</li>
</ol>
<p>降维阶段的典型选型是 PCA（Principal Component Analysis）或 UMAP（Uniform Manifold Approximation and Projection）。PCA 是线性方法，适合作为快速、稳定的基线；UMAP 更擅长保留非线性邻域关系与整体簇结构，因此在文本聚类里往往更常见。工程上常把 UMAP 先降到 5 到 10 维，作为后续聚类的默认起点；这个范围通常已经足以保留主要结构，同时明显降低噪声与计算成本。</p>
<p>UMAP 的一些常见设置也会直接影响聚类形状。<pre class="crayon-plain-tag">min_dist</pre> 控制低维空间中点与点允许靠得多近；若把它设为 0，低维表示通常会形成更紧密的簇。距离度量常设为 <pre class="crayon-plain-tag">cosine</pre>，因为文本表示在高维空间中更常体现为方向相似性；无论是高维稀疏词向量还是高维稠密嵌入，欧氏距离都容易出现判别力下降的问题。</p>
<p>聚类阶段则常见 K-means 或 HDBSCAN。K-means 适合簇数大致已知、簇形状相对规则的场景；HDBSCAN 更适合密度不均、簇形状复杂，或希望显式识别“不属于任何簇”的离群文档。BERTopic 之所以常见，正是因为它把“嵌入模型 + UMAP + HDBSCAN + 主题表示”这条工程链路封装成了一个相对稳定的默认方案。</p>
<div class="blog_h3"><span class="graybg">基于嵌入模型的主题建模</span></div>
<p>基于嵌入模型的主题建模（Topic Modeling）与上一节的聚类路线一脉相承：先把文档映射为向量，再做降维与聚类；不同之处在于，这里还要继续回答“每个簇到底在谈什么”。因此，在聚类结果之上还需要增加一个<span style="background-color: #c0c0c0;">主题提取（Topic Extraction）</span>步骤，为每个簇生成一组能概括其内容的主题关键词。</p>
<p>这一步的典型做法是 c-TF-IDF（class-based TF-IDF）。它不是对单篇文档计算 TF-IDF，而是先把同一簇里的所有文档拼接成一个“类别文档”，再统计词在该簇中的相对频率，并结合它在其它簇中的区分度计算权重。于是，一个词若在当前簇中频繁出现、但在其它簇中并不常见，它的 c-TF-IDF 权重就会更高；反之，那些在所有簇里都常见的泛化词，其权重会被压低。这样提取出来的关键词，描述的是“这个簇相对于其它簇最有代表性的内容”，而不是“整个语料里最常见的词”。</p>
<p>从工程流程看，这条路线可以写成四步：先用嵌入模型得到文档向量，再用降维与聚类得到簇，随后用向量化器统计每个簇中的词项分布，最后用 c-TF-IDF 为每个簇生成主题词。BERTopic 的核心价值就在于，它把“嵌入模型 + UMAP + HDBSCAN + c-TF-IDF”串成了一个统一框架，因此既保留了 embedding 在语义空间中的表达能力，也保留了词袋统计在主题解释上的可读性。</p>
<p>不过，c-TF-IDF 产出的关键词顺序仍然主要依赖词频统计与类间区分度，语义相关性未必总是最优。于是 BERTopic 又提供了主题表示微调（Representation Tuning）机制：先用 c-TF-IDF 生成候选关键词，再对这些候选词做重新排序。这里最常见的表示模型之一是 KeyBERTInspired。它会先利用 c-TF-IDF 为每个主题挑出一组代表性文档，把这些代表文档聚合成该主题的语义表示，再用与文档编码相同的嵌入模型去计算“候选关键词与主题表示”的语义相似度，最后按相似度重排关键词顺序。在实践中，这种表示方式通常还能进一步压低停用词在最终主题表示中的占比，使主题词列表更干净。</p>
<p>因此，KeyBERTInspired 并不是重新做一遍聚类，也不是替代 c-TF-IDF；它更像是在 c-TF-IDF 给出的候选集之上增加一层语义重排序。这样做的结果通常是：靠前的主题词更连贯、停用词和噪声词更少，主题标签也更接近人类对“这个簇在讲什么”的直觉。对 BERTopic 而言，这一步属于主题表示优化，而不是主题发现本身。</p>
<p>即便经过上述处理，主题关键词之间仍可能存在明显冗余，例如多个高频近义词反复出现在同一主题里。此时还可以进一步使用最大边际相关性（Maximal Marginal Relevance, MMR）做关键词多样化：它在选择下一个关键词时，同时考虑“该词与主题表示有多相关”以及“该词与已经选中的关键词有多相似”，从而找到一组彼此具有差异性、但仍然和目标文档或主题表示保持相关的关键词。于是，MMR 的作用不是提升聚类质量，而是让最终主题表示更分散、更少重复，也更适合人工阅读与命名。</p>
<p>在此基础上，还可以再走一步：把已经得到的一组主题关键词交给生成模型（Generative Model），例如 FLAN-T5，让模型基于这些关键词生成一个更短、更自然的主题标签或一小段摘要式说明。这样做并不改变主题发现本身，而是把“关键词列表”进一步压缩成更适合展示给用户阅读的主题名称。</p>
<div class="blog_h3"><span class="graybg">基于嵌入模型的零样本分类</span></div>
<p>嵌入模型还可以直接用于零样本分类（Zero-shot Classification）。做法是不训练额外分类器，而是先把每个候选类别改写成自然语言标签描述，再把输入文本与这些标签描述同时编码到同一嵌入空间中，通过相似度完成类别判断。若影评任务只有“正面”和“负面”两类，就可以构造两个标签文本，例如“这是一条正面影评”和“这是一条负面影评”，然后分别计算影评向量与两个标签向量的余弦相似度：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{score}_k=\cos(e(x),e(t_k))\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 是待分类影评，<span displaypfx="inline-" class="mathjax-container">\(t_k\)</span> 是第 <span displaypfx="inline-" class="mathjax-container">\(k\)</span> 个类别对应的标签描述。若 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{score}_{\text{positive}}\)</span> 高于 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{score}_{\text{negative}}\)</span>，系统就把该影评判为正面评价。这个过程本质上是<span style="background-color: #c0c0c0;">把分类问题改写为“文本与标签描述谁更接近”的相似度匹配问题</span>。</p>
<p>这条路线的优点是部署快、几乎不需要任务专用标注数据，也不必重新训练分类头；只要标签语义写得足够清晰，就可以立刻在新任务上工作。它的代价同样直接：由于模型并未使用该任务的监督信号显式学习分类边界，零样本分类的准确率通常低于有标注数据时训练出的监督式分类器，尤其在标签定义细、类别边界接近、文本包含反讽或领域术语时更明显。</p>
<div class="blog_h3"><span class="graybg">基于生成模型分类</span></div>
<p>另一条更轻量的工程路线是基于生成模型（Generative Model）做分类，即直接调用大语言模型（Large Language Model, LLM），通过提示词工程（Prompt Engineering）把分类任务表述为指令，例如要求模型在读完一段文本后只输出“正面”或“负面”。这种做法本质上是把分类看作条件生成：模型先理解输入，再生成最符合提示约束的类别标签。这里的“生成模型”不只包括 Decoder-only LLM；像 T5 这样的 Encoder–Decoder 模型，虽然结构上属于编解码器，也同样可以在不给额外训练的情况下，直接通过输入提示词完成分类，因此在使用方式上也可以视作这一类路线。</p>
<p>它的优点是上手快、改标签体系方便、对少样本或零样本任务尤其灵活；局限是输出稳定性、延迟和成本通常不如专门训练的分类器，而且类别边界是否清晰、提示词是否严格，会明显影响结果一致性。因此，这条路线更适合作为快速原型、弱标注工具或低频复杂任务的分类接口，而不是高吞吐、强约束场景下的默认方案。</p>
<div class="blog_h1"><span class="graybg">多模态模型</span></div>
<p>多模态系统通常不是从零开始同时学习图像与文本，而是先建立稳定的单模态编码器，再通过对齐或指令微调把不同模态接到统一框架里。视觉 Transformer（Vision Transformer, ViT）处在这条链路的前端：它负责把原始图像变成可供分类、检索或跨模态建模使用的数值表示。</p>
<div class="blog_h2"><span class="graybg">视觉编码器</span></div>
<div class="blog_h3"><span class="graybg">ViT</span></div>
<p>ViT（Vision Transformer）本身不是视觉-语言模型（Vision-Language Model, VLM），因为它只处理图像，不直接建模文本，也不负责跨模态对齐。它的角色是视觉编码器（Visual Encoder）或视觉骨干网络（Backbone）：把非结构化的像素输入转换为结构化的 token 序列表示，再交给 Transformer 编码器提取全局特征。</p>
<p>ViT 的关键创新是把图像按固定大小切成网格化的 patch，并把每个 patch 视作一个视觉 token。这里的 token 与文本 token 不同：文本 token 可以通过词表映射到固定 ID，而图像 patch 没有天然离散词表，因此不能直接分配类似词 ID 的符号编号。工程上通常先把每个 patch 展平，再通过可学习的线性投影映射到统一维度的向量空间，使其在形式上与文本 token embedding 相同，然后叠加位置编码（Positional Encoding）送入 Transformer 编码器。</p>
<p>这种设计把卷积网络擅长处理的二维图像，重写为 Transformer 可直接处理的一维 token 序列。结果不是“理解语言”，而是得到可用于分类、检测、检索或跨模态对齐的高层视觉表示。因此，在 CLIP、LLaVA 一类系统里，ViT 更准确的定位是图像侧表征学习模块，而不是完整的多模态模型。</p>
<div class="blog_h2"><span class="graybg">视觉-语言模型</span></div>
<div class="blog_h3"><span class="graybg">CLIP</span></div>
<p>CLIP（Contrastive Language–Image Pre-training）用对比学习（Contrastive Learning）把图像与文本对齐到同一嵌入空间：图像编码器与文本编码器分别输出向量，通过 InfoNCE 类目标让匹配对相似度更高、非匹配对更低。它既可用于图文检索，也可把类别名/提示词当作文本侧输入实现零样本分类（Zero-shot Classification）。</p>
<p>CLIP 的训练前提就是图像与文本的配对数据。原始论文使用的是大规模图文对语料：每个样本包含一张图像及其对应文本，监督信号不是人工精标类别，而是“哪段文本与哪张图像匹配”。因此，CLIP 的核心不在生成，而在跨模态表示对齐。</p>
<div class="blog_h3"><span class="graybg">OpenCLIP</span></div>
<p>OpenCLIP 不是另一种视觉-语言目标函数，而是 CLIP 路线的开源实现与扩展生态。它复现并扩展了 OpenAI CLIP 的双编码器（Dual Encoder）对比学习框架，使研究者和工程团队可以在公开数据、公开代码和公开权重上继续训练、评测与部署。</p>
<p>工程上，OpenCLIP 的价值主要体现在两点。第一，它把 CLIP 从“论文范式”变成了可复现基础设施：常见模型直接训练或发布在 LAION-400M、LAION-2B、DataComp-1B 等公开图文对数据集之上。第二，它把视觉塔和训练配方做成了可替换组件，既能使用 ViT，也能扩展到 ConvNeXt 等视觉骨干。因此在检索系统、零样本分类和多模态 LLM 前端里，OpenCLIP 往往是比原始 CLIP 更常见的工程起点。</p>
<div class="blog_h3"><span class="graybg">BLIP-2</span></div>
<p>BLIP-2 的核心目标不是重新端到端训练一个全新的多模态大模型，而是让现成的文本生成模型获得图像理解输入。它把预训练视觉编码器与预训练大语言模型（Large Language Model, LLM）冻结下来，只训练中间的轻量桥接模块，从而尽量保留两侧已有能力：视觉编码器继续负责稳定的图像表征，LLM 继续负责成熟的语言生成。</p>
<p>这个桥接模块在论文中的标准名称是 Q-Former（Querying Transformer），不是单纯意义上的“Q-Transformer”。Q-Former 通过一组可学习 query 向量，从冻结视觉编码器输出的图像特征中抽取对语言生成最有用的信息，再把这些压缩后的视觉表示投影到 LLM 可接受的输入空间。它本质上充当跨模态接口：一端接视觉特征，一端接语言模型，使跨模态信息可以传入文本生成链路。</p>
<p>因此，BLIP-2 的方法论很清晰：不破坏已有单模态模型优势，只训练一个参数量相对较小的连接层完成跨模态迁移。这类设计直接影响了后续大量“视觉编码器 + LLM”系统，因为它证明了多模态能力并不一定依赖昂贵的全模型联合训练，也可以通过桥接模块高效获得。</p>
<div class="blog_h3"><span class="graybg">LLaVA</span></div>
<p>LLaVA（Large Language and Vision Assistant）属于“视觉编码器 + LLM”的多模态对话范式：用视觉编码器（如 ViT）提取图像特征，通过投影层把视觉特征对齐到语言模型输入空间，再做视觉指令微调（Visual Instruction Tuning），让模型学会围绕图像进行问答、描述与推理。</p>
<div class="blog_h3"><span class="graybg">GPT-4V / Gemini</span></div>
<p>GPT-4V / Gemini 这类通用多模态大模型（General-purpose Multimodal LLM）通常把视觉理解、OCR、推理与工具使用统一到一个端到端系统中，并以 API 形态提供能力。工程上它们常作为“强但贵”的最终裁决器：当规则与小模型不稳时，用多模态 LLM 做兜底或少量精排。</p>
<div class="blog_h2"><span class="graybg">当前前沿系统怎么做</span></div>
<p>截至 2026 年，多模态 SOTA 更适合从系统设计理解，而不是从单一榜单理解。闭源前沿模型的完整架构通常不公开，但公开资料已经足够说明一个稳定趋势：当前领先系统正在从“图像接到语言模型前面”演化为“统一感知、统一推理、统一动作”的多模态基础模型。</p>
<p>第一类趋势是<span style="background-color: #c0c0c0;">原生多模态（Native Multimodality）</span>。这类系统不再把语音识别、视觉编码、文本生成严格拆成彼此独立的流水线，而是尽量让不同模态进入同一推理主干。这样做的价值在于保留跨模态细节，例如语音中的语气、图像中的局部布局、视频中的时间连续性。工程意义也很直接：模型不只是“看图回答”，而是能在文本、图像、音频、视频之间共享上下文。</p>
<p>第二类趋势是<span style="background-color: #c0c0c0;">更强的视觉前端</span>。当前领先系统很少满足于固定分辨率图片分类，而是强调高分辨率文档、图表、界面截图和长视频理解。对应做法包括动态分辨率（Dynamic Resolution）、窗口注意力（Window Attention）、时间维采样以及跨模态位置编码。这说明视觉模块已经不再只是提供粗粒度 caption feature，而是在承担 OCR、布局解析、目标定位、视频时序理解等更细的感知职责。</p>
<p>第三类趋势是<span style="background-color: #c0c0c0;">把推理与工具调用纳入同一闭环</span>。当前最强系统的目标不只是输出一句描述，而是把“看见什么”“如何思考”“接下来调用什么工具”串成连续决策过程。于是多模态模型逐步具备结构化抽取、框选定位、网页或桌面操作、检索增强、实时语音对话等能力。此时它更接近通用代理（General Agent），而不只是视觉问答模型。</p>
<p>从代表系统看，OpenAI 的 GPT-4o 路线强调端到端原生多模态统一；Gemini 当前公开产品线则明显把长上下文、实时音视频交互与原生音频推理并到主模型能力里；Anthropic 的 Claude 多模态路线更突出长上下文推理、工具使用与计算机操作；开源路线里，Qwen2.5-VL 则把这些趋势写得最具体，包括动态分辨率 ViT、长视频建模、目标定位、结构化输出以及视觉代理能力。</p>
<p>因此，今天讨论多模态 SOTA，重点已经不是“有没有图像输入”，而是四个问题：是否原生统一多模态、是否支持高分辨率与长视频、是否能把感知结果转成结构化中间表示、以及是否能在推理过程中可靠调用外部工具。能同时把这四点做好，才接近当前前沿系统的真实标准。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center;">典型代表</td>
<td style="text-align: center;">训练目标</td>
<td style="text-align: center;">强项</td>
<td style="text-align: center;">局限</td>
</tr>
</thead>
<tbody>
<tr>
<td>视觉编码器（单模态基础）</td>
<td>ViT</td>
<td>图像 patch token 化与表征学习</td>
<td>全局视觉特征强；易作为多模态系统的图像 backbone</td>
<td>本身不建模文本；不直接完成跨模态对齐</td>
</tr>
<tr>
<td>对比学习对齐（Dual Encoder）</td>
<td>CLIP / OpenCLIP</td>
<td>图文对比（InfoNCE）</td>
<td>检索与零样本分类强；向量可索引</td>
<td>生成能力弱；细粒度推理受限</td>
</tr>
<tr>
<td>视觉编码器 + LLM</td>
<td>BLIP-2 / LLaVA 类</td>
<td>桥接对齐 + 视觉指令微调</td>
<td>对话与推理能力强；可扩展工具调用</td>
<td>推理成本高；视觉细节依赖对齐质量</td>
</tr>
<tr>
<td>通用多模态 LLM</td>
<td>GPT-4V / Gemini</td>
<td>多任务端到端</td>
<td>能力上限高；工程集成成熟</td>
<td>成本与可控性；数据合规</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">图像生成模型</span></div>
<div class="blog_h3"><span class="graybg">DALL-E</span></div>
<p>DALL-E 属于“文本到图像”（Text-to-Image, T2I）生成模型路线之一：输入提示词生成图片。工程上更关注提示词控制、风格一致性与安全过滤，而不是单纯的像素指标。</p>
<div class="blog_h3"><span class="graybg">Stable Diffusion</span></div>
<p>Stable Diffusion 属于扩散模型（Diffusion Model）的潜空间版本（Latent Diffusion）：在潜变量空间做扩散与去噪，再解码为图像。它的工程优势在于开源生态、可控微调（LoRA/ControlNet 等）与本地部署可能性。</p>
<div class="blog_h3"><span class="graybg">Imagen</span></div>
<p>Imagen 是文本到图像扩散路线的代表之一，强调高质量生成与文本-图像对齐。与其他 T2I 模型一样，最终效果高度依赖数据与训练配方。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center;">代表</td>
<td style="text-align: center;">优势</td>
<td style="text-align: center;">常见玩法</td>
</tr>
</thead>
<tbody>
<tr>
<td>商业 T2I</td>
<td>DALL-E / Imagen</td>
<td>效果稳定；产品化完善</td>
<td>提示词工程；风格控制；安全策略</td>
</tr>
<tr>
<td>开源扩散</td>
<td>Stable Diffusion</td>
<td>可本地部署；可深度定制</td>
<td>LoRA/ControlNet；自定义 checkpoint；工作流编排</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">语音模型</span></div>
<div class="blog_h3"><span class="graybg">Whisper</span></div>
<p>Whisper 是自动语音识别（Automatic Speech Recognition, ASR）模型代表之一：输入音频输出转写文本。工程落地的关键在于流式推理（Streaming）、噪声鲁棒性、领域词表与端到端延迟控制。</p>
<div class="blog_h3"><span class="graybg">语音合成（TTS）</span></div>
<p>语音合成（Text-to-Speech, TTS）把文本转为音频波形。工程关注点包括音色一致性（Voice Consistency）、韵律（Prosody）、多说话人控制、以及与 ASR/对话系统的整体延迟。</p>
<div class="blog_h2"><span class="graybg">多模态对齐</span></div>
<div class="blog_h3"><span class="graybg">对比学习对齐（CLIP 范式）</span></div>
<p>对比学习对齐（Contrastive Alignment）用“匹配对相似、非匹配对不相似”的目标把不同模态映射到同一空间。它的最大价值是：对齐后可以做检索（Retrieval）、聚类与零样本迁移（Zero-shot Transfer），并为后续的多模态 LLM 提供可用的视觉表示初始化。</p>
<div class="blog_h3"><span class="graybg">视觉指令微调</span></div>
<p>视觉指令微调（Visual Instruction Tuning）把多模态输入组织为对话/指令格式，用监督数据训练模型按指令理解图像并输出文本。与纯对比对齐相比，它更强调“可用性”：模型能解释、能推理、能按格式回答。</p>
<div class="blog_h1"><span class="graybg">预训练与微调</span></div>
<p>对生成式大语言模型（Large Language Model, LLM）而言，训练通常不是一步完成，而是一个逐层收紧目标的三阶段过程。第一阶段是预训练（Pretraining）：让模型“学会语言”，掌握通用世界知识、语言统计规律与基础生成能力。第二阶段是监督微调（Supervised Fine-Tuning, SFT）：让模型“学会按指令做事”，把通用生成能力收束到更明确的任务格式、回复风格和指令遵循能力上。第三阶段是偏好对齐（Preference Alignment）：让模型“生成得更好”，在多个候选答案中更稳定地偏向人类认为更有帮助、更安全、更相关的输出。</p>
<p>这三步也对应了三种不同的模型状态。只做完预训练的模型通常称为基础模型（Base Model）：它拥有语言能力，但并不天然理解“用户现在真正想要什么”。经过监督微调后，模型开始具备指令理解与任务对齐能力，这一步常被称为指令微调（Instruction Tuning）。再往后，通过偏好对齐把模型行为进一步向人类目标、价值约束和回答偏好靠拢，这一步才构成更完整的对齐（Alignment）过程。当前主流通用助手模型，基本都建立在这条“预训练 → SFT → 偏好对齐”的标准范式之上。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">阶段</td>
<td style="text-align: center;">核心目标</td>
<td style="text-align: center;">主要训练信号</td>
<td style="text-align: center;">产出能力</td>
</tr>
</thead>
<tbody>
<tr>
<td>预训练（Pretraining）</td>
<td>学会语言与通用知识</td>
<td>大规模通用语料上的自监督目标，如 next-token prediction</td>
<td>基础语言建模能力，形成 Base Model</td>
</tr>
<tr>
<td>监督微调（SFT）</td>
<td>学会遵循指令与任务格式</td>
<td>高质量指令-回答、任务输入-输出配对数据</td>
<td>更稳定的指令理解、格式控制与领域适配能力</td>
</tr>
<tr>
<td>偏好对齐（Preference Alignment）</td>
<td>让输出更符合人类偏好</td>
<td>偏好排序、奖励模型（Reward Model）、RLHF / DPO 等对齐信号</td>
<td>更有帮助、更安全、更符合人类判断的回答</td>
</tr>
</tbody>
</table>
<p>从工程视角看，微调的本质不是重新训练一个模型，而是在保留预训练通用能力的前提下，对模型进行二次开发。领域适配、风格约束、指令遵循、安全边界和用户体验优化，几乎都发生在预训练之后。也正因为如此，微调并不是可有可无的附加步骤，而是把“会说话的模型”变成“可用的产品模型”的关键环节。</p>
<div class="blog_h2"><span class="graybg">预训练（Pre-training）</span></div>
<div class="blog_h3"><span class="graybg">数据收集、清洗与配比</span></div>
<p>预训练并不是从损失函数开始，而是从数据管线（Data Pipeline）开始。对大语言模型而言，模型最终学到什么、遗漏什么、偏向什么，首先取决于它看到了哪些语料，以及这些语料在进入训练前经历了怎样的筛选、清洗与混合。训练配方当然重要，但在大规模预训练里，<span style="background-color: #c0c0c0;">数据分布本身往往比单个优化技巧更具决定性</span>。</p>
<p>第一步通常是收集训练数据。来源往往包括公开网页、百科、书籍、论文、新闻、论坛、代码仓库、问答站点、对话语料以及经过授权的专有文本。不同来源的作用并不相同：网页和百科提供广覆盖的语言统计与世界知识，代码语料强化程序生成与形式化模式，论文和书籍提升长程结构与知识密度，对话数据则更贴近后续助手形态。预训练阶段谈“知识注入”，最底层的载体首先就是这些原始语料源。</p>
<p>第二步是数据清洗（Data Cleaning）。原始互联网语料通常充满模板页、导航栏、广告、乱码、截断文本、语言混杂、低信息密度页面和大规模重复内容，直接拿来训练只会把噪声写进模型。常见清洗动作包括：语言识别、文本抽取、HTML / Markdown 噪声剥离、异常字符过滤、长度过滤、文档质量评分、敏感内容过滤，以及近重复或完全重复文档去除。它的目的不是把数据“洗得越干净越好”，而是把明显无价值、重复或高风险的部分尽量挡在训练集之外。</p>
<p>第三步是数据去重与质量过滤。对现代大模型来说，重复数据并不只是浪费 token 预算，还会放大训练分布中的头部模式，使模型更容易过拟合少数高频模板、降低有效数据多样性，并污染后续评测。于是，工程上通常既要做文档级去重，也要做段落级、片段级甚至近似语义去重；同时配合质量分类器、启发式规则或小模型过滤，把低信息密度、机器生成垃圾、SEO 内容农场和错误密集文本压低占比。</p>
<p>第四步是数据配比（Data Mixture）。预训练通常不是把所有清洗后的数据简单拼接后均匀抽样，而是会显式控制不同来源、语言、领域和模态的采样比例。原因在于：不同语料的规模差异极大，若完全按原始数量采样，网页噪声和头部来源往往会淹没更高质量但规模更小的数据，例如书籍、论文和代码。数据配比的本质，是决定模型应该把多少训练预算分配给广覆盖、多少分配给高质量、多少分配给特定能力方向。</p>
<p>这种配比通常带来直接的能力权衡。代码比例升高，模型的程序生成和形式化推理往往更强，但自然语言对话风格未必同步变好；高质量书面语比例升高，模型的行文稳定性和知识密度往往改善，但口语互动和开放域覆盖可能下降；多语比例升高，则跨语言泛化更强，但单语极致性能未必最优。因此，数据配比并不是纯粹的数据工程细节，而是预训练目标函数之外最重要的能力分配器之一。</p>
<p>第五步才是把整理后的语料送入真正的训练阶段。也正因为前面已经做过收集、清洗、去重和配比，后面的“初期训练、中期训练、退火训练”才有明确的数据基础：初期通常强调大规模广覆盖混合，中后期再逐步提高高质量数据、特定能力语料或长上下文样本的权重。换句话说，阶段化训练并不是独立于数据工程存在的，它本身就是建立在<span style="background-color: #c0c0c0;">先构造可控数据分布，再按阶段调整采样分布</span>这一前提之上。</p>
<div class="blog_h3"><span class="graybg">阶段化训练与知识注入</span></div>
<p>现代大语言模型的预训练，通常不是把同一种数据、同一种上下文长度和同一组优化超参数一路跑到结束，而更接近一种分阶段课程学习（Curriculum Training）。所谓“知识注入”，本质上也不是把某条事实单独写入参数，而是通过逐步调整数据分布、上下文长度、学习率和训练目标，让模型先建立通用语言统计骨架，再吸收更高质量、更长程或更专业的模式。</p>
<p>工程上常见的三段式可以概括为：</p>
<ul>
<li><span style="background-color: #c0c0c0;">初期训练</span>。这一阶段通常以海量、多样、相对较短的上下文为主，重点是尽快建立词法、句法、语义组合、事实共现与基础推理的统计骨架。之所以大量使用短上下文，是因为在标准注意力下，序列长度增加会显著抬高训练成本；在固定算力预算下，较短序列通常能换来更多 token 更新和更稳定的早期收敛。</li>
<li><span style="background-color: #c0c0c0;">中期训练（Mid-training）</span>。当模型已经具备基本语言能力后，训练重点会从“广覆盖”逐步转向“高价值分布塑形”。这一阶段更常看到更严格过滤的高质量语料、代码、推理数据、专业领域语料，或逐步扩展的上下文长度。它的作用不是重学语言本身，而是把模型的能力重心推向更有用的区域，例如更强的代码能力、更稳的长程依赖、更贴近目标领域的表达分布。</li>
<li><span style="background-color: #c0c0c0;">退火训练（Annealing Phase）</span>。这是预训练后段的精修阶段，通常伴随更小的学习率、更保守的更新幅度，以及更精选、更低噪声的数据混合。它的目标不是再靠大步更新去扩张知识覆盖面，而是收束参数、压低噪声影响、强化高质量模式，并把模型最终的能力形态稳定下来。很多现代配方会把更专业或更高质量的数据留到这一阶段，以获得更好的下游表现。</li>
</ul>
<p>从“注入什么知识”的角度看，这三段关注的重点并不相同。初期训练主要注入广覆盖的语言统计、世界常识共现和通用结构先验；中期训练主要注入能力相关的分布偏好，例如代码、推理、长文档和领域语料；退火训练则更像把高价值知识和高质量行为模式做最后收束，使模型从“已经学会很多”走向“把重要能力学得更稳”。</p>
<p>长上下文能力也常在这一框架下被放到中后期处理。原因并不神秘：长上下文训练既昂贵，又更容易让优化目标与数据工程复杂化；如果在模型尚未建立稳定短程语言骨架时就大规模拉长序列，单位算力的有效学习信号往往并不划算。因此，很多训练配方会先用短上下文把基础能力打牢，再在中后段逐步扩展到更长上下文，或者单独追加一段上下文扩展训练。</p>
<p>因此，预训练阶段谈“知识注入”时，更准确的理解不是一次性灌入，而是<span style="background-color: #c0c0c0;">按训练阶段逐步改变模型看到的分布与约束条件</span>：先学会语言，再学会更有价值的语言分布，最后把这些能力收束成一个更稳定的基座模型。</p>
<div class="blog_h3"><span class="graybg">自回归语言建模（CLM）</span></div>
<p>自回归语言建模（Causal Language Modeling, CLM）把文本建模为从左到右的条件概率连乘：给定前缀 <span displaypfx="inline-" class="mathjax-container">\(x_{&lt;t}\)</span> 预测下一个 token <span displaypfx="inline-" class="mathjax-container">\(x_t\)</span>。训练目标是最小化 next-token 交叉熵：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{CLM}}(\theta)=-\sum_{t=1}^{L}\log p_\theta(x_t\mid x_{&lt;t})\]</span>
<p>CLM 与 Decoder-only 架构天然匹配：因果 attention mask 保证模型只能看见历史 token，避免训练-推理不一致。绝大多数通用生成式大模型都以 CLM 为主目标。</p>
<div class="blog_h4"><span class="graybg">Multi-Token Prediction（MTP）</span></div>
<p>多 token 预测（Multi-Token Prediction, MTP）是在 CLM 基础上的“监督信号加密”：除了预测 <span displaypfx="inline-" class="mathjax-container">\(x_{t+1}\)</span>，还额外让模型在同一隐藏状态上预测更远的未来 token（例如 <span displaypfx="inline-" class="mathjax-container">\(x_{t+2}\)</span>、<span displaypfx="inline-" class="mathjax-container">\(x_{t+3}\)</span>），从而在相同序列长度下产生更多训练信号。一个抽象写法是：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{MTP}}(\theta)=-\sum_{t=1}^{L}\sum_{j=1}^{K}\log p_\theta(x_{t+j}\mid x_{&lt;t})\]</span>
<p>MTP 通常作为辅助损失（Auxiliary Loss）提升训练效率或长程规划能力；但推理阶段是否能“真正一次生成多个 token”取决于解码与验证策略，不能简单由训练目标推出。</p>
<div class="blog_h3"><span class="graybg">掩码语言建模（MLM）</span></div>
<p>掩码语言建模（Masked Language Modeling, MLM）随机遮住输入中的一部分 token（替换为 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{MASK}]\)</span> 或其他扰动），训练模型用双向上下文预测被遮住位置的 token。它是 Encoder-only 表示模型（如 BERT 系列）的典型预训练目标：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{MLM}}(\theta)=-\sum_{t\in \mathcal{M}}\log p_\theta(x_t\mid x_{\setminus \mathcal{M}})\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{M}\)</span> 是被 mask 的位置集合。MLM 的优势是能学到更强的双向表示，但它与生成式解码不天然一致，因此“理解类任务”更常用 MLM 预训练模型，“生成类任务”更常用 CLM。</p>
<div class="blog_h3"><span class="graybg">对比学习预训练</span></div>
<p>对比学习预训练（Contrastive Pre-training）把“相似样本拉近、非相似样本推远”作为核心目标。它广泛用于句向量/图像-文本对齐等场景：例如 CLIP 用图像编码器与文本编码器产生表示，对匹配对最大化相似度；Sentence-BERT 等句向量模型也常用对比目标训练。</p>
<p>典型形式是 InfoNCE：对 batch 内正对（Positive Pair）与负对（Negative Pair）做 softmax，对每个 query 只奖励其匹配的 key：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}=-\sum_{i}\log \frac{\exp(\mathrm{sim}(q_i,k_i)/\tau)}{\sum_{j}\exp(\mathrm{sim}(q_i,k_j)/\tau)}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\tau\)</span> 是温度（Temperature），<span displaypfx="inline-" class="mathjax-container">\(\mathrm{sim}\)</span> 常用余弦相似度或内积。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">目标</td>
<td style="text-align: center;">代表架构</td>
<td style="text-align: center;">擅长</td>
<td style="text-align: center;">典型下游</td>
</tr>
</thead>
<tbody>
<tr>
<td>CLM</td>
<td>Decoder-only</td>
<td>生成（Generation）</td>
<td>对话、写作、代码生成</td>
</tr>
<tr>
<td>MLM</td>
<td>Encoder-only</td>
<td>表示学习（Representation）</td>
<td>分类、匹配、序列标注、reranking</td>
</tr>
<tr>
<td>对比学习</td>
<td>Dual Encoder / 多塔</td>
<td>对齐与检索（Alignment/Retrieval）</td>
<td>Embedding、图文检索、聚类</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">继续预训练（Continual Pre-training）</span></div>
<div class="blog_h3"><span class="graybg">领域适配</span></div>
<p>继续预训练（Continual Pre-training）位于“通用预训练”与“下游微调”之间。它不直接把模型改造成某个具体任务的分类器或助手，而是先用目标领域的大规模无标注语料，对已经完成通用预训练的模型再训练一段时间，让参数分布、词汇统计、上下文共现和知识重心向目标领域迁移。对 Encoder-only 模型，这一步通常仍以掩码语言建模（Masked Language Modeling, MLM）为主；对 Decoder-only 生成模型，则通常继续做自回归语言建模（Autoregressive Language Modeling）。</p>
<p>它的核心价值是<span style="background-color: #c0c0c0;">先让底座学会“这个领域怎样说话”，再让它学会“这个任务怎样输出”</span>。若直接拿通用模型去做医疗、法律、金融、代码仓库、企业内部知识库等垂直场景的监督微调，模型往往会同时面对两类落差：一类是领域词汇和表达方式本身就不熟，另一类是下游任务的标签或指令又要求它立即做出稳定判断。继续预训练先处理前一类问题，把底座拉近目标域分布，后续监督微调只需要处理任务映射，训练通常会更稳定，也更节省标注数据。</p>
<p>因此，继续预训练本质上是一种领域自适应预训练（Domain-Adaptive Pretraining, DAPT）。如果继续预训练的语料不仅来自某个大领域，而是更进一步贴近最终任务的输入分布，例如只使用某个具体产品线、某类工单、某种法律文书或某一学科论文语料，那么它也可以被视作任务自适应预训练（Task-Adaptive Pretraining, TAPT）。两者的区别不在训练算法，而在语料与最终应用的距离：DAPT 更强调“进入这个领域”，TAPT 更强调“贴近这个任务”。</p>
<p>领域适配能带来的收益通常体现在四个层面。第一，模型会更熟悉目标域词汇和短语共现，例如医学缩写、金融术语、企业内部专有名词、代码 API 与日志模式。第二，模型对目标域上下文的概率分布会重新校准，原本罕见的搭配在该领域里会变成高频结构。第三，后续监督微调需要学习的东西会减少，因为模型不必一边补语言常识、一边学任务映射。第四，在低标注数据场景下，继续预训练常常比一上来就重监督微调更稳，因为它先利用了最容易获得的大规模原始文本。</p>
<p>继续预训练最适合三类场景：</p>
<ul>
<li><span style="background-color: #c0c0c0;">领域语料很多、标注很少</span>。这是最经典的适用条件，因为继续预训练最能利用大规模无标注文本，而不要求先构造高成本监督数据。</li>
<li>目标文本分布与通用互联网语料差异极大。例如长文档、半结构化记录、专业术语密集文本、代码与自然语言混合语料；这类差异首先是语言分布差异，而不是标签定义差异。</li>
<li>模型需要吸收的变化更接近“知识与表达分布迁移”，而不是单纯“输出标签变了”或“格式要求变了”。后者通常更适合直接做监督微调或 PEFT。</li>
</ul>
<p>从训练流程看，更合理的顺序通常是：先完成通用预训练，再做领域继续预训练，最后再进入监督微调、参数高效微调或偏好对齐。原因很简单：继续预训练改变的是基座对语言分布和知识结构的建模，而监督微调改变的是输出行为。先做基座适配，再做行为适配，优化目标更清晰，也更符合迁移学习的层次结构。</p>
<div class="blog_h3"><span class="graybg">灾难性遗忘问题</span></div>
<p>继续预训练的主要风险，是灾难性遗忘（Catastrophic Forgetting）。它指的是模型在吸收新分布时，原来在通用语料上学到的能力被明显冲掉：例如领域内术语理解变强了，但通用语言理解、跨领域泛化、常识问答、原始格式鲁棒性或多语言能力反而下降。这个问题并不是继续预训练独有的，但在“新语料分布很窄、训练步数又较长”时尤其容易出现。</p>
<p>其根本原因在于参数共享。神经网络并不会为“旧知识”和“新知识”自动分出两套互不干扰的存储区；当优化器持续在窄领域语料上更新同一组权重时，原先支持通用能力的参数方向会被新的梯度不断改写。如果新领域文本的语言风格、词频结构和任务偏置都高度集中，模型就会把有限参数容量优先分配给当前最常见的模式，从而牺牲原本更广的覆盖面。</p>
<p>灾难性遗忘最常见的外在表现包括：继续预训练阶段训练损失持续下降，但回到通用基准或旧任务验证集上时指标明显退化；模型在目标领域内更流畅，却在开放域输入上变得更僵硬、更偏模板化；对窄领域高频术语反应更强，但对跨域问题的泛化能力下降。这些现象都说明模型不是单纯“学到了更多”，而是在重新分配有限表示能力。</p>
<p>缓解灾难性遗忘有几条经典路线：</p>
<ul>
<li><span style="background-color: #c0c0c0;">控制继续预训练强度</span>。包括减少训练步数、降低学习率、使用更保守的 warmup 与衰减策略，避免模型在窄分布上过度漂移。</li>
<li><span style="background-color: #c0c0c0;">混合语料训练</span>。在领域语料之外保留一定比例的通用语料，让模型在吸收新分布的同时持续回顾旧分布。</li>
<li><span style="background-color: #c0c0c0;">参数隔离</span>。例如只对部分层做继续预训练，或采用 Adapter、LoRA 这类参数高效路径，把领域偏移写进新增参数，而不是完全重写主干。</li>
<li><span style="background-color: #c0c0c0;">保留旧能力验证</span>。继续预训练不应只看领域损失，还应并行跟踪若干通用验证集，否则模型退化往往到很晚才会被发现。</li>
</ul>
<p>因此，继续预训练并不是“领域语料越多、训练越久越好”。更准确的目标应当是：在尽量少破坏通用能力的前提下，把模型的统计重心向目标领域移动。它追求的不是把模型变成只会某个领域的专家，而是在通用底座之上增加一层更贴近目标分布的适配。工程上真正好的继续预训练，通常表现为<span style="background-color: #c0c0c0;">领域内显著增益、领域外可控退化，甚至几乎无退化</span>，而不是单纯把领域损失压到最低。</p>
<div class="blog_h2"><span class="graybg">监督微调（SFT）</span></div>
<p>监督微调（Supervised Fine-Tuning, SFT）用“输入 ➡ 期望输出”的监督数据继续训练预训练模型，使其在特定分布上更符合目标行为。对自回归语言模型（Autoregressive LM）而言，SFT 仍然是 next-token 交叉熵：给定提示词 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 与目标回复 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>，最小化</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{SFT}}(\theta)=-\sum_{t}\log \pi_\theta\!\left(y_t\mid x,y_{&lt;t}\right)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(\pi_\theta\)</span> 表示由参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 决定的模型条件概率分布，也就是“在给定前缀条件下，模型对下一个 token 的预测分布”；<span displaypfx="inline-" class="mathjax-container">\(\pi_\theta\!\left(y_t\mid x,y_{&lt;t}\right)\)</span> 就表示模型在看到提示词 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 和目标回复此前各 token <span displaypfx="inline-" class="mathjax-container">\(y_{&lt;t}\)</span> 时，对当前位置正确 token <span displaypfx="inline-" class="mathjax-container">\(y_t\)</span> 赋予的概率。</p>
<p>训练通常使用教师强制（Teacher Forcing）：把 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(y\)</span> 拼接作为输入，但只在目标回复 token 上计算损失（对提示词部分做标签掩码，label masking），避免模型被迫“复述提示词”。</p>
<p>SFT 的必要性，来自基础模型（Base Model）与“可用助手模型”之间的行为差异。基础模型的原始目标只是预测下一个 token，因此它本质上擅长的是<span style="background-color: #c0c0c0;">续写（Completion）</span>，而不是理解人类正在发出什么指令。给它一句 “The car is”，它自然会继续补全常见续文；给它一个问题 “What is 1+1?”，它在没有对齐之前也完全可能把这当成一段待续写文本，而不是一个必须回答的任务。SFT 的作用，就是把这种“见到前缀就续写”的行为，重塑成“读懂输入意图，再输出目标答案”的行为。</p>
<div class="blog_h3"><span class="graybg">全量微调</span></div>
<p>全量微调（Full Fine-tuning）更新模型的全部参数，表达能力最强，但训练成本高、对数据规模与分布漂移更敏感，也更容易出现灾难性遗忘（Catastrophic Forgetting）。它与预训练在优化形式上并没有本质断裂，区别主要在于数据：预训练依赖海量无标注通用语料，全量微调则依赖规模更小但质量更高、目标更明确的标注数据集。正因为所有参数都会被更新，它在特定任务上的性能上限通常最高，但显存、训练时间和权重存储成本也最高；每做一次完整微调，本质上都在生成一个完整的新模型副本。</p>
<div class="blog_h3"><span class="graybg">部分参数微调</span></div>
<p>部分参数微调（Partial Fine-tuning / Selective Fine-tuning）处在全量微调与参数高效微调（PEFT）之间。它的基本做法不是给模型增加新的适配器参数，而是<span style="background-color: #c0c0c0;">只解冻原模型中一部分已有参数</span>，其余参数保持冻结。这样做的直接收益是显存占用更低、训练更快、过拟合风险更可控；代价则是可调空间受限，性能上限通常低于全量微调。</p>
<p>这类方法的核心思想是：并不是每个任务都需要改写整套参数。若下游变化主要集中在输出读出方式、输入符号分布或某一局部计算结构，那么只更新最相关的一小部分参数，往往就足以完成适配。它与后文的 LoRA、Adapter 有一个重要区别：部分参数微调优化的是<span style="background-color: #c0c0c0;">原模型内部已经存在的参数子集</span>；PEFT 则更常通过新增低秩矩阵、瓶颈层或软提示，把任务偏移写进额外参数。</p>
<div class="blog_h4"><span class="graybg">输出层微调</span></div>
<p>输出层微调（Output-layer Fine-tuning）只更新模型最靠近输出读出的部分，例如语言建模头（LM Head）、分类头（Classification Head）、奖励头（Reward Head），或最后少数几层与任务头直接相连的参数，而把主体 Transformer 基本冻结。它最适合“底层表示已经足够好，但最终读出方式需要重塑”的场景。</p>
<p>对表示模型，这通常意味着冻结编码器主体，只训练顶层分类器；对生成模型或对齐流程，则常见于基于已有 SFT 模型训练奖励模型：保留主干表示层，移除原 LM Head，换成输出单一分数的奖励头，再主要围绕这个输出读出层继续训练。它的优势是参数量极小、训练稳定、成本最低；局限在于它基本不改变主干内部表示，因此当任务真正需要重排中间语义结构时，单靠输出层往往不够。</p>
<div class="blog_h4"><span class="graybg">输入层微调</span></div>
<p>输入层微调（Input-layer Fine-tuning）主要更新输入嵌入相关参数，例如 token embedding、位置嵌入，或与新词表、新符号、新模态入口直接相连的输入投影层，而冻结大部分主体网络。它适合输入分布变化显著、但主体推理与表示能力仍然可复用的场景，例如新增领域术语、扩展专有 token、接入特殊控制符，或需要让模型先学会“看懂新输入”。</p>
<p>这条路线在词表扩展与领域符号接入时尤其有价值。因为很多变化并不在“模型不会推理”，而在“模型还没有为这些新符号建立合适入口”。此时先调输入层，可以把新 token 映射到已有表示空间附近，减少一开始就全模型漂移的风险。在一些更重的训练配方里，也会先单独训练输入嵌入，再逐步解冻更深层参数做融合；但其本质始终是先处理<span style="background-color: #c0c0c0;">输入接口适配</span>，再决定是否需要更深层的结构性更新。</p>
<div class="blog_h4"><span class="graybg">局部结构微调</span></div>
<p>局部结构微调（Local-structure Fine-tuning）只选择模型内部某些特定结构或参数类型来更新，例如仅训练偏置项的 BitFit、仅训练归一化参数的 LayerNorm Tuning、只解冻注意力层参数的 Attention Tuning，或只解冻最后若干层的局部 block。它的共同点是：参数选择不是按“输入端 / 输出端”划分，而是按<span style="background-color: #c0c0c0;">网络内部哪类结构最可能承载任务偏移</span>来划分。</p>
<p>这类方法适合算力极其受限、数据量较小，或已经对任务偏移位置有较强先验的场景。例如，若任务主要要求重新标定特征尺度或阈值边界，LayerNorm Tuning 可能就足够；若任务更多是在改变“关注哪里、聚合哪些信号”，只调注意力层可能比盲目放开全部层更高效；若只是希望用极低成本给模型一点任务校正能力，BitFit 这类只训偏置的方案也有现实价值。它们的上限通常不如更强的 PEFT，但在轻量实验、消融研究和极端资源约束环境中依然很有意义。</p>
<div class="blog_h4"><span class="graybg">BitFit</span></div>
<p>BitFit 的做法极端简单：冻结几乎所有权重矩阵，只训练偏置项（Bias Terms）。若某一层的线性变换写成 <span displaypfx="inline-" class="mathjax-container">\(h'=Wh+b\)</span>，BitFit 只更新其中的 <span displaypfx="inline-" class="mathjax-container">\(b\)</span>，而把 <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 保持不动。它的参数量因此极小，通常只占全模型参数的很小一部分。</p>
<p>它背后的核心假设是：对不少下游任务而言，预训练模型已经学到了足够强的表示空间与主要变换方向，任务适配真正需要的，未必是重写整块权重矩阵，而可能只是<span style="background-color: #c0c0c0;">调整各层激活的平移、阈值和默认响应水平</span>。从这个角度看，BitFit 更像是在重新标定网络内部各单元的“触发基线”，而不是重建新的特征子空间。</p>
<p>这也解释了它为什么在一些小数据分类、文本匹配或轻量行为校正任务里常常表现得比直觉预期更强：如果任务边界与预训练表示已经高度接近，那么改变少量偏置，就足以让原本已经存在的特征更容易被激活，或更容易跨过最终判别阈值。反过来，当任务需要新的知识写入、复杂结构重排或明显不同的推理路径时，BitFit 往往会很快触到容量上限，因为它几乎无法改变表示之间的主导交互方向。</p>
<div class="blog_h4"><span class="graybg">LayerNorm Tuning</span></div>
<p>LayerNorm Tuning 只更新归一化层中的可学习缩放与偏移参数，典型写法可记为：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{LN}(h)=\gamma\odot \frac{h-\mu}{\sqrt{\sigma^2+\epsilon}}+\beta\]</span>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(\gamma\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\beta\)</span> 就是主要可训练对象，而主体权重矩阵保持冻结。它的参数量同样很小，但比 BitFit 更直接作用于每层隐藏状态的尺度（Scale）与中心（Shift）。</p>
<p>它背后的假设是：很多任务偏移并不需要创造全新的特征，只需要重新调节已有特征在各层中的相对幅度与数值范围。因为 Transformer 中大量残差路径都会经过归一化，LayerNorm 参数对信息流的“放大 / 压低 / 重新居中”有全局影响。于是，只调归一化参数，就可能在不改写主干矩阵的前提下，系统性地改变哪些特征更容易穿过后续层并主导输出。</p>
<p>这类方法尤其适合已经拥有较强基座、但需要重新校准风格、阈值、稳定性或局部行为边界的场景。它通常比 BitFit 稍强，因为它直接控制每层表示的尺度结构；但它仍然主要是在<span style="background-color: #c0c0c0;">重标定现有表示</span>，而不是构造新的复杂变换，因此面对大幅领域迁移或新知识注入时，上限仍然有限。</p>
<div class="blog_h4"><span class="graybg">Attention Tuning</span></div>
<p>Attention Tuning 只解冻注意力模块中的参数，例如 <span displaypfx="inline-" class="mathjax-container">\(W_Q,W_K,W_V,W_O\)</span>，而继续冻结 FFN / MLP 与其他大部分结构。它的核心判断是：不少任务真正需要改变的，不是模型是否拥有某些知识，而是模型在当前任务中<span style="background-color: #c0c0c0;">应该关注哪些 token、怎样聚合远近信息、如何在上下文中分配注意力</span>。</p>
<p>它背后的假设比 BitFit 更强，也更结构化：预训练模型中的知识与模式大体已经存在，任务适配更多是在改变“信息路由（Information Routing）”而不是“知识存储（Knowledge Storage）”。如果这个假设成立，只调整注意力层就能显著改变模型的上下文读取方式，例如更关注结尾句、否定词、实体关系、长程依赖或某些格式锚点，而无需重写 FFN 中更重的参数块。</p>
<p>这也是为什么 Attention Tuning 在行为调整类任务上常有不错的性价比：它比 BitFit 和 LayerNorm Tuning 拥有更强的表示重排能力，又比全量微调和大范围 PEFT 更轻。但它的边界也很清楚。若任务核心是注入新事实、学习新术语本体、补足模型原本缺失的知识映射，仅靠改变注意力路由通常不够，因为许多稳定知识关联最终仍要落在 FFN / MLP 所承载的表示重编码里。</p>
<p>从整体上看，部分参数微调提供的是一种<span style="background-color: #c0c0c0;">选择性解冻原参数</span>的思路：输出层微调优先改读出，输入层微调优先改入口，局部结构微调优先改网络内部某个被认为最关键的子结构。若这些选择性更新已经足够，就没有必要进入更重的全量微调；若它们的容量仍然不够，下一步才更自然地转向后文的 LoRA、Adapter、Prefix Tuning 这类参数高效微调路线。</p>
<div class="blog_h3"><span class="graybg">指令微调（Instruction Tuning）</span></div>
<p>指令微调（Instruction Tuning）是 SFT 的一种数据组织方式：把任务描述（Instruction）显式写进输入，使模型学习“读懂指令并按指令输出”。典型样本是三元组：指令（instruction）、输入（input，可为空）、输出（output）。</p>
<pre class="crayon-plain-tag">{&quot;instruction&quot;:&quot;回答以下问题&quot;,&quot;input&quot;:&quot;世界上最高的山是什么？&quot;,&quot;output&quot;:&quot;珠穆朗玛峰。&quot;}</pre>
<p>对话/FAQ 场景更常用多轮消息格式（Chat Format），把 role（system/user/assistant）显式编码进序列；训练时同样只在 assistant 角色对应的目标 token 上计算损失。</p>
<pre class="crayon-plain-tag">{&quot;messages&quot;:[
  {&quot;role&quot;:&quot;system&quot;,&quot;content&quot;:&quot;你是一个严谨的技术助手。&quot;},
  {&quot;role&quot;:&quot;user&quot;,&quot;content&quot;:&quot;解释什么是交叉熵损失。&quot;},
  {&quot;role&quot;:&quot;assistant&quot;,&quot;content&quot;:&quot;交叉熵损失用于衡量预测分布与真实分布的差异&hellip;&hellip;&quot;}
]}</pre>
<p>数据规模没有统一答案，但工程上最关键的是质量（Quality）与覆盖（Coverage）。常见实践：</p>
<ul>
<li>领域 SFT：从 <span displaypfx="inline-" class="mathjax-container">\(10^3\sim10^5\)</span> 级别的高质量样本起步，先跑通指标与错误分析，再扩充数据与任务覆盖。</li>
<li>通用指令微调：更常见的是 <span displaypfx="inline-" class="mathjax-container">\(10^5\sim10^6+\)</span> 的多任务指令样本，用多样性换泛化。</li>
<li>偏好对齐数据：比较对（Preference Pairs）常在 <span displaypfx="inline-" class="mathjax-container">\(10^4\sim10^6\)</span> 级别，且对标注一致性要求更高。</li>
</ul>
<div class="blog_h3"><span class="graybg">拒绝采样微调（Rejection Sampling Fine-Tuning）</span></div>
<p>拒绝采样微调（Rejection Sampling Fine-Tuning）本质上仍然属于监督微调路线。这里讨论的是 rejection sampling fine-tuning 这一路线，而不是近年某些语境里也会写成 RFT 的 reinforcement fine-tuning。它的核心做法是：监督数据不再完全来自人工直接编写，而是先由模型生成多个候选，再通过规则、验证器（Verifier）、奖励模型（Reward Model）或人工筛选，只保留其中最优或通过阈值的样本，最后把这些“被接受”的输出重新写回监督数据集，再按普通 SFT 的方式继续训练。</p>
<p>若把提示词记为 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，候选回答记为 <span displaypfx="inline-" class="mathjax-container">\(\{y^{(k)}\}_{k=1}^{K}\sim \pi_{\mathrm{old}}(\cdot|x)\)</span>，评分函数记为 <span displaypfx="inline-" class="mathjax-container">\(s(x,y)\)</span>，那么拒绝采样微调通常先从这组候选中选出满足 <span displaypfx="inline-" class="mathjax-container">\(s(x,y)\ge \tau\)</span> 的回答，或直接取最高分回答 <span displaypfx="inline-" class="mathjax-container">\(y^\star=\arg\max_k s(x,y^{(k)})\)</span>，再把 <span displaypfx="inline-" class="mathjax-container">\((x,y^\star)\)</span> 当作新的监督样本。后续优化目标并没有变成策略梯度或显式偏好损失，仍然是标准的 next-token 交叉熵。</p>
<p>因此，它可以被理解为一种<span style="background-color: #c0c0c0;">先筛选、再监督</span>的微调方式。与普通 SFT 相比，它利用模型自身采样与外部评分器把“哪种输出更好”这层信息先转成更高质量的目标答案；与 DPO、PPO 这类偏好优化相比，它并不直接学习候选之间的相对排序关系，而是把筛选结果硬化成新的监督标签。工程上，它经常处在普通 SFT 与显式偏好优化之间，既比纯手工 SFT 更能利用自动评估信号，又比完整 RLHF 或 DPO 更容易复用现有监督训练栈。</p>
<p>这条路线尤其适合<span style="background-color: #c0c0c0;">存在较强可验证信号</span>的任务，例如数学推导、代码生成、结构化输出、工具调用轨迹筛选，以及能用单元测试、规则校验、解析器或外部判分器稳定判断好坏的场景。因为一旦评分器足够可靠，拒绝采样就能把“生成多个候选、只留下正确或更优的那个”直接转化为高质量训练样本。</p>
<p>它的边界同样明确。若评分器本身噪声很大，或任务质量强依赖开放式偏好、语气细节、多维安全判断，那么把复杂偏好硬压成“通过 / 不通过”很容易损失信息；筛选过严还会让训练分布变得过窄，导致模型只会复现少数高分写法而削弱多样性。因此，拒绝采样微调更像一种<span style="background-color: #c0c0c0;">高质量数据再蒸馏</span>手段，而不是偏好对齐的终极替代品。</p>
<div class="blog_h3"><span class="graybg">聊天微调（Chat Fine-tuning）</span></div>
<p>聊天微调（Chat Fine-tuning）强调多轮对话一致性：除单轮问答外，还需要覆盖上下文承接、拒答策略、工具调用格式、长对话记忆等。它通常仍是 SFT，只是数据分布更贴近真实对话。</p>
<div class="blog_h2"><span class="graybg">参数高效微调（PEFT）</span></div>
<p>参数高效微调（Parameter-Efficient Fine-Tuning, PEFT）把“微调”从“更新全部参数”变成“冻结基座（Base Model），只训练一小部分新增参数”。它仍然是当前大模型落地的主流选择之一：成本更低、训练更快、便于为不同任务保存多份轻量适配（例如每个业务一套 LoRA）。PEFT 并不只包含 LoRA、Adapter、IA3 这类“在模型内部加参数”的方法，也包含 Prefix Tuning、Prompt Tuning 这类<span style="background-color: #c0c0c0;">软提示（Soft Prompt）</span>路线；它们的共同点不是结构长得像不像，而是都遵循“冻结大部分预训练参数，只训练极小任务参数”这一基本范式。</p>
<div class="blog_h3"><span class="graybg">Adapter</span></div>
<p>Adapter 的核心不是“在模型外面再接一个头”，而是<span style="background-color: #c0c0c0;">在每个 Transformer block 内部插入一条很窄的可训练残差支路</span>。设某一层原本输出为 <span displaypfx="inline-" class="mathjax-container">\(h\in\mathbb{R}^{d}\)</span>，Adapter 会先把它投影到一个远小于 <span displaypfx="inline-" class="mathjax-container">\(d\)</span> 的瓶颈维度 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>，经过非线性后再投影回原维度，再以残差形式加回主干：</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Adapter}(h)=W_{\mathrm{up}}\;\sigma\!\left(W_{\mathrm{down}}h+b_{\mathrm{down}}\right)+b_{\mathrm{up}},\quad h'=h+\mathrm{Adapter}(h)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(W_{\mathrm{down}}\in\mathbb{R}^{r\times d}\)</span> 负责降维， <span displaypfx="inline-" class="mathjax-container">\(W_{\mathrm{up}}\in\mathbb{R}^{d\times r}\)</span> 负责升维，且通常有 <span displaypfx="inline-" class="mathjax-container">\(r\ll d\)</span>。这就是所谓的瓶颈结构（Bottleneck Structure）：主干隐藏维度也许是几千，而 Adapter 内部只开放几十到几百维的可训练通道，因此新增参数量远小于全量微调。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/adapter-structure-canvas.jpg"><img class="alignnone size-full wp-image-41967" src="https://blog.gmem.cc/wp-content/uploads/2026/03/adapter-structure-canvas.jpg" alt="adapter-structure-canvas" width="1438" height="877" /></a></p>
<p> 它通常插在自注意力子层或前馈网络子层之后，也就是“原子层输出 + LayerNorm / 残差”附近的位置。最常见的做法是在每个 block 中放两处：一处跟在 attention 输出之后，另一处跟在 FFN 输出之后。这样设计的直觉很直接：主干模型仍负责保留通用语言能力，而 Adapter 只学习<span style="background-color: #c0c0c0;">相对于原模型的任务特定偏移</span>。由于它是加法残差支路，模型一开始可以非常接近原始基座；随着训练推进，Adapter 再逐步学会把主干表示往当前任务需要的方向轻推一把。</p>
<p>这也是为什么 Adapter 常被初始化为近似恒等映射：例如让升维层初始非常小，使 <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Adapter}(h)\approx 0\)</span>。这样做的效果是，训练初期模型几乎等同于原始基座，不会因为新增模块而立刻破坏预训练表示；随后再通过反向传播逐步放大这条残差分支，让它承担领域偏移、标签边界重塑或任务路由修正。与“直接改写主干权重”相比，这种路径更稳定，也更容易控制灾难性遗忘。</p>
<p>从参数量角度看，单个 Adapter 的主要新增参数就是两次线性映射，大约是 <span displaypfx="inline-" class="mathjax-container">\(2dr\)</span> 量级，而不是全层的 <span displaypfx="inline-" class="mathjax-container">\(d\times d\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(d\times d_{\mathrm{ff}}\)</span> 量级。因此，只要瓶颈维度 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 足够小，就能在保持表达力的同时，把训练显存、优化器状态和存储成本压到很低。这一点与 LoRA 的“低秩更新”在精神上相近，但它们的结构并不相同：Adapter 是显式新增一条小型 MLP 支路，LoRA 则是在原线性层内部参数化一个低秩增量。</p>
<p>工业实践里，Adapter 通常不是只挂在单个层上，而是分布式地插入到所有 Transformer block 中，从而让每一层都具备任务适配能力。它的一个重要工程优势是“插拔式（Plug-and-play）”任务切换：同一个基座模型可以加载不同任务的 Adapter 包，在情感分析、NER、检索重排等任务之间快速切换，而不必为每个任务都保存一整份完整模型。这也是 Adapter 在多任务部署和组织内模型复用场景中一直很有吸引力的原因。</p>
<div class="blog_h3"><span class="graybg">LoRA</span></div>
<p>LoRA（Low-Rank Adaptation）不改动原权重 <span displaypfx="inline-" class="mathjax-container">\(W\)</span>，而是学习一个低秩增量 <span displaypfx="inline-" class="mathjax-container">\(\Delta W\)</span>：</p>
<span displaypfx="" class="mathjax-container">\[W' = W + \Delta W,\quad \Delta W = BA,\quad B\in\mathbb{R}^{d_{\text{out}}\times r},\ A\in\mathbb{R}^{r\times d_{\text{in}}},\ r\ll \min(d_{\text{in}},d_{\text{out}})\]</span>
<p>由于 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 很小，可训练参数与优化器状态显著减少。实践里常把 LoRA 加在注意力投影（如 <span displaypfx="inline-" class="mathjax-container">\(W_Q,W_K,W_V,W_O\)</span>）和/或 FFN 上。</p>
<p>截至 2026 年，LoRA 仍然是大语言模型参数高效微调里最常见的默认方案。原因并不神秘：它与主流 Transformer 线性层天然兼容，适配器权重体积小，便于为不同任务单独保存、热切换与合并；同时它对训练框架、推理框架和量化框架的兼容性也最成熟。因此，工程上常把 LoRA 看成 PEFT 的基线接口：后续很多方法，本质上都是在 LoRA 的参数化、量化方式或更新几何上继续细化。</p>
<div class="blog_h4"><span class="graybg">A、B 矩阵的区别</span></div>
<p>在上面的记号里， <span displaypfx="inline-" class="mathjax-container">\(A\in\mathbb{R}^{r\times d_{\text{in}}}\)</span> 负责把原始输入方向投影到一个 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 维低秩子空间， <span displaypfx="inline-" class="mathjax-container">\(B\in\mathbb{R}^{d_{\text{out}}\times r}\)</span> 再把这个低维表示映射回输出空间。因此，对输入向量 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 而言，LoRA 增量的作用顺序是：</p>
<span displaypfx="" class="mathjax-container">\[\Delta y=\Delta Wx=BAx\]</span>
<p>也就是说，先由 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 做“进低秩空间”的投影，再由 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 做“回原输出空间”的回投。直觉上， <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 更像在问“哪些组合方向值得被拿出来单独调”， <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 则更像在问“这些低维方向最终该怎样广播回原模型的输出通道”。</p>
<p>不同实现里，A、B 的命名和矩阵形状有时会看起来对调，这是因为有的库按数学乘法顺序命名，有的库按代码中参数张量的存储顺序命名。但概念并没有变：总有一个矩阵负责把高维输入压到低秩子空间，另一个矩阵负责把低秩更新再映射回原空间。只要抓住“先降到 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 维，再升回原维度”这一点，就不会被不同实现的符号差异干扰。</p>
<p>LoRA 的经典初始化也依赖这两个矩阵的不同角色。常见做法是让其中一个矩阵采用小随机初始化，而另一个矩阵初始化为 0；在当前这组记号下，更常见的叙述是让 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 随机初始化、 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 零初始化，于是训练开始时有：</p>
<span displaypfx="" class="mathjax-container">\[\Delta W = BA = 0\]</span>
<p>这样做有两个直接好处。第一，训练初始时模型输出与原基座完全一致，不会因为 LoRA 分支突然注入随机扰动而破坏预训练能力。第二，参数不会陷入完全对称的零状态：若两边都初始化为 0，梯度传播会受阻；若两边都随机初始化，训练一开始又会平白给模型加上不必要的噪声。采用“单边随机、单边为零”的非对称初始化，既保证了初始增量为零，又保留了可学习性，这是 LoRA 训练稳定性的关键细节之一。</p>
<p>从梯度角度看，这个设计也有非常直接的理由。若记损失对增量矩阵的梯度为 <span displaypfx="inline-" class="mathjax-container">\(G=\frac{\partial \mathcal{L}}{\partial \Delta W}\)</span>，则有：</p>
<span displaypfx="" class="mathjax-container">\[\frac{\partial \mathcal{L}}{\partial B}=GA^\top,\qquad \frac{\partial \mathcal{L}}{\partial A}=B^\top G\]</span>
<p>因此，当 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 随机、 <span displaypfx="inline-" class="mathjax-container">\(B=0\)</span> 时，训练开始的第一步通常会先更新 <span displaypfx="inline-" class="mathjax-container">\(B\)</span>，因为 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial B}=GA^\top\)</span> 一般不为 0；而 <span displaypfx="inline-" class="mathjax-container">\(\frac{\partial \mathcal{L}}{\partial A}=B^\top G=0\)</span>，所以 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 会在后续几步中随着 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 脱离零状态后再开始获得梯度。也正因为如此，LoRA 的非对称初始化并不会“学不起来”，它只是让学习过程以一种更平稳的方式启动。</p>
<div class="blog_h4"><span class="graybg">主要参数</span></div>
<p>LoRA 真正需要重点理解的超参数并不多，但每一个都在控制不同维度的权衡：容量、增量强度、挂载范围、正则化和训练稳定性。它们之间并不是简单的“越大越好”关系。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">参数</td>
<td style="text-align: center;">控制对象</td>
<td style="text-align: center;">理论含义与实践影响</td>
</tr>
</thead>
<tbody>
<tr>
<td>rank <span displaypfx="inline-" class="mathjax-container">\(r\)</span></td>
<td>低秩子空间维度</td>
<td>决定 <span displaypfx="inline-" class="mathjax-container">\(\Delta W\)</span> 最多能沿多少个独立方向修改原权重。 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 越小，参数越省、正则效应越强，但容量也越受限； <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 越大，表达力更强，却更容易抬高显存、训练成本与过拟合风险。</td>
</tr>
<tr>
<td><span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span></td>
<td>增量缩放强度</td>
<td>通常与 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 一起通过 <span displaypfx="inline-" class="mathjax-container">\(\frac{\alpha}{r}\)</span> 作用在 LoRA 分支上。它控制的是“已经学到的低秩方向到底以多大幅度影响原模型”，因此更接近幅值旋钮，而不是容量旋钮。调大 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 不能替代更高的 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>；它只能放大已有方向，而不会创造新方向。</td>
</tr>
<tr>
<td>target_modules</td>
<td>挂载位置</td>
<td>决定 LoRA 写入模型的哪一部分。只挂注意力投影时，参数最省、更偏行为与路由调整；把 MLP / FFN 一并纳入时，容量更强，也更适合知识关系与复杂边界适配，但训练更重。</td>
</tr>
<tr>
<td>lora_dropout</td>
<td>适配器正则化</td>
<td>主要用于抑制小数据或高重复语料上的过拟合。它并不改变 LoRA 的基本结构，而是在训练时降低低秩分支对局部样本模式的过度依赖。数据量很小时更有价值；数据充分且任务稳定时常保持较低甚至关闭。</td>
</tr>
<tr>
<td>学习率</td>
<td>优化步长</td>
<td>虽然学习率不是 LoRA 独有参数，但 LoRA 对学习率通常比全参数微调更敏感。因为可训练参数很少、每一步更新更集中，学习率过高时更容易直接把行为边界推歪；而 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 较大时，这种不稳定会被进一步放大。</td>
</tr>
</tbody>
</table>
<p>这几个参数里，最容易被混淆的是 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span>。 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 控制的是“允许模型沿多少个方向改”， <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 控制的是“这些方向的改动最终放大到多强”。前者对应容量，后者对应强度。增加 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 会改变可表达子空间本身；增加 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 只是把已存在的低秩更新放大。</p>
<p>从经验上看，LoRA 的调参顺序通常也应当遵循这个逻辑：先确定挂载哪些模块、需要多大 rank 才能容纳任务偏移，再去调节 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 与学习率，让训练稳定落在合适幅度上。若一开始就只靠提高 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 去追求效果，得到的往往不是更强的表达力，而是更剧烈的扰动。</p>
<p>LoRA 挂在哪些线性层上，并不是纯工程细节，而与希望改变模型的哪一部分能力直接相关。若目标更偏风格迁移、格式控制、对话行为调整或路由方式改变，注意力投影层上的 LoRA 往往已经能带来明显效果；但若目标是注入新的领域术语、事实关联、实体属性映射或专业概念之间的稳定关系，FFN / MLP 往往更关键。原因在于：Transformer 里的 MLP 常被视为知识写入与模式重编码的重要位置，因此很多“新知识”最终要落到这些大规模前馈权重所张成的表示子空间里。</p>
<p>这也是为什么在不少实践中，LoRA 不只挂在 <span displaypfx="inline-" class="mathjax-container">\(W_Q,W_K,W_V,W_O\)</span> 上，还会同时挂在 FFN 的线性层上，甚至在资源允许时为 FFN 分配更高的秩（Rank）。低秩更新的本质是在原有权重空间附近增加一个受限的可训练子空间；如果希望修改的是知识关联本身，而不仅是信息流动方式，那么只改注意力层常常不够，需要让 MLP / FFN 也获得足够的适配容量。沿着这条思路发展的变体，如 DoRA（Weight-Decomposed Low-Rank Adaptation），本质上也是在不做全参数微调的前提下，给参数更新更强的表达能力。</p>
<p>这里的低秩假设作用于微调增量 <span displaypfx="inline-" class="mathjax-container">\(\Delta W\)</span>，而不是作用于预训练知识本身的存储方式。预训练模型中的知识通常以分布式表示（Distributed Representation）的方式编码在大量参数 <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 里；LoRA 近似的是“为了适配当前任务，需要沿哪些方向改动这些参数”。前者讨论的是 <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 如何承载知识，后者讨论的是 <span displaypfx="inline-" class="mathjax-container">\(\Delta W\)</span> 如何改变模型行为与输出边界。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/lora.png"><img class="alignnone size-full wp-image-41973" src="https://blog.gmem.cc/wp-content/uploads/2026/03/lora.png" alt="lora" width="1024" height="1024" /></a></p>
<p>很多微调任务并不要求模型写入大规模新知识，而是要求它重新组织已有表示：调整回答风格、强化指令跟随、遵守输出格式、放大某些线索、抑制另一些线索。这类任务的有效更新方向往往集中在少数子空间中，小秩 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 就足以带来明显收益。若任务要求模型稳定吸纳新的领域本体（Ontology）、术语体系、事实关系或复杂规则，适配增量的有效维度通常会上升，低秩近似就更容易成为容量瓶颈。</p>
<p>分布式存储也不意味着所有参数同等重要。即便在全参数微调（Full Fine-tuning）中，显著变化的往往也是少数关键方向；LoRA 的核心假设是，这些方向可以被一个较低维的子空间有效覆盖。当任务迁移幅度较小，这个假设通常成立；当关键更新分散在许多彼此独立的方向上，就需要更高的秩、更广的挂载范围，尤其是让 FFN / MLP 参与适配，必要时再转向 DoRA 或全参数微调。工程上，这体现为一条清晰的权衡：LoRA 优先优化效率，全参数微调提供更高上限；二者对应的是不同任务内在维度（Intrinsic Dimension）下的不同最优解。</p>
<div class="blog_h4"><span class="graybg">LoRA 合并</span></div>
<p>LoRA 的一个工程优势是“可合并（Mergeable）”：推理前可把增量权重并入基座权重，从而不引入额外前向分支。对单个 LoRA，合并后得到的有效权重就是 <span displaypfx="inline-" class="mathjax-container">\(W_{\text{merged}}=W+\Delta W\)</span>（实践中常包含缩放系数）。</p>
<p>当存在多份 LoRA（多任务/多领域适配）时，最简单的合并是对增量做加权和：</p>
<span displaypfx="" class="mathjax-container">\[W' = W + \sum_{j=1}^{M}\lambda_j\,\Delta W^{(j)}\]</span>
<p>这种“线性缝合”实现简单，但容易出现任务干扰（Interference）：不同 LoRA 在同一参数子空间里叠加，可能让模型对多个任务都变差。若你需要同时服务多个领域，更稳健的方案往往是“运行时选择哪一份 LoRA”或引入路由（Routing）机制，而不是把它们永久混成一份权重。</p>
<div class="blog_h4"><span class="graybg">LoRA 缩放</span></div>
<p>LoRA 的低秩增量在工程实现里通常不是直接写成 <span displaypfx="inline-" class="mathjax-container">\(\Delta W=BA\)</span>，而是写成带缩放的形式：</p>
<span displaypfx="" class="mathjax-container">\[\Delta W=\frac{\alpha}{r}BA\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 是秩（Rank），决定低秩子空间的维度；<span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 是缩放系数（Scaling Factor），决定这条增量支路最终以多大强度作用于原权重。把 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 放在一起，不是书写习惯，而是为了在改变 rank 时尽量保持更新量的数值尺度处于可控范围。若只增大 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 而不做归一化，低秩分支的整体幅度往往也会随之增大，使不同配置之间难以直接比较。</p>
<p>因此， <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 控制的是“LoRA 支路有多强”， <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 控制的是“LoRA 支路能沿多少个方向改动权重”。前者更接近幅值控制，后者更接近容量控制。单纯调大 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span>，只是放大既有低秩方向的影响；单纯调大 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>，则是在扩大可表达的更新子空间。</p>
<p>缩放在合并时同样不会消失。真正并回基座的不是裸 <span displaypfx="inline-" class="mathjax-container">\(BA\)</span>，而是已经乘上系数后的有效增量，因此合并后的权重应写成：</p>
<span displaypfx="" class="mathjax-container">\[W_{\text{merged}}=W+\frac{\alpha}{r}BA\]</span>
<p>工程上，较小数据集与较轻任务迁移通常更适合温和缩放，因为此时更重要的是在保留基座先验的同时做局部修正；当任务迁移更深、希望 LoRA 更积极地重写行为边界或知识关联时，才会提高 <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 或放宽 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>。它本质上是在调节“基座保守性”与“任务增量强度”之间的平衡。</p>
<div class="blog_h3"><span class="graybg">DoRA</span></div>
<p>DoRA（Weight-Decomposed Low-Rank Adaptation）的核心不是把 LoRA 的两个低秩矩阵 <span displaypfx="inline-" class="mathjax-container">\(A\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(B\)</span> 再拆一层，而是先把原始权重 <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 按“幅值（Magnitude）+ 方向（Direction）”重写，再只让低秩更新作用在方向部分。对第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 个输出通道，也就是权重矩阵的第 <span displaypfx="inline-" class="mathjax-container">\(j\)</span> 列，可先写成：</p>
<span displaypfx="" class="mathjax-container">\[W_{:,j}=\left\|W_{:,j}\right\|_2\cdot \frac{W_{:,j}}{\left\|W_{:,j}\right\|_2}=m_j\,\hat v_j,\quad \left\|\hat v_j\right\|_2=1\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\frac{W_{:,j}}{\left\|W_{:,j}\right\|_2}\)</span> 就是“把一个向量除以自己的 <span displaypfx="inline-" class="mathjax-container">\(\ell_2\)</span> 范数（L2 Norm）”。这样得到的新向量长度恰好等于 1，因此它不再携带原来的大小信息，只保留方向信息，也就是单位方向向量（Unit Direction Vector）。</p>
<p>这里 <span displaypfx="inline-" class="mathjax-container">\(m_j\)</span> 是一个标量，表示这一列权重的整体长度； <span displaypfx="inline-" class="mathjax-container">\(\hat v_j\)</span> 是单位向量，表示这一列“指向哪里”。这一步只是重参数化（Reparameterization）：没有改动原模型，只是把每一列从“一个普通向量”改写成“长度 × 单位方向”。</p>
<p>DoRA 的更新写法通常记为：</p>
<span displaypfx="" class="mathjax-container">\[W'_{:,j}=m'_j\frac{V_{:,j}+\Delta V_{:,j}}{\left\|V_{:,j}+\Delta V_{:,j}\right\|_2},\quad \Delta V=BA\]</span>
<p>理解这条式子的关键，在于分清谁在控制方向，谁在控制大小。分式里的 <span displaypfx="inline-" class="mathjax-container">\(V_{:,j}+\Delta V_{:,j}\)</span> 先经过 <span displaypfx="inline-" class="mathjax-container">\(\ell_2\)</span> 归一化，因此无论 <span displaypfx="inline-" class="mathjax-container">\(\Delta V\)</span> 本身把这个向量拉长还是压短，归一化之后保留下来的都只有<span style="background-color: #c0c0c0;">方向信息</span>。换句话说，LoRA 产生的低秩增量 <span displaypfx="inline-" class="mathjax-container">\(BA\)</span> 在这里主要决定“这一列朝哪个方向偏转”，而不会直接把这一列的范数放大或缩小，因为范数已经被分母除掉了。</p>
<p>真正决定输出通道大小的是前面的标量 <span displaypfx="inline-" class="mathjax-container">\(m'_j\)</span>。如果把 <span displaypfx="inline-" class="mathjax-container">\(m'_j\)</span> 固定住，那么更新后的列向量范数始终满足 <span displaypfx="inline-" class="mathjax-container">\(\left\|W'_{:,j}\right\|_2=m'_j\)</span>，此时低秩更新确实只在改方向、不改大小；如果把 <span displaypfx="inline-" class="mathjax-container">\(m'_j\)</span> 设为可学习参数，那么 DoRA 就是在<span style="background-color: #c0c0c0;">两个通道里分别学习</span>：低秩分支 <span displaypfx="inline-" class="mathjax-container">\(\Delta V\)</span> 负责方向修正，标量 <span displaypfx="inline-" class="mathjax-container">\(m'_j\)</span> 负责幅值修正。无论是哪一种，方向与大小都不再像原始 LoRA 那样纠缠在同一个增量矩阵里。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/dora.png"><img class="alignnone size-full wp-image-41985" src="https://blog.gmem.cc/wp-content/uploads/2026/03/dora.png" alt="dora" width="1024" height="1024" /></a></p>
<p>这也是 DoRA 比原始 LoRA 更接近全参数微调的原因之一。普通 LoRA 直接对 <span displaypfx="inline-" class="mathjax-container">\(W\)</span> 加一个低秩增量 <span displaypfx="inline-" class="mathjax-container">\(\Delta W\)</span>，因此“方向变化”和“范数变化”混在同一个更新里；DoRA 则把这两件事显式拆开，使优化器可以分别决定“该往哪里转”与“该放大多少”。当任务需要更深地改写领域知识、重塑复杂判别边界或吸收更稳定的专业概念关系时，这种解耦往往更有表达力；代价则是额外的参数、归一化计算与实现复杂度。</p>
<div class="blog_h3"><span class="graybg">QLoRA</span></div>
<p>QLoRA 在 LoRA 基础上进一步把基座权重量化（Quantize）到低比特（常见 4-bit），以极小显存加载大模型；训练时仍只更新 LoRA 参数。它把“能不能放得下”从硬约束变成可控工程问题，是许多个人/小团队微调 7B/13B 的关键技术路径之一。</p>
<p>其核心思路是：冻结量化后的基座权重，只在前向/反向计算时对它们做解量化（Dequantize），而真正需要学习的仍是低秩增量。一个简化写法是：</p>
<span displaypfx="" class="mathjax-container">\[Y=X\,\mathrm{Dequant}(W_q)+\frac{\alpha}{r}XBA,\quad W_q=\mathrm{Quant}(W)\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(X\)</span> 是输入激活， <span displaypfx="inline-" class="mathjax-container">\(W_q\)</span> 是量化后冻结的基座权重， <span displaypfx="inline-" class="mathjax-container">\(\mathrm{Dequant}(W_q)\)</span> 表示把低比特权重恢复到计算精度后的近似值， <span displaypfx="inline-" class="mathjax-container">\(\frac{\alpha}{r}BA\)</span> 是 LoRA 分支， <span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span> 是缩放系数（Scaling Factor），用来控制低秩增量对原模型的影响强度。</p>
<p>这里的“解量化”不是把模型永久还原回全精度权重再训练，而是指：权重在显存或存储中仍以低比特形式保存，只是在一次具体矩阵乘法发生时，临时把对应块恢复成计算所需的近似浮点值，再与输入激活相乘。也就是说，量化主要解决的是<span style="background-color: #c0c0c0;">存储与显存占用</span>，而解量化解决的是<span style="background-color: #c0c0c0;">如何让这些低比特权重仍然参与正常线性计算</span>。</p>
<p>因此，所谓“解量化计算路径”指的是：训练框架是否知道如何从低比特权重 <span displaypfx="inline-" class="mathjax-container">\(W_q\)</span> 出发，在前向过程中正确恢复近似浮点表示、完成矩阵乘法，并在反向传播时把梯度只传给 LoRA 分支而不是错误地写回量化权重本体。若这条路径存在，那么量化基座虽然被冻结，仍然可以作为可计算的主干参与训练；若这条路径不存在，量化权重就只是某种压缩后的静态文件，能用于推理加载，却不能自然地嵌入 QLoRA 的训练图中。</p>
<p>这里的前提不是“训练者必须先拿到一份原始全精度基座，再亲手把它量化”，而是必须拥有一份<span style="background-color: #c0c0c0;">训练兼容的冻结量化基座</span>。如果基座本身已经是可被训练框架直接加载、解量化并挂接 PEFT 的 4-bit / 8-bit 版本，那么它完全可以直接作为 QLoRA 起点；但如果它只是面向推理部署的静态量化模型，例如某些只强调推理速度或离线压缩格式的 checkpoint，那么它往往并不适合作为 QLoRA 的训练底座。决定因素不在于“是不是量化过”，而在于这种量化形式是否仍然保留了训练时所需的解量化计算路径与 PEFT 兼容性。</p>
<p>QLoRA 的关键不只是“4-bit”，而是分块量化（Block-wise Quantization）：权重不会用一套全局刻度统一压缩，而是被划分成许多小块，每块各自保存缩放因子。若第 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 个块的量化码为 <span displaypfx="inline-" class="mathjax-container">\(\hat{w}^{(g)}\)</span>，对应缩放因子为 <span displaypfx="inline-" class="mathjax-container">\(s_g\)</span>，则可抽象写成：</p>
<span displaypfx="" class="mathjax-container">\[w^{(g)}\approx s_g\,\hat{w}^{(g)}\]</span>
<p>这个“分块”通常不是按语义结构切分，而是按固定块大小（例如若干连续权重为一组）直接切开。原因很简单：连续切块最容易实现，也最适合 GPU 并行。对每一个块，系统会单独估计一个比例尺 <span displaypfx="inline-" class="mathjax-container">\(s_g\)</span>，再用这个块自己的尺度去压缩和还原权重。于是更完整的写法通常是：</p>
<span displaypfx="" class="mathjax-container">\[w^{(g)}\approx s_g\cdot Q\!\left(\frac{w^{(g)}}{s_g}\right)\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(Q(\cdot)\)</span> 表示把归一化后的数值映射到低比特量化码。也就是说，比例尺 <span displaypfx="inline-" class="mathjax-container">\(s_g\)</span> 的作用是先把第 <span displaypfx="inline-" class="mathjax-container">\(g\)</span> 个块拉到一个统一的局部数值范围，再交给 4-bit 码本处理；反量化时再乘回 <span displaypfx="inline-" class="mathjax-container">\(s_g\)</span>。在最朴素的实现里， <span displaypfx="inline-" class="mathjax-container">\(s_g\)</span> 可以由该块的最大绝对值、均方根，或其他稳健统计量导出，本质都是在问同一个问题：<span style="background-color: #c0c0c0;">这一小块权重大致处在什么量级上</span>。</p>
<p>一个极小的数值例子能说明为什么需要逐块比例尺。假设某一块权重大致落在 <span displaypfx="inline-" class="mathjax-container">\([-0.1,0.1]\)</span>，另一块却落在 <span displaypfx="inline-" class="mathjax-container">\([-3,3]\)</span>。如果整个张量只共享一个全局比例尺，那么为了容纳大块的幅度，小块里的许多细微差异都会被压扁，量化后落到相同的低比特值；而若分别为这两块设置 <span displaypfx="inline-" class="mathjax-container">\(s_1\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\(s_2\)</span>，两块都能在自己的局部范围里充分利用有限的 4-bit 表示能力。也正因为如此，分块量化比全局共用一个缩放因子保留了更多有效信息，尤其更能缓解离群值（Outlier）对整体刻度的污染。</p>
<p>NF4（Normalized Float 4）进一步改进的不是“是否分块”，而是块内映射到哪一套 4-bit 码本。普通均匀量化更像是把一个区间机械地分成若干等宽小段；NF4 则利用很多 Transformer 权重在局部块内常呈现零中心、近似正态分布的事实，预先设计一套更贴近这种分布的离散代表值。于是块内每个权重更接近写成：</p>
<span displaypfx="" class="mathjax-container">\[w_i^{(g)}\approx s_g\cdot c_{q_i}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(c_{q_i}\)</span> 是 NF4 码本中的代表值， <span displaypfx="inline-" class="mathjax-container">\(q_i\)</span> 是对应的 4-bit 索引。它不是假设所有权重必须严格落在同一个固定区间内，而是先按块做尺度归一化，再用更贴近正态权重分布的码本去逼近这些局部值。</p>
<p>双重量化（Double Quantization）与分页优化器（Paged Optimizer）则是在这套分块量化之上继续做工程压缩。前者的思路是：每个块都要保存自己的 <span displaypfx="inline-" class="mathjax-container">\(s_g\)</span> 或相关元数据，这些量的数量虽然远少于权重数，但它们的精度通常更高，而且每个因子只服务一个较小的权重块，因此把这部分成本均摊回“每个参数”之后，并不总能忽略。举例说，若一个缩放因子用 32 bit 保存、对应一个 64 权重的块，那么仅缩放因子这一项就相当于给每个参数额外分摊了 <span displaypfx="inline-" class="mathjax-container">\(32/64=0.5\)</span> bit；在 4-bit 权重场景里，这已经不是一个可以随手忽略的附加成本。双重量化做的，就是把这些缩放因子或相关元数据再压一层，继续降低这笔“元数据税”。后者则借鉴操作系统的分页思想，把优化器状态按页在 GPU 与 CPU 内存之间调度，从而避免 Adam 一类优化器把峰值显存推得过高。它们都不改变 LoRA 的学习目标，也不改变 QLoRA 的基本数学形式，而是在工程层面继续压低“把大模型微调跑起来”的资源门槛。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/qlora.png"><img class="alignnone size-full wp-image-42007" src="https://blog.gmem.cc/wp-content/uploads/2026/03/qlora.png" alt="qlora" width="1024" height="1024" /></a></p>
<div class="blog_h3"><span class="graybg">Q-DoRA</span></div>
<p>Q-DoRA 可以看作 QLoRA 与 DoRA 的组合：基座仍采用低比特量化以节省显存，但更新形式不再是普通 LoRA，而是“量化基座 + 方向/尺度解耦”的 DoRA 结构。一个简化表达是：</p>
<span displaypfx="" class="mathjax-container">\[W'_{:,j}=m_j\frac{\mathrm{Dequant}(W_{q,:,j})+\Delta V_{:,j}}{\left\|\mathrm{Dequant}(W_{q,:,j})+\Delta V_{:,j}\right\|_2},\quad \Delta V=BA\]</span>
<p>它的工程含义很直接：用 QLoRA 解决“显存放不下”的问题，用 DoRA 缓解“低秩更新表达力不够”的问题。若资源非常紧、任务主要是格式控制与轻量指令对齐，普通 QLoRA 往往已经足够；若任务更偏逻辑推理增强、专业知识注入、复杂边界判别或高质量垂直领域适配，Q-DoRA 往往是更稳妥的折中。对应代价是训练更慢、实现更复杂，且并非所有推理栈都像原始 LoRA 那样原生支持。</p>
<div class="blog_h3"><span class="graybg">LoRA-MoE</span></div>
<p>LoRA-MoE 可以理解为“适配器级的 MoE”：不把 FFN 变成稀疏专家，而是保留基座不变，准备多份 LoRA 作为“领域专家”，再用一个路由器（Router）按请求/句子/甚至 token 选择或加权组合这些 LoRA。直觉上，它用极小的可训练参数，为同一个基座提供多域能力，同时避免把所有任务硬合并到一份权重里。</p>
<p>一种抽象表达是把输出写成“基座 + 适配器混合”：</p>
<span displaypfx="" class="mathjax-container">\[h' = f_{\text{base}}(h) + \sum_{e\in \mathcal{E}} g_e(x)\,f_{\text{lora},e}(h),\quad \sum_e g_e(x)=1\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(g_e(x)\)</span> 是路由权重，可以来自显式分类器（域识别）、检索到的任务标签，或一个可训练的 gating 网络。工程上，LoRA-MoE 的关键不在公式，而在路由与评测：你需要定义“什么输入该走哪套 LoRA”，并防止路由错误导致质量抖动。</p>
<p>截至 2026 年，LoRA-MoE 的实际地位更接近<span style="background-color: #c0c0c0;">高级可选架构</span>，而不是参数高效微调里的默认主流基线。它已经形成了一条持续演进的方法线，说明“多 LoRA + 路由”并非概念玩具；但在更常见的工业部署里，成熟默认方案仍然往往是“单基座 + 多个独立 LoRA 适配器”，按请求或租户切换，而不是把路由器永久并入模型主干。原因并不神秘：LoRA-MoE 除了要训练适配器本身，还要额外处理路由质量、专家利用不均、冷专家几乎不被激活、线上可观测性以及请求分布变化带来的稳定性问题。只有当任务确实需要<span style="background-color: #c0c0c0;">在同一个运行图里动态融合多域能力</span>，而不是简单地在不同 LoRA 之间切换时，LoRA-MoE 的额外复杂度才更值得支付。</p>
<div class="blog_h3"><span class="graybg">多 LoRA 热切换与共享基座</span></div>
<p>比 LoRA-MoE 更常见、也更容易落地的方案，是<span style="background-color: #c0c0c0;">多个独立 LoRA 共享同一个基座模型</span>。这里的共享不是把多份 LoRA 合并成一套永久权重，而是让基座参数在 GPU 中只保留一份；不同请求到来时，再按请求绑定对应的适配器。这样做的直接收益是：显存里最重的那部分参数不需要为每个任务重复存一遍，而任务差异主要体现在额外加载的轻量增量权重上。</p>
<p>从推理执行角度看，这种“热切换”并不意味着每来一个请求就重新加载整个模型。更常见的做法是：基座常驻显存，LoRA 适配器按需驻留在 GPU 或 CPU 侧缓存中；请求只需声明“当前使用哪一份适配器”，调度器就会在对应层上把这一份 LoRA 增量接入当前 forward。若某个适配器近期很少被访问，它可以被换出；当请求再次到来时，再从本地盘、对象存储或 Hub 拉回。于是系统真正管理的是<span style="background-color: #c0c0c0;">适配器缓存与调度</span>，而不是整模型重载。</p>
<p>这条路线之所以在 2026 年更主流，是因为它把“多域能力”问题拆成了两个更容易控制的子问题：第一，训练阶段各自产出独立 LoRA，任务之间天然隔离；第二，推理阶段只做选择和缓存，不必额外训练路由器，也不会把多个领域永久混到同一组权重里。代价主要落在工程侧：适配器的 rank、目标模块集合、张量并行配置与基座版本必须兼容；同时服务系统还要决定 GPU 能同时保留多少份 LoRA、超出容量时按什么策略驱逐，以及批内是否允许不同请求混用不同适配器。</p>
<p>截至 2026 年，这已经不是纸面方案，而是主流高吞吐推理框架的标准能力之一。vLLM 支持按请求选择 LoRA，既可以在服务启动时预注册，也支持通过运行时 API 与解析插件动态加载；SGLang 支持同一批次中的不同序列绑定不同 LoRA，并提供适配器加载、驱逐、后端 kernel 与批内 LoRA 数量控制；Hugging Face TGI 也支持在启动时加载多份 LoRA 并在请求中指定 adapter；TensorRT-LLM 则已经提供多 LoRA 推理示例与运行时请求绑定接口。换句话说，<span style="background-color: #c0c0c0;">多 LoRA 共享基座</span>在今天更像是一种成熟的服务形态，而不是实验性质的技巧。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">方案</td>
<td style="text-align: center;">参数/存储</td>
<td style="text-align: center;">推理开销</td>
<td style="text-align: center;">多域能力</td>
<td style="text-align: center;">主要风险</td>
</tr>
</thead>
<tbody>
<tr>
<td>多 LoRA 合并</td>
<td>单份权重</td>
<td>最低（一次 forward）</td>
<td>不稳定（易相互干扰）</td>
<td>合并策略难；回滚困难</td>
</tr>
<tr>
<td>LoRA-MoE（路由）</td>
<td>多份 LoRA + 路由器</td>
<td>低~中（取决于是否多专家叠加）</td>
<td>强（可按域选择）</td>
<td>路由错误；线上一致性与可观测性要求更高</td>
</tr>
<tr>
<td>全量 MoE（FFN 专家）</td>
<td>多专家权重</td>
<td>中（Top-k 专家计算）</td>
<td>强（容量大）</td>
<td>训练与部署复杂；负载均衡与稳定性</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">基于 Prompt 的微调</span></div>
<p>与 LoRA、Adapter 这类“直接改模型内部参数化”的路线不同，基于 Prompt 的微调把任务适配写在输入条件上。它的核心不是重写主干权重，而是构造一小段能够引导模型行为的任务条件，让模型在保持基座冻结的前提下，沿着这段条件生成更符合目标任务的输出。</p>
<p>这里需要先把两类 Prompt 区分开。硬提示（Hard Prompt）是人工编写的离散文本提示，本质上属于提示工程（Prompt Engineering），而不是参数高效微调；软提示（Soft Prompt）则是一组可训练的连续向量，通常可以看成“不对应真实词表 token 的虚拟 token embedding”。前者没有训练参数，可解释性强但搜索空间受限；后者进入连续空间后更容易通过梯度优化找到有效解，因此才构成 Prompt Tuning、Prefix Tuning、P-Tuning、P-Tuning v2 这一路软提示微调家族。</p>
<p>从机制上看，软提示路线的共同点是：人为构造或学习一小段任务向量，把它们拼接到原始输入或注意力状态中，再让这些额外向量参与模型的注意力计算，从而影响后续真实 token 的生成和判别。它的工程优势非常明确：主干参数无需为每个任务复制一份，多任务场景下只需切换不同的 Prompt 参数即可。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">路线</td>
<td style="text-align: center;">作用位置</td>
<td style="text-align: center;">可训练参数量</td>
<td style="text-align: center;">主要优点</td>
<td style="text-align: center;">主要边界</td>
</tr>
</thead>
<tbody>
<tr>
<td>硬提示</td>
<td>输入文本</td>
<td>0</td>
<td>可读、可解释、适合快速验证</td>
<td>离散搜索困难，效果上限受人工设计限制</td>
</tr>
<tr>
<td>Prompt Tuning</td>
<td>输入层</td>
<td>极少</td>
<td>最轻、最易多任务切换</td>
<td>只影响输入端，表达力最弱</td>
</tr>
<tr>
<td>Prefix Tuning</td>
<td>各层注意力</td>
<td>很少</td>
<td>比纯输入层软提示更强，能在每层引导注意力</td>
<td>实现更复杂，与模型结构耦合更深</td>
</tr>
<tr>
<td>LoRA / QLoRA</td>
<td>模型内部线性层</td>
<td>较少</td>
<td>效果更稳、更通用</td>
<td>需要改写模型参数化与训练图</td>
</tr>
</tbody>
</table>
<p>因此，硬提示、软提示与 LoRA 的差异，并不只是“参数多少”，而是任务条件被写入模型的层次不同。硬提示只改自然语言输入；Prompt Tuning 把任务条件写进输入嵌入；Prefix Tuning 把任务条件送进每层注意力；LoRA 则直接改写模型内部线性映射的参数化。条件写得越深，通常表达力越强，但实现和系统复杂度也越高。</p>
<div class="blog_h3"><span class="graybg">Prefix Tuning</span></div>
<p>Prefix Tuning 属于软提示类 PEFT。它学习的不是自然语言前缀文本，而是一组连续可训练向量（Continuous Prefix），并把这组向量作为每一层注意力里的额外 Key / Value 注入。若某层原本的注意力键值对为 <span displaypfx="inline-" class="mathjax-container">\(K,V\)</span>，则 Prefix Tuning 可以理解为把它们扩展成 <span displaypfx="inline-" class="mathjax-container">\([K_{\text{prefix}};K]\)</span> 与 <span displaypfx="inline-" class="mathjax-container">\([V_{\text{prefix}};V]\)</span>，让后续 token 在每一层都能访问这段任务特定“前缀记忆”。</p>
<p>它的关键不只是“在输入前加几个向量”，而是<span style="background-color: #c0c0c0;">在所有层分别注入前缀状态</span>。不同层的前缀通常并不共享；每一层都有自己的 prefix 参数，因为浅层和深层承担的表示功能并不相同。于是，Prefix Tuning 更像是在每一层都额外挂上一小段可学习上下文，让模型在整条前向路径中持续感知任务条件，而不是只在输入口看一眼提示后就完全交给主干自行传播。</p>
<p>若前缀长度记为 <span displaypfx="inline-" class="mathjax-container">\(m\)</span>、模型隐藏维度记为 <span displaypfx="inline-" class="mathjax-container">\(d\)</span>、层数记为 <span displaypfx="inline-" class="mathjax-container">\(L\)</span>，那么最粗略的参数量量级可以理解为 <span displaypfx="inline-" class="mathjax-container">\(O(Lmd)\)</span>。这也是它为什么通常比全量微调和 LoRA 更轻，但又明显重于单纯输入层 Prompt Tuning：它的参数量来自“每层各有一小段前缀”，而不是只在输入层保存一组虚拟 token。</p>
<div class="blog_h4"><span class="graybg">训练稳定性与重参数化</span></div>
<p>直接把前缀向量当作自由参数去优化，并不总是最稳定。因为这些向量一开始就要进入每层注意力，如果初始化过于随意，训练前期很容易让注意力分布出现较大抖动。为此，Prefix Tuning 的经典实现常引入一层小型重参数化网络，例如用一个 MLP 先把较低维或更结构化的中间表示映射成真正送入各层的 prefix Key / Value。</p>
<p>这种做法的本质不是给 Prefix Tuning 增加永久推理负担，而是把训练阶段的优化空间改造成更平滑、更容易收敛的形式。训练完成后，这个 MLP 生成出的前缀状态通常可以被直接缓存或固化，推理时未必需要继续保留完整重参数化模块。因此，它更像一种<span style="background-color: #c0c0c0;">训练期稳定化技巧</span>，而不是 Prefix Tuning 必须背负的长期结构成本。</p>
<div class="blog_h4"><span class="graybg">适用性评估</span></div>
<p>它与 Prompt Tuning 的差别不在于“前缀长短”，而在于注入位置。Prompt Tuning 只在输入嵌入层增加一小段软提示；Prefix Tuning 则把任务参数直接送进每层注意力，因此它通常更有表达力，也更接近“在每层引导模型如何读写上下文”。代价是实现更复杂，模型结构耦合更深，训练与推理栈也更需要原生支持。</p>
<p>到 2026 年，Prefix Tuning 仍然是成立且标准的 PEFT 方法，但它在主流大语言模型指令微调里的存在感已经明显弱于 LoRA。它最有价值的场景通常是：希望极小参数量地控制条件生成行为、使用 Encoder-Decoder 或较经典的条件生成架构，或者研究上需要把“任务条件”明确写进每层注意力。若任务是当代 Decoder-only LLM 的通用指令对齐、风格迁移或领域适配，LoRA / QLoRA 往往仍是默认起点：更稳、更通用、推理框架支持也更成熟。</p>
<div class="blog_h3"><span class="graybg">Prompt Tuning</span></div>
<p>Prompt Tuning（软提示/Soft Prompt）同样属于 PEFT，但它比 Prefix Tuning 更轻：只在输入嵌入层前面拼接一小段可训练“虚拟 token embedding”，而不改动 Transformer 内部层的参数结构。设输入嵌入序列为 <span displaypfx="inline-" class="mathjax-container">\(E(x)\)</span>，软提示为 <span displaypfx="inline-" class="mathjax-container">\(P\in\mathbb{R}^{m\times d}\)</span>，则模型实际看到的是拼接后的序列 <span displaypfx="inline-" class="mathjax-container">\([P;E(x)]\)</span>。训练时更新的只有 <span displaypfx="inline-" class="mathjax-container">\(P\)</span>，基座参数保持冻结。</p>
<p>它的核心假设是：对于某些任务，模型原本的能力已经足够，真正缺少的只是一个足够好的“任务启动条件”。如果这组输入层虚拟 token 能把模型推到合适的工作点，后面的冻结主干就能沿着原有能力完成任务。因此，Prompt Tuning 在参数量上往往可以做到比 LoRA 还小一个量级，尤其适合“大量轻任务共享同一基座”的场景。</p>
<div class="blog_h4"><span class="graybg">与 Prefix Tuning 的区别</span></div>
<p>Prompt Tuning 与 Prefix Tuning 的根本区别，不在于二者都用了虚拟 token，而在于任务条件写入的深度不同。Prompt Tuning 只在输入层插入软提示，后续所有层看到的都是这段输入在主干网络中自然传播后的结果；Prefix Tuning 则直接在每一层注意力中附加前缀状态，使任务条件持续存在于整条注意力链路中。前者最轻，后者更强。</p>
<p>也正因为如此，Prompt Tuning 在超大模型上有时会随着基座规模增大而变得更有效，因为大模型本身已经足够强，输入端的一点点软条件就足以触发所需能力；而在中小模型或需要强行为控制的任务上，它往往不如 Prefix Tuning、LoRA 稳定。</p>
<div class="blog_h4"><span class="graybg">家族扩展与适用性</span></div>
<p>围绕这一路线还发展出 P-Tuning、P-Tuning v2 等变体。它们的共同目标，都是让软提示不只停留在“输入前拼一小段向量”这么简单，而是通过更强的参数化或更深层的注入方式，提高在理解类任务和较小模型上的表现。若把家族关系压缩来看：Prompt Tuning 是最轻的输入层软提示；Prefix Tuning 把软提示推进到各层注意力；P-Tuning / P-Tuning v2 则在“如何生成这些提示、提示该注入多深”上继续增强。</p>
<p>到 2026 年，Prompt Tuning 仍然实用，但更像<span style="background-color: #c0c0c0;">轻量特化选项</span>而不是主流默认路线：当目标是极小参数、海量任务复用、低存储部署或 prompt-adapter 风格服务时，它仍有现实价值；当目标是指令遵循、复杂格式约束、长对话行为修正或稳定领域适配时，LoRA / QLoRA 往往更稳妥，Prefix Tuning 也通常比纯输入层软提示更有表达力。</p>
<div class="blog_h2"><span class="graybg">微调技术选型</span></div>
<p>前面列出的全量微调、部分参数微调、Prompt 系软提示、Adapter、LoRA、QLoRA 并不是互相替代的“流行名词清单”，而是针对不同约束条件的不同最优解。真正决定选型的，通常不是单一维度上的“效果最好”，而是四个问题同时成立时的交集：样本量够不够、GPU 预算有多紧、任务到底是在改行为还是改知识、上线时更看重单任务极致效果还是多任务复用与切换。</p>
<p>样本量是第一道分界线。数据很少时，更应优先考虑冻结主干参数的路线，例如 Prompt Tuning、Prefix Tuning、LoRA、QLoRA 或更轻的部分参数微调。原因并不只是“省显存”，而是冻结主干更容易保留预训练先验，降低小数据把模型硬拉向局部模式的风险。数据足够大、分布足够稳定、目标能力又确实需要深度改写时，全量微调才更值得支付它的高成本，因为只有在这种条件下，放开全部参数带来的表达上限才真正有机会被利用。</p>
<p>GPU 资源是第二道分界线。若显存非常紧，QLoRA 往往是生成模型微调的现实起点；若任务更轻、希望一个基座承载大量小任务，Prompt Tuning 或 Prefix Tuning 的存储优势会更突出；若 GPU 充裕且追求最强任务特化，上限仍然在全量微调一侧。换句话说，量化 LoRA 解决的是“放不放得下”，LoRA 解决的是“如何低成本改行为”，而全量微调解决的是“是否要把整套模型一起重写”。</p>
<p>任务性质决定第三道分界线。若变化主要发生在输入接口，例如新增专有 token、符号或特殊控制标记，输入层微调与软提示通常比全模型更新更自然；若变化主要体现在最终读出或评分方式，输出层微调往往就够；若目标是稳定调整指令遵循、格式约束、风格边界、多轮行为或一般性领域适配，LoRA / QLoRA 通常是默认解；若真正需要吸收大量新知识、重构深层表示、改变词表、上下文长度或位置编码等底层设定，则继续预训练或全量微调才更匹配问题本质。</p>
<p>推理形态决定第四道分界线。多任务在线服务最看重“一个基座 + 多个轻量增量”时，LoRA 及其热切换形态通常最实用；Prompt Tuning 与 Prefix Tuning 也具备同样的任务切换优势，只是主流推理框架与工业实践对 LoRA 的支持更成熟。Adapter 虽然同样具备插拔式优点，但它会在前向路径里保留额外计算分支，因此在当代大模型场景里通常不再是默认首选。若目标是最低推理延迟，合并后的 LoRA 与单体全量微调模型通常更占优；若目标是海量任务共享一个基座、频繁热切换，则运行时加载轻量适配器更灵活。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">微调技术</td>
<td style="text-align: center;">何时优先考虑</td>
<td style="text-align: center;">主要优点</td>
<td style="text-align: center;">主要代价或边界</td>
</tr>
</thead>
<tbody>
<tr>
<td>全量微调</td>
<td>GPU 资源充足、样本量充足、任务需要深度改写模型能力</td>
<td>表达上限最高，最适合深领域迁移与强任务特化</td>
<td>显存、时间和存储成本最高，也最容易削弱通用泛化</td>
</tr>
<tr>
<td>部分参数微调</td>
<td>只需要改特定层或特定结构，或现有框架不便直接接入 PEFT</td>
<td>选择性强，能用较低成本试探“真正该改哪里”</td>
<td>容量有限，往往更像折中方案而不是通用默认解</td>
</tr>
<tr>
<td>Prompt Tuning / Prefix Tuning</td>
<td>样本较少、任务很多、希望极小增量复用同一基座</td>
<td>参数极少，保留主干泛化，适合多任务轻量切换</td>
<td>表达力通常弱于 LoRA；Prefix 实现更复杂，Prompt 在复杂行为控制上更弱</td>
</tr>
<tr>
<td>Adapter</td>
<td>需要显式模块化、任务插拔或特定架构兼容路径</td>
<td>结构清晰、任务隔离好、便于组织内复用</td>
<td>前向路径保留额外分支，在大模型场景里主流度已弱于 LoRA</td>
</tr>
<tr>
<td>LoRA</td>
<td>通用生成模型微调、多任务适配、需要效果与效率平衡</td>
<td>效果稳、生态成熟、可合并、可热切换，是当前主流默认基线</td>
<td>仍需选择 rank、挂载位置与训练稳定性权衡；深知识注入时可能容量不足</td>
</tr>
<tr>
<td>QLoRA / 量化 LoRA</td>
<td>GPU 资源非常有限，但仍需要微调较大生成模型</td>
<td>显著降低显存门槛，让 7B / 13B 级模型微调更可落地</td>
<td>训练链路更复杂；若任务要求极强表达力，最终仍可能需要更重路线</td>
</tr>
</tbody>
</table>
<p>因此，微调技术选型可以压缩成一条很实际的经验顺序：先判断是否根本不该训练参数，而应优先做参数外优化；若需要训练，再判断任务是否只是轻量行为适配，此时 LoRA / QLoRA 往往是默认起点；若样本极少且强调多任务极致轻量切换，软提示路线才更有吸引力；若任务要求深度改写底座知识或结构，再考虑继续预训练与全量微调。只有当这些路线都无法满足目标时，才值得继续向更重、更贵的训练方式推进。</p>
<p>再往前走一步，就是下一节的偏好对齐问题：如果模型已经学会了任务本身，却仍然不会在多个可行回答中稳定偏向人类真正想要的那个，那么问题就不再只是“选哪种微调技术”，而是要不要进入奖励模型、DPO、PPO、GRPO 这一层相对偏好优化。</p>
<div class="blog_h2"><span class="graybg">强化学习对齐</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">路线</td>
<td style="text-align: center;">直接监督信号来自哪里</td>
<td style="text-align: center;">参考模型的作用</td>
<td style="text-align: center;">如何防止偏离 SFT 太远</td>
</tr>
</thead>
<tbody>
<tr>
<td>RLHF + PPO</td>
<td>奖励模型输出的 reward；奖励模型本身来自人类偏好数据</td>
<td>作为参考策略 <span displaypfx="inline-" class="mathjax-container">\(\pi_{\mathrm{ref}}\)</span>，通常是 SFT 模型的冻结副本</td>
<td>显式加入 KL 项，把当前策略锚定在参考分布附近</td>
</tr>
<tr>
<td>DPO</td>
<td>显式偏好对 <span displaypfx="inline-" class="mathjax-container">\((x,y_w,y_l)\)</span></td>
<td>作为锚点，比较“当前模型相对参考模型是否更偏向好答案、远离差答案”</td>
<td>不单独写 KL 惩罚项，但在损失中隐式约束模型不要脱离参考模型过远</td>
</tr>
<tr>
<td>GRPO</td>
<td>同一 prompt 下一组回答的评分、排序或规则反馈</td>
<td>常作为参考策略或 KL 正则锚点；具体是否使用取决于实现</td>
<td>通过组内相对优势更新，必要时再叠加参考模型 KL 约束</td>
</tr>
</tbody>
</table>
<p>强化学习对齐（RL-based Alignment）更准确的说法是“偏好对齐（Preference Alignment）”：用偏好信号把模型输出推向“更符合人类/评审标准”的区域。监督微调（SFT）解决的是“模型是否会按指令作答”，偏好对齐解决的则是“在多个看似都能回答问题的候选答案中，模型是否会稳定偏向更有帮助、更安全、更符合人类预期的那个”。两者并不重复，而是前后衔接的两层约束。</p>
<p>监督微调本身当然可以承担一部分对齐工作。只要训练数据里显式包含拒答样例、安全边界、好坏答案对照、批判依据（Critique Rationale）、自我修正链路，模型就能通过有监督学习吸收相当一部分“什么回答风格更合适、什么回答应当避免”的行为模式。很多现代对齐流程的第一步，本来就是把这些规则先写进 SFT 数据，再让模型学会基础行为边界。</p>
<p>但 SFT 的学习目标本质上仍然是<span style="background-color: #c0c0c0;">给定输入，去拟合某个目标输出</span>。若把提示词记为 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>、参考答案记为 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>，那么它优化的是 <span displaypfx="inline-" class="mathjax-container">\(\log \pi_\phi(y|x)\)</span> 这一类似然目标。即使数据里同时给出“好答案、坏答案、批判依据”，SFT 也主要是在学习如何复现这些文本本身，而不是直接学习“在多个候选回答之间，哪个应该被稳定偏好”。这意味着它更擅长教会模型<span style="background-color: #c0c0c0;">怎样说</span>，却不天然等价于教会模型<span style="background-color: #c0c0c0;">怎样在多个可行答案里做排序</span>。</p>
<p>拒绝采样微调可以看作这两层之间的一条中间路线。它先让模型对同一提示词生成多个候选，再借助验证器、规则或奖励信号只保留最好的一部分，把筛选结果重新写回监督数据，再继续做 SFT。这样做比纯手工监督更能利用自动评估，但仍然没有显式保留“胜者为何优于败者”的相对排序结构，因此它更像<span style="background-color: #c0c0c0;">把偏好信号先离散化成高质量目标答案，再交给监督微调吸收</span>。</p>
<p>偏好对齐之所以需要单独成层，关键就在这里。很多真实问题并不存在唯一标准答案，而是存在一组“都不算错、但质量不同”的候选回答：有的更完整，有的更安全，有的更符合语气预期，有的虽然事实没错却明显不够有帮助。把这种问题压成单一参考答案做 SFT，模型容易学到某一种写法，却未必真正学到“为什么 A 应优于 B”。批判依据也有同样的局限：模型可以学会输出一段像样的批判文本，但这并不自动保证它在自由生成时，会稳定地把这些批判原则内化为回答排序规则。</p>
<p>因此，今天更准确的技术分层是：SFT 可以完成<span style="background-color: #c0c0c0;">基础行为对齐</span>，而偏好优化负责处理<span style="background-color: #c0c0c0;">相对偏好排序</span>。前者让模型学会回答、学会拒答、学会遵守基本格式；后者让模型在多个都看似合理的答案之间，更稳定地偏向人类真正想要的那个。也正因为如此，现代对齐并不必然等于“强化学习”本身：PPO / RLHF 是一条路线，DPO、ORPO 等把偏好直接写进损失的路线则是另一条路线。它们解决的是同一个问题，只是优化手段不同。</p>
<p>这一层之所以重要，还因为大模型的质量并不能被单一指标完整刻画。古德哈特定律（Goodhart's Law）指出：一旦某个指标变成优化目标，它往往就不再是一个好的指标。放到模型对齐里，这意味着如果只盯住某个狭窄基准分数，模型很可能学会迎合该指标，却牺牲真正的可用性、稳健性与安全性。偏好对齐因此更强调相对排序、多维度评审与参考模型约束，而不是把“好答案”简化成单一静态分数。</p>
<p>从工程上看，主流路线分为两类：</p>
<ul>
<li>显式奖励：先训练奖励模型（Reward Model），再用 PPO 等策略优化算法做 RLHF。</li>
<li>直接偏好：不显式训练奖励模型，直接用偏好对优化策略（例如 DPO）。</li>
</ul>
<div class="blog_h3"><span class="graybg">奖励模型（Reward Model）</span></div>
<p>奖励模型（Reward Model, RM）把（提示词 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，回复 <span displaypfx="inline-" class="mathjax-container">\(y\)</span>）映射为一个标量分数 <span displaypfx="inline-" class="mathjax-container">\(r_\theta(x,y)\in\mathbb{R}\)</span>。它的职责不是生成答案，而是充当自动评审器：输入“问题 + 回答”，输出一个可比较的质量信号，用来近似人类对回答质量的偏好判断。</p>
<div class="blog_h4"><span class="graybg">偏好数据</span></div>
<p>奖励模型的训练数据通常不是单条样本加绝对分数，而是成对偏好数据。对同一提示词 <span displaypfx="inline-" class="mathjax-container">\(x\)</span>，先让模型生成多个候选回答，再由人工标注员或更强的评审模型选择其中更优的一条，形成三元组 <span displaypfx="inline-" class="mathjax-container">\((x,y_w,y_l)\)</span>：其中 <span displaypfx="inline-" class="mathjax-container">\(y_w\)</span> 是被接受的回答（chosen response），<span displaypfx="inline-" class="mathjax-container">\(y_l\)</span> 是被拒绝的回答（rejected response）。</p>
<p>这种二选一偏好标注通常比直接打 1 到 5 分更稳定。原因并不复杂：绝对分数依赖个人尺度，主观漂移大；相对偏好只要求判断 A 和 B 谁更好，一致性通常更高，标注成本也更低。因此，现代偏好对齐流程更常见的监督信号是排序关系，而不是绝对评分。</p>
<div class="blog_h4"><span class="graybg">模型形态</span></div>
<p>奖励模型通常并不是从零开始训练，而是以已经完成 SFT 的语言模型为骨干（Backbone）进行改造：保留主体 Transformer 表示层，移除原先用于生成下一个 token 的语言建模头（LM Head），换成一个输出单一标量的质量头（Reward Head）。这样做的好处是，奖励模型继承了 SFT 模型对指令与回答结构的理解，只需要继续学习“哪种回答更好”这一层偏好判断。</p>
<p>对一对“胜/负”样本 <span displaypfx="inline-" class="mathjax-container">\((x,y_w,y_l)\)</span>，常用 Bradley–Terry / Logistic 形式把分数差转为偏好概率：</p>
<span displaypfx="" class="mathjax-container">\[\Pr(y_w \succ y_l\mid x)=\sigma\!\left(r_\theta(x,y_w)-r_\theta(x,y_l)\right)\]</span>
<p>并用对数损失训练：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{RM}}(\theta)=-\log \sigma\!\left(r_\theta(x,y_w)-r_\theta(x,y_l)\right)\]</span>
<p>关键点：sigmoid 用在“分数差”上，而不是对每个 <span displaypfx="inline-" class="mathjax-container">\(r_\theta(x,y)\)</span> 再套一层 sigmoid。分数本身不需要限制在 <span displaypfx="inline-" class="mathjax-container">\([0,1]\)</span>；概率来自差值的 logistic 映射。</p>
<p>参数 <span displaypfx="inline-" class="mathjax-container">\(\theta\)</span> 表示奖励模型的参数集合（Parameter Set），包含全部权重矩阵与偏置项，并非单一标量。</p>
<p>单调性视角：因为 <span displaypfx="inline-" class="mathjax-container">\(\sigma\)</span> 单调递增且 <span displaypfx="inline-" class="mathjax-container">\(-\log(\cdot)\)</span> 在 <span displaypfx="inline-" class="mathjax-container">\((0,1)\)</span> 上单调递减，所以 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{L}_{\mathrm{RM}}\)</span> 对分数差 <span displaypfx="inline-" class="mathjax-container">\(\Delta r=r_\theta(x,y_w)-r_\theta(x,y_l)\)</span> 单调递减。训练会推动 <span displaypfx="inline-" class="mathjax-container">\(\Delta r\)</span> 变大，从而把胜者分数推高、败者分数压低。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/reward.png"><img class="alignnone size-full wp-image-42015" src="https://blog.gmem.cc/wp-content/uploads/2026/03/reward.png" alt="reward" width="1024" height="1024" /></a></p>
<p>&nbsp;</p>
<div class="blog_h3"><span class="graybg">RLHF</span></div>
<p>RLHF（Reinforcement Learning from Human Feedback）把整个偏好对齐流程拆成三段：先收集偏好数据，再训练奖励模型，最后用奖励模型为主语言模型提供优化信号。它的核心贡献不在于“使用了强化学习”本身，而在于把原本昂贵、缓慢、难以规模化的人类评审，转化成可以自动计算的奖励分数。</p>
<p>在 RLHF 里，语言生成被改写为一个强化学习问题：token 是动作（Action），策略是语言模型 <span displaypfx="inline-" class="mathjax-container">\(\pi_\phi(y|x)\)</span>，奖励来自 <span displaypfx="inline-" class="mathjax-container">\(r_\theta(x,y)\)</span>。早期最经典、也最具代表性的做法，是以 PPO（Proximal Policy Optimization）作为策略更新算法，用奖励模型给出的分数提高高质量回答的生成概率，同时压低低质量回答的概率。常见目标写成：</p>
<span displaypfx="" class="mathjax-container">\[\max_{\phi}\ \mathbb{E}_{y\sim \pi_\phi(\cdot|x)}\big[r_\theta(x,y)\big]-\beta\,D_{\mathrm{KL}}\!\left(\pi_\phi(\cdot|x)\,\|\,\pi_{\mathrm{ref}}(\cdot|x)\right)\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\pi_{\mathrm{ref}}\)</span> 通常不是额外训练出来的一套神秘模型，而就是<span style="background-color: #c0c0c0;">PPO 开始前那一版 SFT 模型的冻结副本</span>。标准顺序通常是：先从预训练基座得到 SFT 模型，再把这份 SFT checkpoint 复制成两路，一路继续作为可训练策略 <span displaypfx="inline-" class="mathjax-container">\(\pi_\phi\)</span>，一路冻结为参考模型 <span displaypfx="inline-" class="mathjax-container">\(\pi_{\mathrm{ref}}\)</span>。因此并不存在“先有参考模型还是先有策略模型”的循环依赖；二者都来自同一个 SFT 起点，只是一个继续更新，一个保持不动。</p>
<p>式子里的 KL 项与 KL 散度（Kullback–Leibler Divergence）是直接对应的关系：这里所谓的“KL 项”，就是目标函数中的 <span displaypfx="inline-" class="mathjax-container">\(\beta\,D_{\mathrm{KL}}(\pi_\phi\|\pi_{\mathrm{ref}})\)</span> 这一项，只不过前面再乘了一个权重系数 <span displaypfx="inline-" class="mathjax-container">\(\beta\)</span>。它的作用不是做额外评测，而是作为正则约束，把当前策略锚定在 SFT 参考分布附近，防止模型为了讨好奖励模型而偏离过远，出现奖励黑客（Reward Hacking）、语言退化或能力崩塌。PPO 在这类任务里长期被广泛采用，原因正是它对策略更新幅度加了“护栏”，能在追求更高奖励和保持原有能力之间维持相对稳定的折中。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/rlhf.png"><img class="alignnone size-full wp-image-42019" src="https://blog.gmem.cc/wp-content/uploads/2026/03/rlhf.png" alt="rlhf" width="1024" height="1024" /></a></p>
<div class="blog_h4"><span class="graybg">PPO</span></div>
<p>PPO（Proximal Policy Optimization）之所以长期是 RLHF 的默认优化器，不是因为它在理论上最优，而是因为它足够稳。普通策略梯度（Policy Gradient）的问题在于：一旦某次更新把策略推得过远，语言模型就可能突然偏离原有分布，表现为回复风格失真、可读性下降、奖励黑客，甚至整体能力退化。PPO 的核心改进就是限制“这一步最多改多少”，让策略朝高奖励方向移动，但每次只允许小步修正。</p>
<p>它最经典的形式是 clipped objective。若当前策略与旧策略的概率比记为 <span displaypfx="inline-" class="mathjax-container">\(\rho_t=\frac{\pi_\phi(a_t|s_t)}{\pi_{\phi_{\mathrm{old}}}(a_t|s_t)}\)</span>，对应优势函数（Advantage Function）为 <span displaypfx="inline-" class="mathjax-container">\(A_t\)</span>，则目标可写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{PPO}}(\phi)=\mathbb{E}_t\left[\min\left(\rho_t A_t,\ \mathrm{clip}(\rho_t,1-\epsilon,1+\epsilon)A_t\right)\right]\]</span>
<p>这里的 <span displaypfx="inline-" class="mathjax-container">\(\epsilon\)</span> 是更新护栏宽度。若某次更新让概率比偏离 1 太远，clip 就会截断继续放大的收益，阻止模型为了追求更高奖励而走得过猛。对 LLM 而言，这个机制尤其重要，因为语言模型的输出分布非常高维，只要少量 token 的条件概率被过度放大，就可能连锁改变整段回答的行为模式。</p>
<p>放到 RLHF 流程里，PPO 的完整闭环通常是：先用当前策略对同一提示词采样若干回答，再用奖励模型评分，并结合参考模型的 KL 惩罚构造最终回报；随后估计优势 <span displaypfx="inline-" class="mathjax-container">\(A_t\)</span>，再用 clipped objective 更新策略。也正因为这里同时牵涉采样、奖励模型、参考模型、旧策略快照与优势估计，PPO 路线的工程链条明显比 DPO 更长，训练成本和调参复杂度也更高。</p>
<p>工业实践中，奖励往往也不是单一维度。一个典型做法是分别训练“有用性（Helpfulness）”与“安全性（Safety）”奖励模型，再按加权和形成总奖励，例如 <span displaypfx="inline-" class="mathjax-container">\(R_{\mathrm{total}}=\alpha R_{\mathrm{helpful}}+\beta R_{\mathrm{safety}}\)</span>。这样可以显式控制不同对齐目标之间的权重，而不是把所有偏好都压缩进一个不可分解的单一评分器里。</p>
<div class="blog_h3"><span class="graybg">DPO</span></div>
<p>DPO（Direct Preference Optimization）直接用偏好对 <span displaypfx="inline-" class="mathjax-container">\((x,y_w,y_l)\)</span> 优化策略，不显式训练奖励模型，也不需要 PPO rollout。它的出发点很明确：既然手里已经有“哪个回答更好”的偏好数据，那么没有必要再额外训练一个奖励模型，再把奖励模型嵌入完整强化学习闭环；可以直接把偏好关系作用到策略本身。</p>
<p>DPO 仍然保留一个冻结的参考模型 <span displaypfx="inline-" class="mathjax-container">\(\pi_{\mathrm{ref}}\)</span> 作为锚点，并同时比较当前可训练模型与参考模型在“被接受回答”和“被拒绝回答”上的相对概率。这里的概率不是单个 token 的局部值，而是整条回答在 token 级对数概率上的汇总，因此<span style="background-color: #c0c0c0;">本质上是在比较“当前模型是否比参考模型更偏向好答案、同时更远离差答案”</span>。典型目标写成：</p>
<span displaypfx="" class="mathjax-container">\[\mathcal{L}_{\mathrm{DPO}}(\phi)=-\log \sigma\!\Big(\beta\big[\log\pi_\phi(y_w|x)-\log\pi_\phi(y_l|x)-\log\pi_{\mathrm{ref}}(y_w|x)+\log\pi_{\mathrm{ref}}(y_l|x)\big]\Big)\]</span>
<p>直觉上，DPO 直接增大“胜者相对败者”的对数概率优势（log-odds margin），同时用参考模型作为锚点。它把 RLHF 中“奖励建模 + PPO 优化”的两步折叠成一步有监督式偏好优化，因此工程更轻、训练更稳定，也更容易复用现有 SFT 训练栈。正因为这一点，DPO 已经成为许多中小团队进行偏好对齐时的默认起点。</p>
<p>DPO 训练并不是要把“好样本相对差样本的概率优势”硬拉到某个固定阈值才算结束。它优化的是整个偏好数据集上的相对排序损失，而不是某个预设的绝对 margin。对容易区分的样本，胜者优势会很快变大，梯度也随之减弱；对天然模糊、偏好边界不清的样本，这个优势不可能无限扩大。若继续强行训练，模型更可能开始过拟合偏好数据、放大表面写法差异，甚至损害生成分布稳定性。因此，DPO 的停止标准本质上仍然是<span style="background-color: #c0c0c0;">验证集偏好指标、生成质量与分布稳定性是否已经饱和</span>，而不是“margin 必须大于某个固定常数”。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2026/03/dpo.png"><img class="alignnone size-full wp-image-42031" src="https://blog.gmem.cc/wp-content/uploads/2026/03/dpo.png" alt="dpo" width="1024" height="1024" /></a></p>
<div class="blog_h3"><span class="graybg">GRPO</span></div>
<p>GRPO（Group Relative Policy Optimization）是一类“组内相对比较”的偏好优化方法：对同一个提示词 <span displaypfx="inline-" class="mathjax-container">\(x\)</span> 采样一组候选回复 <span displaypfx="inline-" class="mathjax-container">\(\{y_k\}_{k=1}^{K}\)</span>，用奖励/偏好信号在组内排序或打分，再把“相对好坏”转成策略梯度更新。它的核心动机是：用 <span style="background-color: #c0c0c0;">组内比较</span> 构造优势（Advantage）或基线（Baseline），从而减少对显式价值网络（Critic / Value Function）的依赖。</p>
<p>一种常见的做法是把组内奖励做标准化得到相对优势：</p>
<span displaypfx="" class="mathjax-container">\[A_k=\frac{r_k-\mu_r}{\sigma_r+\epsilon},\quad \mu_r=\frac{1}{K}\sum_{k=1}^{K}r_k,\ \sigma_r^2=\frac{1}{K}\sum_{k=1}^{K}(r_k-\mu_r)^2\]</span>
<p>并用 PPO 风格的 clipped objective 更新策略（仍然可带 KL 正则到参考模型）：</p>
<span displaypfx="" class="mathjax-container">\[\max_{\phi}\ \mathbb{E}\Big[\min\big(\rho_k A_k,\ \mathrm{clip}(\rho_k,1-\epsilon,1+\epsilon)\,A_k\big)\Big]-\beta D_{\mathrm{KL}}\!\left(\pi_\phi\,\|\,\pi_{\mathrm{ref}}\right),\quad \rho_k=\frac{\pi_\phi(y_k|x)}{\pi_{\phi_{\mathrm{old}}}(y_k|x)}\]</span>
<p>真正决定“奖惩”的不是某个回答的绝对分数，而是它在同组候选中的相对位置。若某个回答的组内奖励 <span displaypfx="inline-" class="mathjax-container">\(r_k\)</span> 高于这一组的平均水平 <span displaypfx="inline-" class="mathjax-container">\(\mu_r\)</span>，则 <span displaypfx="inline-" class="mathjax-container">\(A_k&gt;0\)</span>，训练会提高模型再次生成这类回答的概率；若某个回答低于组内平均水平，则 <span displaypfx="inline-" class="mathjax-container">\(A_k&lt;0\)</span>，训练会压低模型对这类回答的偏好。换句话说，GRPO 奖励的是“在同一个 prompt 下，比同组其他回答更好的样本”，惩罚的是“在同一个 prompt 下，相对更差的样本”，而不是单独给每个回答设一个全局固定门槛。</p>
<p>从优化角度看，这种“压制”并不是额外加一个独立惩罚按钮，而是让该回答在目标函数里贡献<span style="background-color: #c0c0c0;">负优势（Negative Advantage）</span>更新。结果上，它等价于让这类回答对应的策略概率逐步下降：模型以后再采样到相似回答时，会更倾向于远离它，而不是继续强化它。</p>
<p>这种机制尤其适合答案质量强依赖上下文、很难用一个全局绝对分数刻画的场景。对某些 prompt，70 分的回答可能已经是组内最优，应当被正向强化；对另一些 prompt，80 分的回答仍可能只是组内倒数，应当被压制。GRPO 的核心就在于：目标模型并不是因为“达到某个统一分数线”而得到奖励，而是因为它在<span style="background-color: #c0c0c0;">同题候选集合里相对更优</span>而受到正向更新。</p>
<p>GRPO 仍然需要某种奖励/偏好信号（显式奖励模型、规则打分、对比标注等）；它改变的是“如何用这些信号构造稳定的更新”，而不是免除奖励来源本身。把几条路线放在一起看，会更清楚：RLHF + PPO 强调显式奖励建模与稳定策略更新，DPO 强调跳过奖励模型后的直接偏好优化，GRPO 则强调用组内相对比较构造更稳定的优势信号。它们共享同一个目标，只是在“偏好信号如何表达、如何被模型消化”这件事上采取了不同工程路径。</p>
<div class="blog_h2"><span class="graybg">参数外优化</span></div>
<p>并不是所有效果提升都要通过训练权重来完成。在很多任务里，模型本身的基础能力已经足够，真正的瓶颈反而出在任务描述、输出约束、示例组织方式和评估闭环上。此时，更合理的第一步往往不是立刻进入 SFT、LoRA 或 DPO，而是先做参数外优化（Parameter-free Optimization）：不改模型参数，只改模型使用方式。</p>
<div class="blog_h3"><span class="graybg">自动化 Prompt 优化（APO）</span></div>
<p>自动化 Prompt 优化（Automatic Prompt Optimization, APO）的核心思想是：把人工反复修改 Prompt 的经验循环，改写成一个自动化搜索与评估过程。它不更新模型权重，而是把 Prompt 本身当作待优化对象，再用验证集上的错误信号驱动 Prompt 迭代。换句话说，APO 优化的不是模型内部参数，而是模型与任务之间的接口描述。</p>
<p>这条路线之所以重要，是因为很多任务的误差并不来自“模型完全不会”，而来自任务定义不够精确、输出约束不够严格、规则优先级表达不清，或者 few-shot 示例没有覆盖真正的边界情况。对这些问题，先做 Prompt 优化通常比直接微调更便宜，也更容易定位问题来源。</p>
<div class="blog_h4"><span class="graybg">基本流程</span></div>
<p>APO 可以概括为一个闭环迭代过程。</p>
<ol>
<li>从当前 Prompt 出发，在固定验证集上运行模型，得到一轮可量化的表现。</li>
<li>收集错误样本，重点关注 false positive、false negative 以及模型在边界样本上的失误模式。</li>
<li>把这些错误样本与当前 Prompt 一起交给一个更强的语言模型，要求它分析当前 Prompt 的缺陷，例如规则表述含糊、优先级冲突、示例覆盖不足、输出格式不稳定，或对某些语义模式缺乏约束。</li>
<li>基于这些分析生成若干候选 Prompt。候选改动可以包括：重写系统提示、重排规则顺序、增加约束语句、补充反例、加入 few-shot 示例，或强化输出格式说明。</li>
<li>用同一个验证集重新评估这些候选 Prompt，并按预先定义的指标选出当前最优版本。</li>
<li>重复这一过程，直到验证集指标收敛，或进一步修改已经不能带来稳定收益。</li>
</ol>
<p>这个闭环的本质是“用验证集驱动 Prompt 搜索”。人工调 Prompt 时，工程师通常也是先看错例，再猜原因，再改写提示词，再重新评估；APO 只是把这条经验流程交给模型辅助完成，并把迭代过程系统化。</p>
<div class="blog_h4"><span class="graybg">APO 依赖什么</span></div>
<p>要让 APO 真正有效，至少要具备三样东西：</p>
<ol>
<li>高质量验证集：它不一定需要极大规模，但必须足够准确，且覆盖关键边界情况。</li>
<li>清晰的评价指标：分类任务通常可以直接使用 Accuracy、Precision、Recall、F1；抽取、排序、生成任务则需要对应的可重复评价标准。</li>
<li>被优化对象必须足够明确：例如系统提示词、用户提示模板、few-shot 示例集、输出格式约束或工具调用说明。</li>
</ol>
<p>如果缺少这三者中的任意一个，APO 都很容易退化成“让模型随意重写 Prompt”，最终只是在做风格漂移，而不是真正基于验证信号优化任务表现。因此，APO 的核心不是“会不会改 Prompt”，而是“能否把 Prompt 修改纳入可验证的实验闭环”。</p>
<div class="blog_h4"><span class="graybg">验证信号质量</span></div>
<p>参数外优化对验证信号质量极其敏感。因为它并不通过海量训练样本去平均噪声，而是直接把验证集上的错误模式反向写入 Prompt 结构，所以一旦验证集标签本身含糊、冲突或混入大量低置信样本，优化器就很容易朝错误方向改写规则。高质量、边界清晰的验证集通常能显著提升 APO 的稳定性；相反，模糊数据会让优化过程更像是在追逐评测噪声，而不是纠正模型真实缺陷。</p>
<div class="blog_h4"><span class="graybg">与微调的区别</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">维度</td>
<td style="text-align: center;">微调</td>
<td style="text-align: center;">APO</td>
</tr>
</thead>
<tbody>
<tr>
<td>优化对象</td>
<td>模型权重</td>
<td>Prompt、示例、输出约束</td>
</tr>
<tr>
<td>资源需求</td>
<td>通常需要训练框架、显存和数据管线</td>
<td>通常只需要验证集与可调用的模型</td>
</tr>
<tr>
<td>成本</td>
<td>高</td>
<td>低</td>
</tr>
<tr>
<td>可解释性</td>
<td>较弱，行为被写入权重</td>
<td>强，Prompt 变更可直接审查</td>
</tr>
<tr>
<td>性能上限</td>
<td>更高，可把规则和模式内化进模型</td>
<td>受限于基座模型自身能力</td>
</tr>
<tr>
<td>更适合</td>
<td>数据充足、任务长期稳定、性能要求高</td>
<td>快速迭代、规则频繁变化、数据量有限</td>
</tr>
</tbody>
</table>
<p>因此，APO 和微调并不是互相替代的关系，而更像两层不同成本的优化手段。若模型本身已经具备足够能力，但任务接口还没有调顺，先做 APO 往往收益最高；若 Prompt 已经被压到比较成熟，但模型仍然持续犯系统性错误，才更适合进入微调阶段。</p>
<div class="blog_h4"><span class="graybg">适用边界</span></div>
<p>APO 最适合规则可文本化、评估指标清晰、且基座模型本身已经具备足够语义理解能力的任务。例如分类、抽取、轻量结构化生成、审核规则执行、问答格式控制和工具调用提示约束，往往都能从 APO 中获益。尤其当任务规则经常变化、人工需要频繁更新 Prompt 时，APO 的价值会非常明显。</p>
<p>它的边界同样清晰。若任务需要模型学习新的领域知识、记住稳定事实、吸收大量风格样本，或长期存在系统性能力缺口，那么单纯修改 Prompt 的收益通常很快见顶。此时，Prompt 优化可以继续作为上层控制手段存在，但性能提升的主战场已经会转移到微调与继续预训练。</p>
<div class="blog_h3"><span class="graybg">多分支 Prompt 优化（AMPO）</span></div>
<p>AMPO（Automatic Multi-Branched Prompt Optimization）可以看作 APO 的结构化升级版。普通 APO 往往默认“只有一条主 Prompt 流程”，优化方式主要是改写文字、调整规则顺序或补充示例；AMPO 则进一步把 Prompt 看作一种可演化的决策结构。它的目标不只是把单条 Prompt 写得更好，而是让 Prompt 从单流程逐步生长为多分支结构，使模型在面对不同输入模式时，能够沿着更合适的子路径完成判断。</p>
<p>这种思路来自一个很强的人类专家直觉：复杂任务往往并不是靠一条统一规则解决，而是靠“先识别情形，再走对应流程”。因此，AMPO 的真正创新不只是自动改 Prompt，而是把 Prompt 优化从“文案修订”提升成“结构搜索”。在这种框架下，提示词已经不只是几句话，而更像一个树状决策蓝图。</p>
<div class="blog_h4"><span class="graybg">为什么需要多分支</span></div>
<p>当任务内部同时包含多种错误模式时，单分支 Prompt 很容易不断堆叠例外说明，最后变成冗长、脆弱且难以维护的规则串。AMPO 的判断是：如果不同错误模式对应的是不同处理逻辑，就不应强行把它们揉进同一段线性指令，而应允许 Prompt 显式分叉。这样做有三个直接收益。第一，结构更清晰：每个分支各自负责一类模式，规则可解释性更强。第二，复杂场景适应性更好：模型不必在一条过长指令中硬找适用规则，而是先定位情形，再走对应分支。第三，后续维护更容易：新增模式时可以局部增补或重构特定分支，而不必重写整个 Prompt。</p>
<div class="blog_h4"><span class="graybg">三大模块</span></div>
<p>AMPO 的核心流程可以分成三个协同模块：模式识别（Pattern Recognition）、分支调整（Branch Adjustment）和分支剪枝（Branch Pruning）。这三个模块合在一起，构成了一个从错误样本出发、逐步生长并控制复杂度的优化回路。</p>
<p><span style="background-color: #c0c0c0;">模式识别</span>负责把零散坏例归纳成少数根因模式。它通常不是直接把所有错误样本原样塞进一个大 Prompt，而是采用角色分工：一个分析器（Analyzer）逐个解释失败原因，另一个总结器（Summarizer）把这些解释压缩成更高层的模式，并给模式分配重要性。这样做的关键价值在于：优化目标从“修这几个具体坏例”转成“修这一类错误背后的共同规则缺口”。</p>
<p><span style="background-color: #c0c0c0;">分支调整</span>负责决定 Prompt 结构该如何变化。这里最重要的决策不是“改不改”，而是“深化已有分支，还是新增分支”。若新模式与某个已有分支高度相关，只需补充约束或细节，那么更合理的做法是深化；若新模式与现有逻辑明显不同，继续堆进原分支只会制造冲突，就应当拓宽，新增一个独立子分支。也正因为如此，AMPO 优化的不是词句表面，而是 Prompt 的控制流结构。</p>
<p><span style="background-color: #c0c0c0;">分支剪枝</span>负责抑制过拟合。多分支优化的自然风险是：随着迭代次数增加，Prompt 可能长出大量只服务于少数训练样本的局部规则，最终在未知数据上退化。AMPO 因此显式引入两层剪枝：</p>
<ol>
<li>预剪枝（Pre-pruning）：相当于基于独立验证集的早停机制。若新增分支不再带来稳定收益，就停止继续扩张。</li>
<li>后剪枝（Post-pruning）：要求优化器在输出最终 Prompt 前重新审视各分支，删掉不必要、过于具体或明显带有训练集记忆痕迹的规则。</li>
</ol>
<div class="blog_h4"><span class="graybg">坏例抽样</span></div>
<p>AMPO 的一个很有代表性的设计，是每轮并不分析大量失败样本，而是只抽取很少量的代表性坏例，例如固定 <span displaypfx="inline-" class="mathjax-container">\(K=5\)</span>。这不是随意取值，而是一种典型的小样本归纳策略：在模式识别任务里，最前面的少数高信息量样本往往已经足以揭示一类错误的共性，而继续增加样本数量，边际信息收益会快速下降。对具备较强归纳能力的 LLM 来说，少量围绕同一主题的坏例通常已经足够提炼出根本模式。</p>
<p>更重要的是，AMPO 并不要求 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 随总坏例数量成比例扩大。固定的小 <span displaypfx="inline-" class="mathjax-container">\(K\)</span> 让每轮优化的成本更可控，也避免分析器被大量重复、低信息量坏例淹没。参数外优化的关键不是“看尽可能多的错误”，而是“看足够有代表性的错误”。</p>
<div class="blog_h4"><span class="graybg">是否需要失败输出</span></div>
<p>AMPO 还提供了一个很重要的方法论提醒：分析错误原因时，并不总是必须把模型当轮生成的失败输出显式提供给分析器。很多时候，输入、当前 Prompt、正确标签以及“这条样本被判错了”这一事实，已经足以让分析器定位规则缺失。因为优化器真正需要回答的问题不是“模型具体说错了什么句子”，而是“当前指令为什么会把模型引向这类错误”。当失败输出本身噪声较大、措辞随机性较强时，强行把它加入分析，反而可能干扰模式归纳。</p>
<p>换言之，参数外优化的关注重点应当放在<span style="background-color: #c0c0c0;">规则缺口</span>而不是<span style="background-color: #c0c0c0;">错误表面</span>上。一个高质量分析器更像在做“指令失效诊断”，而不是对错误回答做逐字复盘。</p>
<div class="blog_h4"><span class="graybg">优化器与执行器解耦</span></div>
<p>AMPO 还强化了一个现实而重要的工程模式：用于分析与重写 Prompt 的优化器模型，并不必与实际执行任务的目标模型相同。前者更强调总结、重写和结构设计能力，后者则更关注线上推理成本、延迟、稳定性和部署约束。把“谁负责优化 Prompt”和“谁负责执行 Prompt”解耦，往往能在成本与效果之间得到更好的组合。</p>
<div class="blog_h4"><span class="graybg">实验启示</span></div>
<p>从论文记录呈现的实验关注点看，AMPO 主要验证了三件事。第一，优化效率：多分支结构在复杂任务上往往能更快探索到高质量 Prompt，而不是在单分支上做无穷尽的局部修补。第二，收敛性：随着迭代推进，Prompt 结构会逐步稳定下来，说明这种优化并非纯随机搜索。第三，消融结果：模式识别、分支调整和分支剪枝都不是装饰性组件，拿掉任一模块，性能与稳定性都会受到影响。与普通 APO 相比，AMPO 的价值并不只是“Prompt 更长”，而是 Prompt 结构更像一个经过错误模式驱动后生长出来的决策树。</p>
<p>因此，参数外优化不应只被理解成“自动改几个词”。从 APO 到 AMPO，它实际上形成了一条清晰的演进路径：先把 Prompt 当作可搜索的文本接口，再把 Prompt 当作可演化的结构接口。前者已经足以覆盖大量规则型任务，后者则更适合复杂、异质、边界模式较多的任务。</p>
<div class="blog_h2"><span class="graybg">基座模型选择</span></div>
<p>在进入具体训练场景之前，首先要做的通常不是“选哪种微调方法”，而是判断任务应该建立在什么类型的基座模型之上。这个选择会直接决定后续数据形态、训练目标、推理链路与上线成本。很多项目的真正分水岭并不在 LoRA、QLoRA、DPO 这些技术细节，而在于一开始是否选对了模型范式：到底应当使用 BERT 一类表示模型（Representation Model），还是使用 Decoder-only 生成模型（Generative Model）。</p>
<div class="blog_h3"><span class="graybg">表示模型适合什么</span></div>
<p>BERT、RoBERTa、DeBERTa 一类 Encoder-only 模型，最擅长的是把输入压缩成稳定表示，再围绕固定目标做判别。它们特别适合闭集分类（Closed-set Classification）、文本匹配、检索、序列标注、重排序，以及“输入充分、标签空间明确、输出形式固定”的任务。只要目标可以被表述为“给这段输入打一个标签”或“判断这两段文本是否匹配”，表示模型通常都是更高效、更便宜、也更容易评估的选择。</p>
<p>这里需要明确一点：BERT 并不是完全没有顺序信息。它同样通过位置编码（Positional Encoding）与自注意力看到序列先后关系，因此能区分“先发生什么、后发生什么”。真正的限制不在“看不到顺序”，而在于它通常把整段输入压缩为一个判别表示，再直接映射到标签空间。对于需要显式执行规则、动态权衡多段证据、并把局部冲突统一到最终结论上的任务，这种一次性判别路径往往不够灵活，也缺乏可解释的中间推理结构。</p>
<div class="blog_h3"><span class="graybg">生成模型适合什么</span></div>
<p>生成式大语言模型的优势出现在另一类任务中：任务目标本身包含开放式语义判断、复杂规则执行、跨轮次状态整合，或者需要先形成中间结论，再决定最终输出。这类任务往往不只是“看完文本后打标签”，而是要求模型先判断哪些证据重要、哪些证据只是过程噪声，再决定最终答案。对这类问题，生成模型更容易通过指令约束、上下文推理和多步语义整合完成任务，因此更适合作为基座。</p>
<p>例如，在对话满意度判定中，若规则是“过程中的波折、局部负面情绪视为过程成本，只要最终方案被接受、问题收尾清晰，就优先按已解决处理”，任务本质上就不再是简单情感分类。模型需要区分中间波折与最终状态，识别“收尾是否清晰”，并对冲突信号做优先级排序。这里最困难的部分不是识别负面词，而是执行一条带有<span style="background-color: #c0c0c0;">结尾优先、过程降权</span>结构的规则。生成模型在这种场景下通常更稳，因为它更容易在长上下文中整合多段证据，并按指令执行“先看结尾，再回看过程”的判断逻辑。</p>
<div class="blog_h3"><span class="graybg">BERT 类模型的边界</span></div>
<p>只要任务满足以下条件，BERT 类模型通常仍然足够胜任：</p>
<ul>
<li>输出空间固定且较小，例如满意 / 不满意、风险 / 非风险、升级 / 不升级。</li>
<li>决定标签的证据主要是局部可见、模式稳定的文本特征，而不是依赖复杂跨轮推理。</li>
<li>规则可以被充分体现在标注数据里，使模型通过监督学习稳定内化这种判别边界。</li>
</ul>
<p>典型例子包括情感分类、意图分类、FAQ 匹配、实体识别、工单主题归类，以及大量结构清晰的客服路由任务。</p>
<p>若任务开始依赖以下能力，BERT 类模型的风险就会显著上升：</p>
<ul>
<li>需要对长对话做结尾优先的全局判断。</li>
<li>需要把中间负面情绪降权，但又不能完全忽略。</li>
<li>需要区分“问题解决了但过程不愉快”与“问题根本没解决”。</li>
<li>需要持续引入新规则，并要求模型在推理时可控地遵循这些规则。</li>
</ul>
<p>此时，即使通过多段编码、层级聚合、结尾加权或级联分类等工程技巧勉强构造出一个系统，上限通常也受限于任务本身的推理复杂度，而且系统维护成本往往会迅速上升。</p>
<div class="blog_h3"><span class="graybg">选择准则</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">任务特征</td>
<td style="text-align: center;">更合适的基座</td>
<td style="text-align: center;">原因</td>
</tr>
</thead>
<tbody>
<tr>
<td>固定标签分类、匹配、检索、序列标注</td>
<td>BERT 类表示模型</td>
<td>判别目标清晰，推理链短，部署成本低，训练与评估都更直接</td>
</tr>
<tr>
<td>需要开放式回答、格式生成、工具调用、复杂指令遵循</td>
<td>生成式模型</td>
<td>输出本身就是生成任务，Encoder-only 模型不适合作为主干</td>
</tr>
<tr>
<td>多轮对话总结、结尾优先判断、冲突证据加权、规则动态注入</td>
<td>生成式模型</td>
<td>需要跨轮整合与规则执行，往往不能稳定压缩成一次性判别</td>
</tr>
<tr>
<td>局部模式强、业务规则稳定、标注数据充分的分类任务</td>
<td>BERT 类表示模型</td>
<td>表示模型更省资源，延迟更低，也更适合大规模批量预测</td>
</tr>
</tbody>
</table>
<p>因此，基座模型选择的真正问题不是“哪个模型更先进”，而是“任务本质上是在做判别，还是在做规则驱动的语义决策”。前者优先考虑 BERT 一类表示模型，后者通常应直接进入生成式模型范式。把这一步判断做对，后面的训练场景、微调路径和评估方式才会自然收敛。</p>
<div class="blog_h2"><span class="graybg">训练场景</span></div>
<div class="blog_h3"><span class="graybg">小数据集微调</span></div>
<p>小数据集微调（Small-data Fine-tuning）讨论的不是“有没有数据”，而是“可用于更新模型权重的有效监督信号是否足够”。到了 2026 年，这个问题已经出现一个很值得重视的经验转向：在小数据场景里，微调容量并不是越大越好。很多情况下，越轻量的适配反而越稳，尤其当数据本身同时带有长尾、噪声、分布偏移和验证波动时更是如此。</p>
<div class="blog_h4"><span class="graybg">核心判断</span></div>
<p>小数据微调最容易误判的一点，是把“任务复杂”直接等同于“应该开放更多可训练参数”。在大多数小样本任务里，真正需要学习的往往不是重写整套表示空间，而只是沿少数方向对基座模型做任务相关偏移。更大的可训练子空间确实提高了拟合训练集的能力，但也更容易把头部模式、局部模板、错标样本和偶然噪声一起写进参数更新，最终损害泛化。</p>
<p>因此，小样本适配的默认逻辑通常不是“先开大再收缩”，而是<span style="background-color: #c0c0c0;">先用更强约束保护基座模型，再按验证集证据逐步放大更新空间</span>。以 LoRA 为例，低 rank 更新 <span displaypfx="inline-" class="mathjax-container">\(\Delta W = AB\)</span> 的价值不仅是省显存，更是把参数更新限制在一个低维子空间中，使模型更难直接记忆训练集表面模式。这也是为什么在低资源任务里，参数效率与泛化能力往往不是对立关系，而是同一套约束机制的两个结果。</p>
<div class="blog_h4"><span class="graybg">微调什么地方</span></div>
<p>小数据集微调不仅要决定“调多大”，还要决定“调哪里”。这个问题通常有两个维度：第一，更新哪些层；第二，更新每层里的哪些矩阵。它们共同决定模型最终学到的是局部表面模式，还是更稳定的高层语义偏移。</p>
<p>从层位分工看，Transformer 各层的注意力并不是完全同质的。浅层通常更偏局部句法、词性与相邻词关系，通用性最强，因此往往不应轻易扰动；中层开始形成更复杂的语义组合，处理跨句指代、因果联系和较长范围的信息整合；深层则更接近任务特定的高层语义，情感、意图、分类边界和最终决策信号通常更多集中在这里。对于情感判断、满意度判断、意图识别等高层语义任务，优先从中深层，尤其是后部层开始微调，通常更符合信号分布。</p>
<p>从矩阵类型看，小数据行为调整任务通常应先从注意力侧开始，而不是默认同时打开 FFN。Q 决定“向哪里发起关注”，V 决定“实际取出什么信息”，因此它们往往最直接影响模型如何组织证据与整合线索；K 和 O 也会影响结果，但通常不是最低成本起点。若任务主要是在已有知识之上重排注意力优先级、加强长程依赖或改变决策依据，先调 Q、V 往往最稳。只有当验证集持续显示模型确实缺少领域知识写入或表示重编码能力时，再逐步把 FFN、K 或 O 纳入更新范围更合适。</p>
<div class="blog_h4"><span class="graybg">主要风险来源</span></div>
<p>小数据的核心矛盾并不是绝对样本量，而是<span style="background-color: #c0c0c0;">有效监督信号稀疏，模型容量却仍然巨大</span>。如果数据高度重复、标签边界模糊、头部模式占据绝大多数样本，或者训练集与真实线上分布存在偏移，那么模型面对的就不是“少量但可靠的规律”，而是“少量规律 + 大量重复与噪声”。这时，训练往往不是训练不动，而是下降得过快、记忆得过深。</p>
<p>其中最容易被低估的是长尾问题。头部样本会主导梯度方向，新增容量也最容易先被头部模式吸收；结果是总体指标继续上涨，尾部类别、边界样本和罕见情形却未必同步改善，甚至可能恶化。平均 Accuracy 往往会掩盖这种退化，因此小数据微调若不单独观察尾部表现，很容易把“更会处理常见样本”误判成“整体更强”。</p>
<p>实践中，以下信号通常说明当前瓶颈不在容量不足，而在数据质量、长尾覆盖、验证设计或规则表达：</p>
<ul>
<li>训练损失快速下降而验证集停滞。这通常说明模型正在高效记忆训练集，却没有学到可迁移的判别规律，额外容量只会让这种记忆更彻底。</li>
<li>总体指标改善但尾部类别恶化。这说明新增容量主要被头部模式吸收，模型对高频样本更熟练，却以牺牲罕见场景为代价换取平均分提升。</li>
<li>不同随机种子之间波动很大。这意味着当前结果对初始化、数据划分或训练噪声过于敏感，说明监督信号本身不稳定。这里的不稳定，通常就来自数据分布不均、标签质量不足或验证集设计过小，例如头部样本占绝大多数、尾部样本只出现几次，或同类边界样本在不同标注员之间标准并不一致。继续放大可训练空间通常只会把这种不确定性进一步放大。</li>
<li>新增参数带来的提升只集中在头部模式。这表明模型并没有真正学到更普适的决策边界，而是在更深地贴合样本最密集的局部区域。</li>
<li>模型开始出现明显的风格漂移与任务外退化。这往往意味着局部小数据已经开始覆盖预训练先验，模型虽然在当前任务里更贴近训练分布，却损伤了原本更广泛的泛化能力。</li>
</ul>
<div class="blog_h4"><span class="graybg">2026 年的实践建议</span></div>
<p>小数据场景下，更稳妥的默认策略通常不是一开始就把可训练空间全部打开，而是按证据逐步放开：</p>
<ol>
<li>把轻量适配作为默认起点。若使用 LoRA / QLoRA，应优先把它理解为控制更新容量的手段，而不只是省显存工具。</li>
<li>先尝试只调后半层的 Q、V，或更保守地只调最后三分之一层。它的参数量最小，过拟合风险也最低，适合监督极少、验证集波动较大的情况。</li>
<li>若验证集显示模型对长程结构或中层语义组合仍然适配不足，再比较全层 Q、V 的极低 rank 配置。这样做的出发点是：任务信号不一定只存在于最深层，中层也可能承担一部分长程整合与语义组合；给所有层一点点可调空间，有时比只改最后几层更容易保持泛化。</li>
<li>只有当这些方案仍然显示出明确的知识注入或表示重编码瓶颈，再把 K、O 或 FFN 逐步放开。</li>
<li>把验证集当作主导信号，而不是训练损失。小数据场景下，训练集拟合速度通常极快，真正有意义的是验证集是否持续改善，以及尾部样本是否同步获益。</li>
<li>把参数选择和数据问题一起看。若长尾、噪声和分布偏移没有处理好，继续增加微调容量往往只会更快过拟合这些问题。</li>
</ol>
<p>这条顺序的核心不是追求一次命中最优结构，而是把可训练空间按证据逐步展开，避免在一开始就把过大的自由度交给少量数据。更成熟的理解方式，是把小数据集微调视为<span style="background-color: #c0c0c0;">受强约束的增量适配</span>，而不是缩小版的大规模微调。</p>
<div class="blog_h3"><span class="graybg">训练嵌入模型</span></div>
<div class="blog_h4"><span class="graybg">简介</span></div>
<p>嵌入模型（Embedding Model）并不天然等于“通用语义相似度模型”。它可以围绕特定目标进行训练，使向量空间优先保留某一类任务真正关心的判别信号。例如在情感分类（Sentiment Classification）场景里，模型更关心“正面 / 负面 / 中性”的倾向是否一致，而不一定关心两段文本在主题或措辞上是否高度相似。于是，训练得到的嵌入空间可能会把“物流很快，体验很好”和“包装一般，但整体满意”拉得较近，因为它们在情感方向上同属正向；反过来，即使两条评论都在谈“物流”，只要情感倾向相反，也可能被推向更远位置。</p>
<p>从更抽象的角度看，无论目标是情感、相关性、意图、风险、偏好还是图文匹配，嵌入模型始终都在学习一件事：让<span style="background-color: #c0c0c0;">与当前任务定义下“相关”的文档特征在向量空间中更接近，让“不相关”或“应被区分”的特征更远离</span>。区别只在于“相关”的定义来自哪里。通用 embedding 把相关性主要定义为语义相似；任务特化 embedding 则把相关性改写为某个业务目标下的等价关系，例如同一标签、同一情感、同一用户意图、同一风险等级，或“查询与正确答案匹配”。</p>
<p>正因为如此，嵌入训练与对比学习（Contrastive Learning）天然契合。只要能够构造正样本对与负样本对，就可以把任务目标转写成“哪些样本应靠近、哪些样本应分开”的几何约束。监督标签、点击行为、人工偏好、检索点击日志、FAQ 配对、复述句、图文配对，最终都可以落回这一范式：通过对比式目标把任务真正关心的结构写进表示空间。这样得到的 embedding 既可以直接用于最近邻检索、聚类和召回，也可以作为下游分类器或 reranker 的输入表示。Sentence-BERT 就是文本领域最常见的一条对比式嵌入技术路线之一，前文已经展开其结构，这里只把它当作训练范式的代表。</p>
<p>这里尤其要强调<span style="background-color: #c0c0c0;">替代选项（Alternative Option）</span>的重要性。对嵌入训练而言，替代选项本质上就是负样本：模型不只要知道“什么应该靠近什么”，还要知道“它为什么不是另一个看起来也很像的东西”。如果没有负样本，模型最容易学到的是一组宽泛、正确但区分度不足的共有特征；只有把相似但不同的替代选项放进训练过程，表示空间的边界才会真正被压实。</p>
<p>例如，如果想教模型理解“马”这一概念，只告诉它“有嘴巴、有鼻子、四条腿、长尾巴”，这些特征当然不算错，但它们并不能有效区分马和斑马，因为斑马同样满足这些描述。真正有区分度的，反而是“有没有条纹”、更接近哪种奔跑方式、整体体态和纹理模式这类能把两者分开的特征。把“马”和“斑马”作为相互竞争的替代选项放进对比训练后，模型才会被迫降低那些共有特征的权重，转而提升真正决定分类边界的特征权重。这也是为什么高质量负样本往往比继续堆更多正样本更能提升 embedding 的判别力。</p>
<div class="blog_h4"><span class="graybg">训练方式</span></div>
<p>如果把特定目标的 embedding 训练落到一个可执行流程，通常可以分成六步。</p>
<p>但无论流程写得多完整，训练或微调 embedding 模型的主要难点始终都不在 Trainer 本身，而在数据。可用数据不仅要足够大，质量门槛也很高；正例对通常相对容易收集，例如复述句、点击匹配、NLI 蕴含对、FAQ 问答对，但真正困难的是构造高质量的难负例，因为它们既要足够接近真实混淆项，又不能把本该相关的样本误标为负例。</p>
<ol>
<li>构造对比样本。最常见的起点是自然语言推断（Natural Language Inference, NLI）类句对数据，因为它天然提供“哪些句子应该更近、哪些句子应该更远”的监督信号。以蕴含（Entailment）关系作为正例、以矛盾（Contradiction）关系作为负例，是非常常见的做法；中立（Neutral）样本则可按任务目标决定是作为弱负例还是直接舍弃。工程实践里，经常直接使用 SNLI、MNLI 或二者合并后的 AllNLI。GLUE（General Language Understanding Evaluation）则更适合作为上层参照系：它汇总了九个语言理解任务，可用于分析模型在句对理解、推断和相似度相关任务上的整体表现，但并不是把九个任务原样全部转成对比样本。</li>
<li>定义评估器。训练过程不能只看训练损失，还需要一套稳定的验证指标。最常见的选择是 STS-B（Semantic Textual Similarity Benchmark）：它由人工标注句子对相似度，原始标签通常位于 1 到 5 的区间，适合评估句向量是否学到了连续的语义距离。若需要更全面的外部评估，则可以进一步使用 MTEB（Massive Text Embedding Benchmark）一类综合基准，它覆盖多类嵌入任务与大量数据集，能更系统地检查模型在检索、聚类、分类和语义匹配等场景中的迁移能力。</li>
<li>选择基座模型。特定目标的嵌入训练通常从一个现成的 Encoder-only Transformer 开始，例如 microsoft/mpnet-base、BERT、RoBERTa 或其领域变体。若任务更接近通用句向量，也可以直接从已经具备较强句向量能力的基座继续微调；若任务明显偏领域化，则优先选择语料分布更接近业务场景的编码器。</li>
<li>调用 sentence-transformers 进行训练。它提供了从数据集封装、池化、损失函数到 Trainer 的完整流水线。默认情况下，模型参数并不会被自动冻结，整个编码器都会参与更新；虽然也可以手动冻结底层若干层以节省显存或降低训练不稳定性，但对 embedding 任务而言，表示空间往往需要全层共同调整，因此在资源允许时，全量解冻通常比只训练顶部少数层更容易得到更好的句向量。</li>
<li>设置超参数。最关键的超参数通常是训练轮次（Epochs）、批次大小（Batch Size）和学习率预热（Learning Rate Warmup）。批次大小会直接影响 in-batch negatives 的数量，因此不仅关系到吞吐，也关系到对比学习的难度；训练轮次决定模型能否真正把目标关系写入向量空间；预热则用于降低训练初期的梯度震荡，避免刚开始就把预训练表示空间破坏掉。</li>
<li>选择损失函数。若目标是得到高质量 embedding，一般不建议把 SoftmaxLoss 当作默认选项，因为它更偏向“把当前任务做成分类”，而不是直接优化向量空间的几何结构。若手里有连续相似度分数，常用余弦相似度相关目标，例如 CosineSimilarityLoss；若任务是检索、召回或通用句向量训练，MultipleNegativesRankingLoss 往往是默认优先尝试的方案之一，因为它会把同一 batch 中其他样本自然当作负例，直接优化“正确匹配更近、错误匹配更远”的排序关系。不过它并不是所有任务上的统一最优解，最终仍取决于数据格式、负样本质量和任务目标。</li>
</ol>
<p>这六步背后的主线始终一致：先定义“相关”和“不相关”，再把这种关系写进向量空间。任务标签、蕴含关系、点击行为和人工偏好只是构造这种几何关系的不同来源；一旦正负样本定义清楚，训练目标就会自然收敛到对比学习的框架里。</p>
<p>在这条主线里，<span style="background-color: #c0c0c0;">难负例（Hard Negatives）</span> 往往能够显著提升嵌入模型的判别力。随机负例通常太容易，模型很快就能把它们推远；真正能继续塑造决策边界的，往往是那些“表面上很像、但在任务定义下并不相关”的样本。对检索、匹配和语义区分任务而言，负例越接近真实混淆项，模型越容易学到更有区分度的表示。</p>
<p>一个常见的负例收集流程如下：</p>
<ol>
<li>获取简单负例（Easy Negatives）。最直接的方法是从训练集里随机采样文档或句子，和当前 query/anchor 拼成负样本对。这一步成本最低，适合快速建立基础对比信号，但训练到中后期往往会变得过于容易。</li>
<li>获取半难负例（Semi-hard Negatives）。可以先用一个预训练 embedding 模型遍历训练集，为每个样本召回一批“看起来较相似”的候选，再排除真实正例、重复样本和语义等价样本，把剩余候选作为半难负例。这类负例已经靠近当前表示空间的边界，通常比随机负例更能提升检索质量。</li>
<li>获取难负例（Hard Negatives）。更强的做法是人工构造，或借助数据合成（Data Synthesis）生成高混淆样本。例如为同一个 query 人工编写“主题相关但答案错误”的文档，或利用大模型生成与正例高度相似但标签相反、结论错误、实体错配的文本。这样的负例最有训练价值，但也最容易混入假负例（False Negatives），因此质量控制必须更严格。</li>
</ol>
<p>难负例并不是越难越好。若负例实际上与正例同样合理，或者只是标注遗漏导致的“假负例”，模型就会被迫把本应接近的样本推远，反而损害 embedding 空间的结构。真正有效的 hard negative，是对模型足够难、但在任务定义下又明确应该分开的样本。</p>
<div class="blog_h3"><span class="graybg">特定目标嵌入微调</span></div>
<p>从头训练嵌入模型（Embedding Model）通常不是多数团队的首选路径。原因并不神秘：它既需要大规模高质量句对或 query-document 数据，也需要持续的负例挖掘、评估基准建设和较长训练周期；若多语言还要兼顾长文本、跨语言检索和任务泛化，成本会进一步抬升。对绝大多数工程团队而言，更高效的做法是从一个已经具备稳定表示空间的基座继续微调（Fine-tuning），把现有 embedding 几何结构朝业务目标方向“推一小步”，而不是从零发明一整个向量空间。</p>
<p>这也是为什么 embedding 项目的真正瓶颈往往不在“有没有训练框架”或“能不能跑通微调”，而在于能否拿到足够多、足够干净、又足够贴近业务分布的数据。正例对开发相对直接，难的是负例设计，尤其是高价值 hard negatives：它们决定模型能否学会区分那些最容易混淆、最接近真实线上错误的样本。</p>
<p>Sentence-BERT（SBERT）路线的实用价值就在这里体现得很明显：它并不要求必须从头构建一个新的 embedding 模型，而是允许直接以现有 SentenceTransformer 模型或预训练编码器为基础继续训练。这样做的收益有两个。第一，预训练阶段已经学到大量通用语言结构，微调只需要重塑与当前任务最相关的距离关系。第二，训练资源会集中花在“任务适配”而不是“重新学习基本语言知识”上，因此更适合企业内部检索、垂直领域分类、多语言 RAG 和跨语言匹配这类目标明确的场景。</p>
<p>实际选基座时，不应只看单一榜单名次，而应同时看四个因素：语言覆盖、上下文长度、是否指令化（Instruction-aware）、以及微调成本。榜单可以帮助筛掉明显过时的模型，但真正决定工程效果的，往往是“它是否与你的数据形态和训练预算匹配”。截至 2026 年 3 月，下面几类开源基座尤其值得优先考虑，尤其是在多语言场景中。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">基座</td>
<td style="text-align: center;">多语言能力</td>
<td style="text-align: center;">核心特点</td>
<td style="text-align: center;">更适合的微调目标</td>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-Embedding-8B / 4B / 0.6B</td>
<td>100+ 语言</td>
<td>当前公开多语言榜单前列的强基座；支持指令感知、最长 32K 上下文、可自定义输出维度</td>
<td>多语言检索、跨语言召回、长文档检索、代码与自然语言混合语料</td>
</tr>
<tr>
<td>BGE-M3</td>
<td>100+ 语言</td>
<td>同时支持 dense / sparse / multi-vector；最长 8192 token；对混合检索和 RAG 结构非常友好</td>
<td>多语言 RAG、混合检索、长文档场景、需要兼容 BM25 风格稀疏信号的系统</td>
</tr>
<tr>
<td>multilingual-e5-large-instruct</td>
<td>94 语言</td>
<td>指令式 embedding 路线成熟；query 端显式带任务描述；体量相对可控</td>
<td>任务定义清晰的检索、问答召回、跨语言语义匹配</td>
</tr>
<tr>
<td>jina-embeddings-v3</td>
<td>多语言；重点调优 30 种语言</td>
<td>8192 token 长上下文；内置任务 LoRA 适配器；支持 Matryoshka 截断维度</td>
<td>一套基座服务多任务、需要分类 / 检索 / text-matching 共用底座的系统</td>
</tr>
</tbody>
</table>
<p>若强调“当前最强的开源多语言底座”，Qwen3-Embedding 系列是首先应试的对象；若强调“检索形态复杂、需要 dense + sparse + rerank 协同”，BGE-M3 的工程灵活性仍然非常突出；若更在意成熟的指令式 query-document 训练范式，multilingual-E5-large-instruct 依然是很稳的起点；若希望在同一基座上兼顾多任务并降低任务切换成本，jina-embeddings-v3 的任务适配设计更有吸引力。</p>
<p>因此，特定目标的嵌入微调并不是“随便挑一个 embedding 模型然后继续训”。更合理的顺序是：先根据任务选择合适的基座拓扑，再设计正负样本与评估器，最后用微调把表示空间朝业务目标压缩。对今天的大多数团队来说，真正的竞争力很少来自“从零训练一个全新 embedding 模型”，而更多来自“是否用合适的底座，把微调目标、负例设计和评测体系做对”。</p>
<div class="blog_h3"><span class="graybg">基于少量数据的嵌入微调</span></div>
<p>在标注数据非常有限的场景里，增强型 SBERT（Augmented SBERT）提供了一条经典而务实的路径：用少量高质量标注，扩展出一套足够大的嵌入训练集。它利用的是双编码器（Bi-Encoder）与交叉编码器（Cross-Encoder）的互补性：双编码器推理快、适合检索，但通常需要较多训练数据；交叉编码器推理慢，却能在句对打分上提供更高精度。因此，可以先让交叉编码器学会目标任务，再利用它为大量未标注句对生成伪标签，最后反过来训练一个可高效部署的 SBERT。</p>
<p>这个方法的关键不在于引入全新的模型结构，而在于重新组织数据生产流程。少量人工标注但可靠的数据，构成黄金数据集（Gold Dataset）；由交叉编码器离线打标生成的大规模伪标签数据，构成白银数据集（Silver Dataset）。黄金数据集负责提供可信监督，白银数据集负责放大覆盖面。两者组合后，就能把“标注稀缺”的问题转化成“高精度慢模型辅助生成训练信号”的问题。</p>
<p>因此，增强型 SBERT 本质上是一种<span style="background-color: #c0c0c0;">低数据场景下的数据增强（Data Augmentation）与知识蒸馏（Knowledge Distillation）</span>策略：先用少量黄金数据把交叉编码器调准，再让交叉编码器把自己的句对判断能力迁移给双编码器。最终得到的不是一个更慢的模型，而是一个依然可以做大规模向量检索、但在目标任务上明显更强的嵌入模型。</p>
<div class="blog_h4"><span class="graybg">核心流程</span></div>
<p>增强型 SBERT 的整体流程可以概括为四步。</p>
<ol>
<li>先用少量黄金数据集微调交叉编码器。这里的黄金数据通常规模不大，但标签质量高，足以让交叉编码器学会“在当前任务里什么样的句对应该更相似，什么样的句对应该更远”。</li>
<li>再生成一批新的候选句子对。这一步既可以来自额外的未标注语料，也可以从现有语料中重新组合样本，目的是构造一个远大于黄金数据集的候选池。</li>
<li>然后让已经微调好的交叉编码器为这些候选句子对打分，生成白银数据集。这里的标签不是人工真值，而是高精度模型给出的伪标签，因此质量通常高于简单启发式规则，却又远比全人工标注便宜。</li>
<li>最后用“黄金数据集 + 白银数据集”一起训练双编码器。这样训练出来的 SBERT 保留了双编码器可预编码、可缓存、可做向量检索的速度优势，同时通过白银数据学到了更多与目标任务一致的距离关系。</li>
</ol>
<div class="blog_h4"><span class="graybg">白银数据集如何构造</span></div>
<p>白银数据集的质量，决定了增强型 SBERT 能否真正成立。若手里本来就有大量未标注句对或 query-document 数据，最直接的做法就是把它们交给交叉编码器离线标注，再转成伪标签训练集。这是最标准也最稳定的路径，因为候选样本来自真实语料分布，白银数据更接近真实任务。</p>
<p>如果没有现成的大规模未标注句对，也可以从现有黄金数据出发构造更多候选样本。例如把不同句子的前半部分与后半部分重新组合，或把不同 query 与 candidate 文档重新配对，生成新的候选对。但纯随机组合通常会制造过多明显不相似的负例，导致数据分布过于偏斜，模型学到的主要是“轻松区分非常不像的样本”，而不是处理真正困难的边界。</p>
<p>因此，更有效的做法通常是先用一个预训练 embedding 模型做粗检索：为每个句子或 query 召回若干看起来较相似的候选，再把这些候选送给交叉编码器做精标。这样生成的白银数据会包含更多“高混淆但仍可判别”的样本，训练价值明显高于随机拼接。换句话说，预训练 embedding 在这里不负责给出最终标签，而是负责提高候选样本的质量；真正的伪标签仍然由交叉编码器给出。</p>
<div class="blog_h4"><span class="graybg">为什么适合少量数据微调</span></div>
<p>增强型 SBERT 的价值，在低资源场景下尤其明显。少量黄金数据本身往往不足以直接把 SBERT 微调到理想状态，因为双编码器更依赖足够多的成对训练信号去塑造向量空间；但这同一小批黄金数据，往往已经足够把交叉编码器调成一个“能较准打分”的教师模型。之后，只要有额外候选样本，教师模型就能持续扩充白银数据集，从而把训练信号放大很多倍。</p>
<p>这也是它与普通监督微调的根本差别：普通微调直接把少量标注样本喂给双编码器；增强型 SBERT 则多加了一层“教师打标”环节，用交叉编码器把少量高质量监督扩展成大量可用监督。因此，它特别适合文本匹配、语义检索、问答匹配、句子对排序等 pairwise sentence scoring 任务。</p>
<div class="blog_h4"><span class="graybg">边界与适用条件</span></div>
<p>增强型 SBERT 并不是“数据越少越万能”。它仍然要求黄金数据足够准确，否则交叉编码器会先学歪，再把错误批量复制到白银数据里。它也要求候选样本池与真实任务分布足够接近，否则伪标签再多，也只是把错误分布放大。更关键的是，白银数据集并不能替代最终评估：真正决定模型是否可用的，仍应是人工标注的黄金验证集与测试集。</p>
<p>因此，这条路线最适合的场景不是“完全没有数据”，而是“有少量高质量标注、但不足以直接训练出强双编码器”。在这种情况下，增强型 SBERT 提供了一条非常自然的过渡路径：先用高精度慢模型吸收黄金数据，再把这种判断能力蒸馏成一个推理高效的嵌入模型。</p>
<div class="blog_h3"><span class="graybg">无监督嵌入模型训练</span></div>
<div class="blog_h4"><span class="graybg">过渡背景</span></div>
<p>增强型 SBERT 已经把监督数据需求压得很低，但它仍然依赖少量黄金数据去微调交叉编码器。再往前推进一步，现实里还存在更苛刻的场景：没有人工标注句对，甚至没有可靠的点击日志、排序日志或问答配对数据。此时，嵌入模型训练只能转向无监督学习（Unsupervised Learning）或更准确地说，自监督学习（Self-supervision）：训练信号不再来自显式标签，而来自原始语料自身的结构、扰动视图（Augmented Views）或重建目标。</p>
<p>因此，无监督嵌入模型训练处理的是“零标注”条件下的表示学习问题。它并不是放弃监督，而是把监督信号改写为模型可以从原始文本中自动构造出的约束：哪些表示应该在扰动前后保持一致，哪些句子应被视为同一语义对象的不同视图，哪些带噪输入必须恢复到原始句子。这类方法尤其适合冷启动和领域适配，因为垂直领域最容易获得的往往不是标签，而是大量原始文本。</p>
<div class="blog_h4"><span class="graybg">主流路线</span></div>
<p>无监督嵌入训练的核心目标仍然是学习一个有判别力的句向量空间，只是实现方式不同。当前常见路线可以概括为四类。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">方法</td>
<td style="text-align: center;">全称</td>
<td style="text-align: center;">核心原理</td>
<td style="text-align: center;">特点</td>
</tr>
</thead>
<tbody>
<tr>
<td>SimCSE</td>
<td>Simple Contrastive Learning of Sentence Embeddings</td>
<td>把同一句子在不同 dropout 下得到的表示视为正例，用 batch 内其他句子作负例做对比学习</td>
<td>结构极简，是无监督句嵌入的经典标杆路线</td>
</tr>
<tr>
<td>CT</td>
<td>Contrastive Tension</td>
<td>通过对比张力机制重新调整预训练表示，使语义相近样本更聚合、无关样本更分离</td>
<td>强调对预训练表示的语义重调</td>
</tr>
<tr>
<td>TSDAE</td>
<td>Transformer-based Sequential Denoising Auto-Encoder</td>
<td>先破坏原句，再要求模型从带噪输入重建原句，逼迫编码器学习句级语义压缩</td>
<td>在无监督训练和领域适配中都非常强，是这一节的重点</td>
</tr>
<tr>
<td>GPL</td>
<td>Generative Pseudo-Labeling</td>
<td>从无标签语料出发自动生成 query 与伪标签，再训练 dense retriever</td>
<td>更接近弱监督，但非常适合无人工标注的检索场景</td>
</tr>
</tbody>
</table>
<p>这些方法的共同点是：都在尝试从原始文本中自动制造训练信号；差别在于信号是来自对比、重建，还是生成式伪标签。无监督训练真正的难点依然是数据，只不过难点从“标注是否充足”转移成“自监督信号是否有效”。如果正样本过于容易、负样本过于随机，模型学到的往往只是表面相似性；若扰动方式破坏了核心语义，模型又会被迫学习错误的不变性。</p>
<div class="blog_h4"><span class="graybg">TSDAE</span></div>
<p>TSDAE（Transformer-based Sequential Denoising Auto-Encoder）是无监督句嵌入中非常重要的一条路线，尤其适合领域适配（Domain Adaptation）。它的核心思想可以概括为“破坏 - 重建”：先对原始句子加噪，例如随机删除部分词、打乱局部结构或施加其他轻度破坏；再让模型从这个带噪版本重建原始句子。模型若想完成这项任务，就不能只记住局部 token，而必须把句子的整体语义压缩到编码表示中。</p>
<p>TSDAE 与掩码语言建模（Masked Language Modeling, MLM）的差别也很关键。MLM 主要学习“根据上下文补出被遮住的词”，重点仍然是词级预测；TSDAE 学习的是“从受损输入恢复整个句子”，目标天然更偏句级语义表示。因此，TSDAE 训练出来的编码器更容易直接拿来生成句嵌入，而不必再从词级预测目标里间接提炼句向量。</p>
<p>这条路线在垂直领域里尤其有效。原因是领域适配最常见的现实条件正是：有大量目标域原始文本，但缺乏成对标注数据。TSDAE 可以先在这些无标签文本上训练一个更贴近目标领域分布的编码器，把通用嵌入空间向领域语义挪动；随后若再获得少量黄金数据，就可以继续用常规 SBERT 或增强型 SBERT 做最后一步任务对齐。</p>
<div class="blog_h4"><span class="graybg">领域适配闭环</span></div>
<p>从工程角度看，最有价值的并不是把“监督训练”“少样本微调”“无监督训练”割裂开，而是把它们看成一条连续路线。标注充足时，直接做监督训练；标注很少时，用增强型 SBERT 放大黄金数据；标注几乎为零但有大量原始语料时，先做 TSDAE 或类似无监督适配，再在后续叠加少量监督微调。这样就形成了一个完整闭环：覆盖有监督、低监督到零监督三种数据条件。</p>
<p>因此，无监督嵌入训练并不天然优于监督微调。它的价值在于为 embedding 模型打地基，并在领域迁移时先把表示空间校正到目标语料分布附近。若后续能够获得少量高质量黄金数据，再叠加监督微调，通常会比单独依赖无监督训练更稳，也更接近真实业务目标。</p>
<div class="blog_h3"><span class="graybg">表示模型继续预训练</span></div>
<p>表示模型继续预训练（Continued Pretraining）处理的是这样一种典型情况：基座表示模型已经在通用开放语料上完成预训练，例如互联网文本、维基百科、新闻语料或大规模网页数据，因此具备稳定的通用语言能力；但它并不天然掌握特定领域知识，例如医学术语、金融表述、法律条文、企业内部缩写和业务语境。此时，问题往往不在于模型“不会语言”，而在于模型虽然懂通用语言，却还不够懂目标领域。</p>
<p>这正是继续预训练的切入点。传统 BERT 路线通常只有两阶段：先在通用语料上做预训练，再直接在下游分类或序列标注任务上微调。继续预训练在两者之间插入了一个新的中间层，形成<span style="background-color: #c0c0c0;">通用预训练 → 领域继续预训练 → 下游任务微调</span>的三阶段流程。它的目标不是立刻输出分类结果，而是先用目标领域的无标注文本，重新校正编码器的词汇分布、上下文统计与语义偏好，让表示空间先贴近领域，再去做具体任务。</p>
<div class="blog_h4"><span class="graybg">为什么需要继续预训练</span></div>
<p>通用预训练模型对“movie”“doctor”“interest”“appeal”这类词的理解，默认来自开放语料中的统计分布；一旦进入垂直场景，这些词的含义和搭配方式可能会明显变化。医学文本中的 drug、lesion、metastasis，金融文本中的 guidance、yield、hedging，法律文本中的 plaintiff、statute、liability，都承载着更窄、更稳定、更专业的语境。如果直接拿通用模型去做下游微调，模型往往会在专业术语、长尾表达和领域共现关系上吃亏。</p>
<p>继续预训练的价值就在于：它允许模型先用领域无标注数据补上这层知识，再进入任务微调阶段。因此它本质上是一种领域自适应预训练（Domain-Adaptive Pretraining, DAPT）策略。医疗领域的 BioBERT、金融领域的 FinBERT、法律领域的 LawBERT，本质上都属于这一路线的不同落地版本。</p>
<div class="blog_h4"><span class="graybg">核心训练任务</span></div>
<p>对 BERT 一类 Encoder-only 表示模型而言，继续预训练最经典的目标仍然是掩码语言建模（Masked Language Modeling, MLM）。它可以理解成一种受控的“完形填空”：随机选择输入序列中约 15% 的 token 作为预测目标，其中 80% 替换成 [MASK]，10% 替换成随机 token，剩余 10% 保持不变，但仍要求模型预测原始 token。这样做的目的，是迫使模型根据双向上下文恢复被遮蔽的信息，从而继续学习领域语料中的词汇、搭配和上下文统计。</p>
<p>在继续预训练场景中，MLM 的关键价值不在于“学会猜词”本身，而在于让词向量与上下文表示持续向领域分布靠拢。通用 BERT 看到 What a horrible [MASK]! 时，可能只学到通用情绪词与常见名词；若继续在影评语料上做 MLM，它会更容易把 horror、ending、premise、performance 这类领域表达织进表示空间。医学、金融、法律乃至企业内部文档都是同样的道理。</p>
<div class="blog_h4"><span class="graybg">训练流程</span></div>
<p>如果把表示模型继续预训练落到一个可执行的理论流程，通常可以分成六步。</p>
<ol>
<li>确定基础模型。起点通常是一个已经完成通用预训练的表示模型，例如 BERT、RoBERTa、DeBERTa 或其领域相近变体。这里不需要从头训练模型，而是直接继承已有语言能力。</li>
<li>收集目标领域的无标注语料。继续预训练最重要的资源不是标签，而是高质量领域文本。它既可以来自公开领域语料，例如 PubMed 医学文献、金融新闻、法律判例，也可以来自企业内部数据，例如业务文档、客服对话、知识库与工单记录。</li>
<li>完成分词与语料预处理。继续预训练阶段通常不再保留下游标签，只保留原始文本并转换成模型输入序列。此时最重要的是保证文本清洗、截断策略、特殊符号处理与分词器保持一致，因为模型更新的是对领域文本分布的内部表示，而不是标签映射。</li>
<li>选择掩码策略。最基础的是词元掩码（Token Masking），即对子词粒度随机掩码；若希望模型更充分学习完整术语和专业表达，也可以使用整词掩码（Whole Word Masking, WWM），让一个完整单词的所有子词同时被遮蔽。整词掩码通常训练更难、收敛更慢，但在领域术语密集的场景下更有价值。</li>
<li>执行继续预训练。用领域无标注语料继续运行 MLM 目标，让模型参数在不丢失通用语言能力的前提下，逐步适配目标领域。这个阶段更新的不是分类头，而是整个编码器本身，因此它更像“重塑表示空间”，而不是“学习某个具体任务标签”。</li>
<li>切换到下游微调。等继续预训练完成后，再把更新后的表示模型接到具体任务上，例如文本分类、语义搜索、命名实体识别（Named Entity Recognition, NER）或关系抽取。此时，下游微调面对的已经不是通用基座，而是一个更懂目标领域语境的表示模型。</li>
</ol>
<div class="blog_h4"><span class="graybg">企业级场景</span></div>
<p>继续预训练在企业内部尤其有价值。很多企业并不缺文本，而是缺可公开复用的标注数据。客服对话、知识库、工单、合同、操作手册、会议纪要、内部 wiki，这些数据天然带有组织级语境和术语。如果直接把通用模型拿去做企业任务微调，模型经常会在缩写、术语和内部表述上显得迟钝；但若先用这些无标注内部数据继续预训练，再做客服主题分类、语义搜索或实体识别，下游效果通常会明显更稳。</p>
<p>因此，继续预训练最适合的不是“领域知识已经充分内化到通用模型”的场景，而是“目标领域有大量原始文本，但通用模型对其语境仍然陌生”的场景。它本身不是终点，而是一个连接通用语言能力与领域任务能力的中间适配层。</p>
<div class="blog_h3"><span class="graybg">分类目标表示模型微调</span></div>
<p>除了直接训练 embedding 模型，另一条非常常见的路线是围绕分类目标（Classification Objective）微调表示模型（Representation Model）。这类方法的基本结构是：以预训练编码器作为基座，在顶层接一个专用分类头（Classification Head），然后用分类损失共同优化表示与决策边界。它适用于情感分类、主题分类、风险识别、意图分类等闭集标签任务，目标不是学习一个通用距离空间，而是让表示尽可能服务于当前分类边界。</p>
<p>在这种设定下，基座模型参数既可以冻结（Freeze），也可以与分类头一起更新。冻结时，训练只发生在分类头，优点是显存占用小、训练稳定、对小数据集更保守；缺点是表示空间几乎不变，模型只能在既有语义表征上学习一个浅层决策边界。若不冻结，则分类头与基座参数会在训练中<span style="background-color: #c0c0c0;">协同进化</span>：分类头不断把梯度传回编码器，编码器又持续调整自己的表示方式去配合分类目标，最终得到更贴近任务边界的内部表征。这通常能带来更高上限，但也更依赖数据质量、学习率设置和正则化控制。</p>
<p>从经验上看，把基座模型全部冻结后，分类微调效果通常会明显受限，尤其当任务分布与预训练语料存在偏移时更是如此。资源受限时，部分解冻（Partial Unfreezing）是一种常见折中：只更新分类头往往太弱，而全量解冻又可能超出显存或训练预算。在某些具体实验里，只解冻少数几个 Transformer 模块就已经能得到足够好的结果；但这并不是通用规则，更不能机械理解成“总该解冻前 5 层”。对文本分类而言，更常见、更合理的做法通常是优先解冻靠后的高层模块，因为后层表示更接近任务语义与决策边界；前层更偏向词法与局部句法特征，保留冻结状态往往问题不大。若任务与预训练域差异很大，或输入风格明显特殊，再考虑进一步向下解冻更多层。换句话说，部分解冻的关键不在于固定解冻“哪 5 层”，而在于把有限资源优先用在最可能影响任务边界的高层表示上。</p>
<div class="blog_h4"><span class="graybg">训练流程</span></div>
<p>以烂番茄影评数据集（Rotten Tomatoes Movie Review Dataset）的情感分类为例，分类目标表示模型微调通常可以拆成六步。</p>
<ol>
<li>选择任务与数据集。烂番茄影评数据集是二分类情感任务：输入是一段影评文本，输出是正面或负面标签。它非常适合说明“表示模型 + 分类头”这一路线，因为情感边界往往依赖整体语义与局部措辞共同决定。</li>
<li>加载数据并完成划分。最基本的划分是训练集（Training Set）与测试集（Test Set）；若训练流程中还需要调超参数或做早停，则还应额外保留验证集（Validation Set）。这里的重点不是机械切分比例，而是保证测试集不参与模型选择，从而让最终分类指标具有解释价值。</li>
<li>加载基座模型与分词器（Tokenizer）。基座通常选择预训练 Transformer 编码器，例如 BERT、RoBERTa、DeBERTa 或更轻量的蒸馏版本；分词器负责把原始文本转成 token 序列、attention mask 以及模型可接收的输入张量。模型与 tokenizer 必须配套，因为词表、特殊 token 和预训练时的文本规范共同决定了输入表示。</li>
<li>进行分词与样本编码。文本在进入模型前需要完成截断（Truncation）、编码与必要的长度控制。这个阶段的目标不是简单把字符串变成整数，而是把“原始语言序列”转换成“可被编码器稳定处理的张量化输入”。序列最大长度、是否保留句首句尾、以及是否对长文本做裁剪策略，都会直接影响分类效果。</li>
<li>构建专用数据整理器（Data Collator）。它负责把长度不一的样本动态组织成批次（Batch），例如按当前 batch 的最长序列做填充（Padding），并同步构建 attention mask，保证同一批次内张量形状一致。更进一步，数据整理器也可以承载轻量数据增强策略，例如随机裁剪、句段保留或噪声注入；但它的最基本职责仍然是稳定、高效地完成批次构造，而不是单纯“把数据拼起来”。</li>
<li>定义评估指标函数。分类目标的训练不仅需要损失函数，还需要一组与业务目标对齐的评估指标。最基本的是 Accuracy；若类别不平衡或更关心误报 / 漏报，则通常还要同时看 Precision、Recall 与 F1。对情感分类这类任务，宏平均 F1（Macro-F1）往往比单独 Accuracy 更能反映模型是否真正学到了稳定的标签边界。</li>
</ol>
<p>这种训练路线与前文 embedding 微调的根本区别在于优化目标。embedding 微调强调“让相关样本在向量空间中更近，让无关样本更远”；分类目标微调强调“让表示空间直接服务于当前标签决策”。前者更适合检索、聚类、匹配与召回，后者更适合闭集判别任务。实际工程中，两条路线并不冲突：很多系统会先训练或微调一个较强的 embedding / encoder 基座，再在其上叠加分类头完成特定标签任务。</p>
<div class="blog_h3"><span class="graybg">少量样本分类目标微调</span></div>
<p>少量样本分类目标微调（Few-shot Classification Fine-tuning）处理的是另一类很常见的现实约束：标签体系已经明确，但每个类别只有极少数标注样本，往往只有 8、16、32 或几十个例子。这类场景下，直接按常规监督流程全量微调一个分类模型，很容易过拟合到表面措辞或偶然噪声；真正有效的方法通常不是“把普通微调硬做小”，而是换用对低样本更友好的训练机制。</p>
<p>少样本分类本质上仍然属于监督学习，只是把“每类有大量标注样本”的常规设定，收缩成“每类只有极少量高质量样本”的稀缺设定。它特别适合标注成本高、样本获取慢、但标签体系明确的任务，例如小众领域文本分类、垂直领域情感分析、专业工单归类或内部知识标签识别。少样本方法的核心，不是让模型凭空学会分类，而是尽可能榨出每一条标注样本中的监督信号。</p>
<div class="blog_h4"><span class="graybg">SetFit</span></div>
<p>SetFit（Sentence Transformer Fine-tuning）是少样本文本分类里最实用的一条路线之一。它基于 sentence-transformers 生态构建，但并不直接把少量样本喂给一个普通分类器，而是先把这些样本改写成大量句子对训练信号，再用两阶段流程完成分类。它的核心价值在于：仅靠极少量标注样本，就能让嵌入模型学到任务相关的类别结构，随后再用一个很轻量的分类头完成判别。</p>
<p>SetFit 的完整流程可以概括为三步：</p>
<ol>
<li>采样训练数据。先基于少量原始标注样本构造句子对：同一类别下任意两个文本组成正例，不同类别下的文本组成负例。这样一来，即使每类只有 2 到 8 条样本，也能通过类内 / 类间组合迅速扩展出大量训练对。少样本的关键不再只是“原始样本有多少”，而变成“能否从这些样本中构造出足够有判别力的相似 / 不相似关系”。</li>
<li>微调嵌入模型。利用这些正负句子对，对预训练的 Sentence Transformer 做对比学习微调。正例要求模型把同类文本的句向量拉近，负例要求模型把异类文本推远。这个阶段优化的是表示模型本身，也就是 SetFit 的 body。</li>
<li>训练分类器。等嵌入模型被调到更适配当前任务后，再用这些高质量句向量作为特征，训练一个轻量分类头（head），例如逻辑回归、线性分类器或其他简单监督分类器。最终推理时，文本先被编码成嵌入，再由分类头输出类别概率。</li>
</ol>
<p>这套设计的本质是两阶段训练：第一阶段先让嵌入空间学会“同类靠近、异类远离”；第二阶段再在这个已经整理过的表示空间里学习分类边界。相比直接对大模型做全参数分类微调，这种路线对小样本更稳定，也更节省资源。</p>
<p>SetFit 的优势主要体现在四个方面。第一，少样本效率高：每类只需极少数标注样本，就可能逼近常规大数据分类微调的效果。第二，无需提示词：它不像提示式 few-shot 方法那样依赖 prompt 或 verbalizer 设计。第三，训练成本低：大部分计算都集中在句向量微调和轻量分类头训练上。第四，数据利用率高：通过句子对采样，有限标注被最大化放大。</p>
<p>以烂番茄影评（Rotten Tomatoes）这类二分类情感任务为例，SetFit 的典型做法是先按类别均衡抽取极少量样本，例如每类 16 条；之后通过正负配对把几十条原始样本扩展成上千个训练对，再完成对比学习与分类头训练。这个例子最能说明 SetFit 的关键巧思：真正被放大的不是“文本本身”，而是文本之间的监督关系。</p>
<p>SetFit 也有边界。它最适合短文本、句子级、闭集标签明确的分类任务；若类别语义高度重叠、任务严重依赖复杂推理，或标签本身更像开放式生成目标，那么单纯依赖句向量空间分离的办法未必最优。在这些场景里，提示式 few-shot 或更强的生成式模型可能更有优势。</p>
<div class="blog_h4"><span class="graybg">提示式 Few-shot 微调</span></div>
<p>另一大类主流方案是提示式（Prompt-based）few-shot 微调，其代表方法包括 PET（Pattern-Exploiting Training）和 LM-BFF（Better Few-shot Fine-tuning of Language Models）。它们不再直接把分类任务看成“输入文本 → 标签 id”，而是把任务改写成 cloze 风格或自然语言提示，让预训练语言模型去预测标签词（label words）或完成模式匹配。PET 的特点是利用 prompt 把少样本监督对齐到语言模型原本更擅长的预训练目标，并进一步用少量标注模型为未标注样本分配软标签；LM-BFF 则把 prompt 搜索、label word 选择和 demonstration 设计系统化，以提高 few-shot 稳定性。</p>
<p>这类方法在标签语义清晰、prompt 设计得当时非常强，但工程代价通常高于 SetFit。它们更依赖 prompt 模板、verbalizer 质量以及不同随机种子的稳定性，迁移到新任务时也往往需要额外搜索和调参。换句话说，提示式 few-shot 的上限很高，但工程摩擦也更大。</p>
<div class="blog_h4"><span class="graybg">参数高效微调</span></div>
<p>第三类主流路线是参数高效微调（Parameter-Efficient Fine-Tuning, PEFT），例如 LoRA、IA3、Prefix Tuning、Prompt Tuning 等。它们的共同点是：大部分预训练参数保持冻结，只训练一小部分新增参数或适配器参数，从而显著降低显存与存储成本。这类方法更直接解决“模型太大，如何降低微调成本”的问题，因此在大基座模型上尤其有价值。</p>
<p>PEFT 与 SetFit 的关注点并不相同。SetFit 解决的是“样本太少，如何更高效地榨出监督信号”；PEFT 更直接解决“模型太大，如何降低微调成本”。在少样本分类里，二者并不互斥：完全可以把少样本策略与参数高效策略叠加。例如，当基座模型较大、显存非常紧张时，可以优先采用 LoRA / IA3 这类方法；若样本极少且更看重训练稳定性与部署成本，则 SetFit 往往是更直接的起点。</p>
<div class="blog_h4"><span class="graybg">如何选型</span></div>
<p>如果任务是典型的短文本或句子级闭集分类，且每类只有极少样本，SetFit 往往是首选起点，因为它训练快、对样本效率高、工程上也最直接。若任务标签本身具有很强自然语言语义，且团队愿意投入 prompt 设计与搜索成本，PET / LM-BFF 这类提示式 few-shot 往往有更高上限。若主要矛盾不是样本太少，而是模型太大、显存和部署预算太紧，则应优先考虑 LoRA、IA3 或其他 PEFT 方案。实际系统中，最稳妥的做法通常不是先争论“哪条路线绝对最好”，而是先判断当前瓶颈究竟是样本、算力，还是 prompt 工程复杂度。</p>
<div class="blog_h3"><span class="graybg">生成模型高效微调</span></div>
<p>生成模型高效微调（Parameter-Efficient Fine-Tuning for Generative Models）面向的是 Decoder-only 大语言模型（Large Language Model, LLM）的指令对齐与领域适配场景。它处理的不是“如何训练一个分类器”或“如何训练一个检索向量空间”，而是如何在显存、训练时间和存储预算都有限的条件下，让基座生成模型学会遵循指令、稳定输出目标格式，并吸收特定领域的表达习惯。对绝大多数 7B、13B 乃至更大规模的开源生成模型而言，基于 QLoRA 的 PEFT 已经成为默认起点。</p>
<div class="blog_h4"><span class="graybg">适用场景</span></div>
<p>这一路线最适合三类任务。第一类是指令微调（Instruction Tuning），即让基座模型从“擅长续写”转向“稳定遵循用户指令”；第二类是风格或格式约束，例如客服回复风格、结构化 JSON 输出、企业内部答复模板；第三类是轻量领域适配，例如让模型更熟悉某个组织、行业或产品线的术语与常见问答模式。若主要瓶颈是显存不足，QLoRA 往往优于全量微调；若目标是深度改写模型底层知识、继续预训练大规模领域语料，或修改上下文长度、词表与位置编码等底层结构，则继续预训练或全参数微调仍然更合适。</p>
<div class="blog_h4"><span class="graybg">训练流程</span></div>
<p>从理论上看，基于 QLoRA 的生成模型高效微调通常遵循六步流程。</p>
<ol>
<li>确定基座模型与任务目标。基座一般选择已经具备稳定因果语言建模能力的生成模型，例如 Llama、Qwen、Mistral 或 TinyLlama 一类架构；任务目标则通常是监督微调（Supervised Fine-Tuning, SFT）意义上的指令跟随，而不是从头学习语言能力。</li>
<li>构造高质量指令数据。训练样本通常采用<span style="background-color: #c0c0c0;">instruction / input / output</span>三段式结构，或更一般的多轮对话消息结构。这里最关键的不是样本数量本身，而是格式与质量：用户角色、助手角色、轮次边界、结束标记、系统提示词都必须稳定一致。若基座模型已经绑定特定 chat template，就应沿用同一模板组织训练语料；否则模型学到的首先不是任务能力，而是混乱的对话边界。实际工程里，过滤后的 UltraChat 风格指令对话之所以常被拿来做示例，核心原因也正在于此：它提供了相对稳定、结构清晰的监督信号。</li>
<li>以低比特方式加载基座。QLoRA 的“Q”来自量化（Quantization）：基座权重以 4-bit 形式存储，常见配置包括 NF4、双重量化以及 BF16 / FP16 计算精度。这样做的目的不是改变模型结构，而是把静态权重占用压到足够低，使更大模型能够在有限显存中完成微调。NF4 尤其适合这一场景，因为它针对 Transformer 权重常见的零中心、近似正态分布做了量化设计。</li>
<li>挂接 LoRA 适配器。QLoRA 的训练对象不是量化后的基座权重，而是附加在目标投影层上的低秩适配器参数。实践中通常会把 LoRA 挂到注意力层的 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> / <span displaypfx="inline-" class="mathjax-container">\(k\)</span> / <span displaypfx="inline-" class="mathjax-container">\(v\)</span> / <span displaypfx="inline-" class="mathjax-container">\(o\)</span> 投影上，必要时再扩展到 MLP 的 up / down / gate 投影。这样得到的是“冻结量化基座 + 可训练低秩分支”的结构：原模型保留通用语言能力，增量参数专门负责吸收任务相关行为。</li>
<li>执行监督微调。训练过程本质上仍然是自回归下一个 token 预测，只是训练语料已经被改写为指令遵循格式。由于显存约束依然存在，单卡 batch size 往往较小，因此通常依靠梯度累积（Gradient Accumulation）来获得更合理的等效批大小；学习率调度常采用 warmup 后接 cosine 衰减；优化器常配合分页优化器以压低峰值显存；最大序列长度则决定模型一次看到多少上下文，也直接决定训练成本。</li>
<li>导出训练产物。训练完成后，最常见的保存形式有两种：一种是只保存 LoRA 适配器，部署时与同一基座模型组合使用，这最适合多任务、可插拔部署；另一种是把适配器权重合并回基座，得到单体模型，便于独立推理与分发。前者更省存储、更灵活，后者更接近传统“一个模型直接上线”的部署习惯。</li>
</ol>
<div class="blog_h4"><span class="graybg">关键超参数</span></div>
<p>QLoRA 的表现很大程度上由少数关键超参数决定。它们控制的不是同一件事，而是分别约束适配器容量、优化稳定性、上下文覆盖范围与显存预算。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">超参数</td>
<td style="text-align: center;">控制对象</td>
<td style="text-align: center;">实践含义</td>
</tr>
</thead>
<tbody>
<tr>
<td>LoRA rank <span displaypfx="inline-" class="mathjax-container">\(r\)</span></td>
<td>低秩适配器的容量</td>
<td><span displaypfx="inline-" class="mathjax-container">\(r\)</span> 越大，适配器表达力越强，但显存与训练成本也越高；过小容易欠拟合，过大则削弱 PEFT 的资源优势。</td>
</tr>
<tr>
<td>lora_alpha</td>
<td>LoRA 更新量的缩放强度</td>
<td>它决定适配器增量对原模型输出的影响幅度，通常与 <span displaypfx="inline-" class="mathjax-container">\(r\)</span> 配合设置；过大容易训练不稳，过小则适配不足。</td>
</tr>
<tr>
<td>target_modules</td>
<td>LoRA 挂接位置</td>
<td>只覆盖 <span displaypfx="inline-" class="mathjax-container">\(q\)</span> / <span displaypfx="inline-" class="mathjax-container">\(v\)</span> 投影时最省资源；同时覆盖注意力与 MLP 投影时，通常有更高上限，但训练更重。</td>
</tr>
<tr>
<td>lora_dropout</td>
<td>适配器分支正则化</td>
<td>小数据集或高重复训练语料更容易过拟合，适度 dropout 有助于稳定；数据充足时则通常保持较低取值。</td>
</tr>
<tr>
<td>学习率（Learning Rate）</td>
<td>参数更新步长</td>
<td>QLoRA 只训练少量适配器参数，因此学习率通常可以高于全参数微调；但过高仍会导致输出风格漂移、格式崩溃或损失震荡。</td>
</tr>
<tr>
<td>批大小与梯度累积</td>
<td>等效 batch 规模</td>
<td>单卡显存通常只允许很小的 per-device batch size，因此需要靠梯度累积换取更稳定的优化轨迹。真正重要的是等效 batch，而不是单步看到多少样本。</td>
</tr>
<tr>
<td>最大序列长度（Max Sequence Length）</td>
<td>单样本上下文覆盖范围</td>
<td>序列越长，训练成本增长越快；过短又会截断多轮对话、长指令或结构化输出。它是质量与成本之间最直接的杠杆之一。</td>
</tr>
<tr>
<td>计算精度与优化器</td>
<td>数值稳定性与显存占用</td>
<td>支持时优先使用 BF16；否则常退回 FP16。分页 AdamW 一类优化器更适合量化微调，因为它们能显著压低优化器状态带来的峰值显存。</td>
</tr>
<tr>
<td>学习率调度器（Scheduler）</td>
<td>训练初期稳定性与后期收敛</td>
<td>cosine 衰减配合短 warmup 是常见默认配置：前期避免梯度过猛，后期逐步收敛到更稳的解。</td>
</tr>
</tbody>
</table>
<div class="blog_h4"><span class="graybg">适用边界</span></div>
<p>QLoRA 的核心优势是把“生成模型微调”从高门槛算力工程，压缩成可在有限资源上反复迭代的日常流程。因此，只要任务主要是指令跟随、格式控制、轻量领域适配或特定风格注入，它通常都应作为第一选择。它的边界同样明确：当任务需要深度改写基座知识、吸收大规模新领域语料、重构模型底层能力或逼近全参数微调的极限上限时，QLoRA 更适合作为基线而不是终点。此时更合理的路线通常是继续预训练、Q-DoRA，或直接转向更重的全参数微调。</p>
<div class="blog_h3"><span class="graybg">生成模型拒绝采样微调</span></div>
<p>生成模型拒绝采样微调处理的是这样一类场景：模型已经具备基本生成能力，甚至已经完成一轮 SFT，但仍希望进一步提高答案正确率、格式稳定性或可验证任务表现；同时，团队又不希望立刻进入完整 RLHF、PPO 或 DPO 训练链路。此时，最自然的做法往往不是直接改写优化目标，而是先让模型对同一提示词生成多个候选，再通过外部评分器筛选出最优答案，把它重新写回 SFT 数据，再继续监督训练。</p>
<p>从训练形态上看，它通常遵循五步流程。</p>
<ol>
<li>确定起点模型。起点通常是 Base Model 经过一轮指令微调后的模型，因为拒绝采样依赖“先能生成基本可读候选”这一前提；若模型连任务格式都不稳定，后续筛选只会浪费大量采样预算。</li>
<li>为每个提示词生成多个候选。生成阶段的目标不是一次命中标准答案，而是提供一个足够有区分度的候选池，因此通常会适度提高采样多样性，让模型在同一问题上给出若干不同解法、表述或结构。</li>
<li>使用外部评分器做筛选。评分器可以是规则校验、可执行验证、单元测试、格式解析器、奖励模型、人类打分，或它们的组合。只要能较稳定地区分“明显更好”和“明显更差”，就足以支撑拒绝采样式数据构造。</li>
<li>把通过筛选的样本回写成监督数据。最常见的做法是只保留每个提示词下得分最高或达到阈值的候选，把它们整理成新的 prompt-response 数据集。这样做之后，后续训练仍然是标准 SFT，而不是显式偏好优化。</li>
<li>按监督目标继续训练，并周期性重新采样。随着模型能力提升，旧一轮采样得到的高分样本可能不再代表新的最优边界，因此很多实践会多轮迭代：采样、筛选、回写、再训练，再用更新后的模型继续采样。</li>
</ol>
<p>这条路线的关键超参数主要有五类：每个提示词生成多少候选、采样温度与 top-p 等多样性控制、接受阈值或保留比例、评分器本身的一致性与噪声水平，以及每轮回写数据占原始 SFT 数据的比例。它们共同决定一个核心权衡：候选越多、筛选越严格，样本平均质量可能越高，但成本也越高，且更容易把训练分布压窄；候选太少或筛选过松，则回写数据与原始 SFT 的差异不够明显，改进幅度往往有限。</p>
<p>与 DPO 相比，拒绝采样微调不会直接保留“胜者优于败者”这层相对关系，而是只把胜者留下来，因此信息利用率更低，但训练链路更简单；与 PPO 相比，它没有显式策略优化与在线回报建模，因此更容易稳定落地。工程上，它特别适合<span style="background-color: #c0c0c0;">答案是否正确较容易验证</span>的任务，而对开放式偏好、帮助性、安全性与语气细粒度排序，更常需要再叠加 DPO、RLHF 或其他偏好优化方法。</p>
<div class="blog_h3"><span class="graybg">生成模型直接偏好调优</span></div>
<p>生成模型直接偏好调优（Direct Preference Tuning for Generative Models）处理的是生成模型训练中的下一层目标：模型已经通过监督微调（SFT）学会了基本的指令跟随，但在多个可行回答之间，仍未必稳定偏向人类真正想要的输出。此时，训练重点不再是“把任务教给模型”，而是“把偏好写进模型”。在当前工程实践里，DPO（Direct Preference Optimization）是最典型、也最实用的直接偏好调优方案。</p>
<p>它之所以称为“直接”，就在于它绕开了“先训练奖励模型，再用 PPO 做强化学习”的显式 RLHF 流程，直接用偏好数据本身更新策略。对资源受限、希望复用现有 SFT 训练栈的团队而言，这通常是比传统 RLHF 更轻、更稳的选择。</p>
<div class="blog_h4"><span class="graybg">适用前提</span></div>
<p>直接偏好调优几乎总是建立在一个已经完成 SFT 的模型之上，而不是直接从 Base Model 开始。原因很直接：偏好数据表达的是“在两个都还算合理的回答之间，哪个更好”；如果模型连基本指令都还不会遵循，那么偏好优化得到的首先不是更好的对齐，而是更混乱的行为。因此，标准路径通常是<span style="background-color: #c0c0c0;">Base Model → SFT / QLoRA SFT → DPO</span>。若显存非常紧，DPO 阶段也仍然可以继续沿用 LoRA / QLoRA 这一类参数高效微调方案。</p>
<div class="blog_h4"><span class="graybg">训练流程</span></div>
<p>从工程与理论结合的角度看，生成模型直接偏好调优通常可以拆成六步。</p>
<ol>
<li>确定起点模型。直接偏好调优的起点通常不是原始基座，而是已经完成指令微调的模型。若前一阶段使用的是 LoRA / QLoRA，则这里有两种常见路径：要么先把 SFT 适配器合并回模型，再继续挂接新的 DPO 适配器；要么保留 SFT 结果作为起始状态，在其之上继续叠加偏好调优适配器。无论采用哪条路径，本质都一样：DPO 学习的是“在 SFT 行为之上继续排序优化”，而不是重新学习指令能力。</li>
<li>构造偏好数据集。训练样本的基本形态是三元组：提示词（prompt）、被接受回答（chosen）、被拒绝回答（rejected）。它要求两条回答都与同一个提示词对应，并且都具备一定可读性，否则优化信号会退化成“学会排除明显坏答案”，而不是学习细粒度偏好边界。高质量 DPO 数据的关键，不只是 chosen 比 rejected 更好，还在于两者足够接近、足够可混淆，这样模型才会被迫学习真正决定人类偏好的因素，例如信息完整性、语气、格式遵循、安全边界与事实可靠性。</li>
<li>建立参考模型。DPO 不显式训练奖励模型，但仍然需要一个冻结的参考模型（Reference Model）作为锚点。这个参考模型通常就是 SFT 后的模型快照，它定义了“原本模型认为哪些回答更可能”。训练模型的目标不是盲目提高 chosen 的概率，而是相对于参考模型，进一步提高 chosen 的相对优势，并压低 rejected 的相对优势，从而避免模型在优化偏好时过度漂移。</li>
<li>配置可训练参数。若采用 PEFT 路线，训练对象通常仍是 LoRA 适配器，而不是全参数更新。此时需要重新决定 DPO 阶段的 LoRA 容量与挂载范围。实践中，一个常见选择是让 DPO LoRA 覆盖注意力投影与 MLP 投影，因为偏好优化往往既涉及回答内容选择，也涉及风格、结构和安全边界的重排。若只覆盖极少数层，偏好容量可能不足；若覆盖过广，则训练成本与过拟合风险都会上升。</li>
<li>执行直接偏好优化。DPO 训练时会同时比较当前模型和参考模型在 chosen / rejected 两条回答上的条件概率。优化目标可以概括为：让当前模型比参考模型更偏向 chosen，同时更远离 rejected。与 PPO 不同，这一过程不需要 rollout、奖励建模或价值函数估计，因此训练流程更接近“带特殊损失函数的 SFT”，也更容易保持数值稳定。</li>
<li>保存与合并训练产物。若使用 LoRA / QLoRA，训练完成后最常见的产物仍然是 DPO 适配器，而不是完整模型。部署时可以只加载适配器与基座组合使用；若希望得到单体模型，也可以按顺序把 SFT 适配器与 DPO 适配器依次合并。这个顺序很重要，因为偏好调优是在指令能力之上继续修正输出排序，若跳过 SFT 阶段直接只保留 DPO 适配器，模型通常不会得到预期行为。</li>
</ol>
<div class="blog_h4"><span class="graybg">重要参数</span></div>
<p>直接偏好调优的关键超参数主要决定四件事：偏好优化强度、可训练容量、序列覆盖范围，以及在有限显存下能否稳定收敛。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">参数</td>
<td style="text-align: center;">控制对象</td>
<td style="text-align: center;">实践含义</td>
</tr>
</thead>
<tbody>
<tr>
<td>beta</td>
<td>DPO 偏好强度 / 正则化强度</td>
<td>它决定 chosen 相对 rejected 的概率优势要被放大到什么程度。取值过大，模型容易偏离参考模型过快；取值过小，偏好信号又会过弱。工程上常把它理解为“偏好更新的力度旋钮”。</td>
</tr>
<tr>
<td>学习率（Learning Rate）</td>
<td>更新步长</td>
<td>DPO 阶段通常比早期 SFT 更接近“行为微调”而非“能力学习”，因此学习率往往需要更保守。学习率过高时，最先损坏的往往不是困惑度，而是回复风格、格式一致性与安全边界。</td>
</tr>
<tr>
<td>批大小与梯度累积</td>
<td>等效 batch 规模</td>
<td>DPO 每个样本都包含 prompt、chosen、rejected 三部分，显存压力通常高于普通单输出 SFT，因此更依赖小 batch 配合梯度累积来换取稳定训练。</td>
</tr>
<tr>
<td>max_prompt_length</td>
<td>提示词截断长度</td>
<td>它控制参考上下文保留多少信息。过短会丢失任务条件，过长则显著抬高显存与计算成本。</td>
</tr>
<tr>
<td>max_length</td>
<td>整体序列长度上限</td>
<td>它决定 prompt 与回答总共能占多少 token。对多轮对话、长解释和结构化输出任务而言，这个参数直接影响偏好信号能否覆盖完整回答。</td>
</tr>
<tr>
<td>warmup_ratio</td>
<td>训练初期学习率预热比例</td>
<td>偏好训练通常数据量小、梯度信号陡峭，预热有助于避免一开始就把模型推离参考分布。</td>
</tr>
<tr>
<td>优化器与精度</td>
<td>显存与数值稳定性</td>
<td>若沿用 QLoRA 路线，分页 AdamW、混合精度与梯度检查点通常仍是默认组合，它们解决的是“偏好训练能否在消费级显存下跑稳”的问题，而不是损失函数本身的问题。</td>
</tr>
<tr>
<td>LoRA 的 <span displaypfx="inline-" class="mathjax-container">\(r\)</span>、<span displaypfx="inline-" class="mathjax-container">\(\alpha\)</span>、target_modules、dropout</td>
<td>可训练容量与挂载位置</td>
<td>这些参数控制 DPO 阶段到底允许模型改动多大子空间。偏好差异若主要体现在语气、格式和细粒度行为边界上，低秩适配器通常足够；若 chosen / rejected 差异深度依赖复杂知识、推理链条或长程一致性，则更高秩或更广覆盖范围往往更稳。</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">推理阶段优化</span></div>
<p>推理优化（Inference Optimization）目标是在质量约束下同时降低延迟（Latency）、显存（Memory）和成本（Cost）。主线方法包括量化（Quantization）、KV Cache 优化、推测解码（Speculative Decoding）和连续批处理（Continuous Batching）。</p>
<p>关于“温度（Temperature）设为 0 仍可能不完全一致”的现象：温度为 0 通常等价于贪心解码（Greedy Decoding），理论上对固定权重与固定数值计算应是确定的；但线上推理系统可能包含非确定性来源（Non-determinism），例如并行算子中的浮点归约顺序差异、不同硬件/内核实现、以及服务端的动态调度与缓存策略。若业务需要强确定性，除了设置 <span displaypfx="inline-" class="mathjax-container">\(\text{temperature}=0\)</span>，还需要禁用采样相关选项并启用确定性计算（Deterministic Kernels）/固定随机种子（Seed）（若推理框架支持）。</p>
<div class="blog_h2"><span class="graybg">量化</span></div>
<p>量化（Quantization）通过降低数值精度减少带宽与显存占用。常见路径：FP16/BF16 到 INT8/INT4/FP8。大模型推理最常见的是权重量化（Weight-only Quantization），因为它实现简单、收益稳定；进一步的激活量化（Activation Quantization）能带来更大的加速空间，但对硬件与校准（Calibration）更敏感。</p>
<p>常见概念：</p>
<ul>
<li>PTQ（Post-Training Quantization）：训练后量化，依赖少量校准数据估计尺度（Scale）与零点（Zero-point）。</li>
<li>QAT（Quantization-Aware Training）：训练时模拟量化误差，通常精度更好但成本更高。</li>
<li>按粒度：per-tensor / per-channel / group-wise，粒度越细通常越准但开销更高。</li>
</ul>
<ul>
<li>INT8：8-bit 整数量化，精度与兼容性通常最稳，常作为权重量化的保守起点。</li>
<li>INT4：4-bit 整数量化，压缩率更高，但更依赖量化算法、分组方式与校准质量。</li>
<li>FP8：8-bit 浮点格式，动态范围通常优于 INT8，更适合高端硬件上的高吞吐推理与训练。</li>
<li>GGML：最早的本地 CPU/GPU 推理张量库与算子生态，强调轻量、本地部署与量化推理。</li>
<li>GGUF：建立在 GGML 生态上的统一模型文件格式，用于封装权重、词表、量化元数据和模型配置，已成为 llama.cpp 一类本地推理工具的主流分发格式。</li>
</ul>
<div class="blog_h2"><span class="graybg">KV Cache 缓存</span></div>
<p>KV 缓存（Key-Value Cache, KV Cache）用于避免重复计算历史 token 的 Key/Value。在解码器（Decoder-only）模型中，生成第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 个 token 时需要与前 <span displaypfx="inline-" class="mathjax-container">\(t-1\)</span> 个位置做注意力；若不缓存，每一步都会重复算一遍历史的 K/V，代价极高。</p>
<p>代价分解常用“预填充（Prefill）+ 解码（Decode）”来理解：Prefill 处理提示词（Prompt）并写入 KV；Decode 每生成一个 token 只需计算新 token 的 Q/K/V，并与缓存做一次注意力。这把每步成本从“重算整段历史”降为“读取缓存并做一次加权求和”。</p>
<p>KV Cache 的主要代价是显存：缓存规模与层数、头数、上下文长度线性增长，因此优化会围绕缓存布局（如 Paged Attention）、压缩（如 KV 量化）与复用策略展开。</p>
<p>粗略的量级估算：若每层缓存张量形状近似为 <span displaypfx="inline-" class="mathjax-container">\(K,V\in\mathbb{R}^{L\times n_{\text{kv}}\times d_k}\)</span>，则单 batch 的显存规模约为</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{Mem}_{\mathrm{KV}}\approx 2\cdot N_{\text{layers}}\cdot B\cdot L\cdot n_{\text{kv}}\cdot d_k\cdot \text{bytes}\]</span>
<p>其中 <span displaypfx="inline-" class="mathjax-container">\(n_{\text{kv}}\)</span> 在 GQA/MQA 中显著小于 Query 头数，因此 <span style="background-color: #c0c0c0;">GQA/MQA 往往是降低 KV Cache 的一阶有效手段</span>；进一步的手段包括 KV 量化、Paged KV（按块管理与回收）、以及对重复前缀做复用/持久化（Prompt Caching）。</p>
<div class="blog_h3"><span class="graybg">Paged Attention</span></div>
<p>Paged Attention 明确属于推理阶段优化。它解决的不是“如何把 KV Cache 压得更小”，而是“如何把不断增长的 KV Cache 更高效地组织、分配和访问”。核心做法是把逻辑上连续的一段 KV 序列拆成固定大小的块（Blocks / Pages），再用块表把请求看到的连续上下文，映射到物理上未必连续的显存块。</p>
<p>这种设计的收益主要有三点。第一，减少显存碎片（Fragmentation）：请求长度不断变化时，不再需要为每条序列预留一整段连续大内存。第二，提升动态批处理与并发调度效率：不同请求可以共享统一的块分配与回收机制，更容易在在线服务里做 token 级调度。第三，和前缀复用天然兼容：当某段前缀已经生成过，对应的 KV blocks 可以直接挂到新请求的块表上，而不必搬移整段缓存。</p>
<p>因此，Paged Attention 的本质是<span style="background-color: #c0c0c0;">KV Cache 的分页管理与访问优化</span>，而不是新的注意力数学形式。它不改变注意力结果本身，改变的是推理引擎如何在显存里存放和调度这些历史状态。也正因为如此，它通常与连续批处理（Continuous Batching）、Prompt Caching 和块级回收策略一起出现，是高吞吐推理引擎里的基础设施级组件。</p>
<div class="blog_h4"><span class="graybg">为什么连续预留显存会失效</span></div>
<p>若沿用传统的连续分配思路，系统通常需要为每条请求预留一大段连续显存，用来容纳“未来可能继续增长”的 KV Cache。但生成长度在服务开始时并不可知：有的请求很快结束，有的请求会持续生成很长文本。于是，预留得太保守会频繁扩容甚至失败，预留得太激进又会浪费大量显存。</p>
<p>这会同时带来两类碎片。内部碎片（Internal Fragmentation）来自“已经预留但尚未使用”的那部分连续空间；外部碎片（External Fragmentation）则来自请求不断进入和退出后，显存里出现许多零散空洞。即使总空闲显存仍然足够，也可能因为拿不到一整段足够长的连续区域，而无法容纳新的长请求。Paged Attention 的出发点正是消除这种“连续大块内存”假设。</p>
<div class="blog_h4"><span class="graybg">块表是如何工作的</span></div>
<p>Paged Attention 借鉴了虚拟内存（Virtual Memory）的分页思想。推理引擎先把可用于 KV Cache 的显存切成大量固定大小的物理块（Physical Blocks），每个块只容纳固定数量 token 的 K/V。对单条序列而言，模型逻辑上仍然看到一段从位置 1 到位置 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 的连续上下文；但在物理层，这些 token 对应的 KV 可能分散存放在许多互不相邻的块里。</p>
<p>块表（Block Table）负责维护这种映射关系：逻辑上的第 <span displaypfx="inline-" class="mathjax-container">\(i\)</span> 个块，对应显存中的哪一个物理块。当当前尾块写满后，系统只需从空闲池中再取一个新块，把它挂到块表末尾即可，并不要求新块和前一块在物理地址上相邻。这样一来，序列增长就从“申请更长的一整段连续空间”变成了“追加一个固定大小的新块”。</p>
<p>这种布局把碎片问题压到很小的范围内。内部浪费通常只会出现在最后一个尚未写满的尾块中，而不会沿整条序列累计；外部碎片则因为不再要求大块连续空间而大幅缓解。工程上真正被频繁分配和回收的，不再是“整条请求的整段缓存”，而是细粒度、等尺寸的块。</p>
<div class="blog_h4"><span class="graybg">注意力访问路径如何改变</span></div>
<p>Paged Attention 改变的是读取路径，而不是注意力公式本身。对当前 query 而言，模型仍然需要与全部历史 token 的 Key / Value 交互；不同之处在于，这些历史状态不再通过一块连续显存读取，而是由 kernel 按块表顺序逐块定位、聚合并完成注意力计算。逻辑上的序列连续性由块表保证，物理上的离散布局由运行时屏蔽。</p>
<p>因此，Paged Attention 更准确地说是一种<span style="background-color: #c0c0c0;">分页式 KV 访问内核</span>。它要求注意力实现能够接受“逻辑连续、物理离散”的缓存布局，并在 kernel 内高效完成地址映射、块级遍历和结果聚合。也正因为如此，Paged Attention 并不是简单的内存分配器技巧，而是需要推理 runtime 与注意力 kernel 协同设计的系统级能力。</p>
<div class="blog_h4"><span class="graybg">前缀共享与写时复制</span></div>
<p>分页布局还自然支持前缀共享（Prefix Sharing）。当多个请求拥有相同前缀时，它们可以在块表层共同指向同一组前缀块，而不必各自复制一份完整的前缀 KV。这种共享对系统提示词固定、多轮追问、束搜索（Beam Search）或树状探索都很重要，因为这些场景往往存在大段公共前缀。</p>
<p>一旦不同请求在后续 token 上开始分叉，系统再为各自分配新的尾部块即可；已经共享的旧块保持只读并可继续复用。这就是写时复制（Copy-on-Write）的典型思想：真正发生差异时才复制，未分叉前尽量共享。后文的 Prompt Caching 可以看作这种能力在“跨请求、跨时间窗口前缀复用”上的工程化延伸。</p>
<div class="blog_h3"><span class="graybg">Prompt Caching（前缀缓存）</span></div>
<p>Prompt Caching（前缀缓存）缓存的是“提示词前缀的 KV Cache”，目标是避免在多轮对话或重复前缀场景下反复做 Prefill：当新请求的开头 token 序列与某个已缓存前缀完全一致时，推理引擎可以直接复用这段前缀的 KV blocks，只对新增 token 做增量计算。</p>
<p>它直接优化两个指标：</p>
<ul>
<li>首 token 延迟（Time To First Token, TTFT）：减少或跳过重复前缀的 Prefill。</li>
<li>成本：若推理服务对缓存命中（Cache Hit）的前缀按更低费率计费，则输入 token 成本显著下降。</li>
</ul>
<p>Prompt Caching 的工程前提是“字节级一致（Exact Prefix Match）”：通常要求 tokenizer 后的 token 序列完全一致，任何空格/标点/系统提示词差异都会导致 cache miss。因此它更适合“系统提示词固定 + 文档前缀固定 + 多次追问”的产品形态，而不是随意变化的自由对话。</p>
<p>这类缓存的生命周期通常不是固定 TTL，而更像一种<span style="background-color: #c0c0c0;">受显存压力驱动的短生命周期缓存</span>。最常见的形态是直接驻留在 GPU 显存里，只要显存充足就保留，一旦新请求挤压缓存池，就按 LRU 等策略优先淘汰不活跃前缀。也有系统会把不活跃的 KV blocks 下沉到 CPU 内存形成分层缓存，以换取更长的可复用时间；但完整 KV Cache 很少作为常规路径直接落盘长期持久化，因为它体积大、回读慢、反序列化开销高，往往不如重新做一次 Prefill 划算。</p>
<div class="blog_h3"><span class="graybg">多租户隔离（Multi-tenancy Isolation）</span></div>
<p>在共享推理后端中，Prompt Caching 必须严格做多租户隔离（Multi-tenancy Isolation）：缓存命中不能只依赖“前缀文本哈希”，还需要把租户/用户身份与模型版本纳入缓存键（Cache Key），避免跨用户“串台”与侧信道泄露。典型复合键（Composite Key）包括：</p>
<ul>
<li>租户 ID（Org/User ID）</li>
<li>模型版本（Model/Weights Version）</li>
<li>前缀 token 哈希（Prefix Hash）与长度等元数据</li>
</ul>
<p>缓存生命周期通常很短：KV Cache 占用显存，后端会用 LRU（Least Recently Used）等策略在压力下淘汰缓存；因此“隔天再聊成本回到原价”是常见现象。若业务需要跨小时/跨天复用长前缀，工程上一般转向两类手段：长效上下文缓存（Context Caching，若服务支持）或 RAG/摘要把长前缀变成可检索的外部状态。</p>
<div class="blog_h2"><span class="graybg">KV Cache 压缩</span></div>
<p>KV Cache 压缩（KV Cache Compression）讨论的是：在不显著破坏注意力行为的前提下，把每个 token 需要缓存的 Key / Value 表示存得更小。它的直接目标不是训练提速，而是长上下文推理时的显存占用、带宽压力和并发能力。因此，从<span style="background-color: #c0c0c0;">优化对象</span>看，Latent KV、MLA、KV 量化、TurboQuant 都属于推理阶段优化；只是从<span style="background-color: #c0c0c0;">实现方式</span>看，它们并不全是推理后处理技巧，有些方法需要在模型架构和训练阶段就内建进去。</p>
<div class="blog_h3"><span class="graybg">Latent KV / MLA</span></div>
<p>一条路线是潜空间压缩（Latent-space Compression）。它不是直接缓存完整维度的 Key / Value，而是先把每个 token 的 KV 投影到更低维的潜空间（Latent Space），缓存潜变量；当需要参与注意力计算时，再由模型内部结构把潜变量还原成用于打分和聚合的表示。这类方法常被概括为 Latent KV，典型代表就是 MLA（Multi-head Latent Attention）。</p>
<p>这类方法的本质是一种<span style="background-color: #c0c0c0;">架构级的推理友好设计</span>。它服务的是推理阶段的 KV 成本问题，但并不是像 KV 量化那样在现成模型外部直接套一个压缩器；模型通常需要在训练时就学会如何在潜空间里存储和恢复有效的注意力信息。因此，它应被放在推理优化里讨论，但不能误解成“任何现有模型都可无缝加上的后处理插件”。</p>
<p>相对于 GQA/MQA 只是在“头数”上减少缓存，Latent KV / MLA 更进一步，直接压缩每个 token 的 KV 表示维度。收益通常体现为更长上下文、更高并发和更低带宽占用；代价则是算子更复杂、实现更依赖内核与数值稳定性，而且表示压缩本身会改变模型的内部信息流，因此往往需要专门的训练配方与模型容量补偿。</p>
<div class="blog_h3"><span class="graybg">KV 量化</span></div>
<p>另一条路线不是换表示，而是保留原始 Key / Value 的语义角色，直接把缓存张量从 FP16 / BF16 压成更低 bit 的数值格式。它的目标是在尽量不改变注意力行为的前提下，减少 KV Cache 的存储开销与读取带宽。这类方法更接近传统意义上的推理后处理：对现有模型更友好，工程接入成本通常低于 Latent KV / MLA，但量化误差是否会放大到注意力分布中，是成败关键。</p>
<div class="blog_h3"><span class="graybg">TurboQuant</span></div>
<p>TurboQuant 属于 KV 量化路线，但它并不是把“最小化坐标重建误差（MSE）”当作唯一目标。它真正抓住的是注意力计算的承重点：对 Key 而言，最重要的不是逐坐标把原向量还原得多么精确，而是尽量保住 <span displaypfx="inline-" class="mathjax-container">\(q\cdot k\)</span> 这类内积关系，因为注意力分数本身就是由这些内积驱动的。换句话说，TurboQuant 优先保护的是<span style="background-color: #c0c0c0;">注意力几何结构</span>，而不只是原始向量外形。</p>
<div class="blog_h4"><span class="graybg">为什么普通 KV 量化不够</span></div>
<p>如果只从存储角度看，KV Cache 似乎只是把一串浮点数改成更低 bit 的数字格式；但注意力并不是逐坐标读取这些数，而是先计算</p>
<span displaypfx="" class="mathjax-container">\[s_i=\frac{q\cdot k_i}{\sqrt{d_k}},\quad \alpha_i=\mathrm{softmax}(s_i)\]</span>
<p>然后再用 <span displaypfx="inline-" class="mathjax-container">\(\alpha_i\)</span> 去聚合对应的 <span displaypfx="inline-" class="mathjax-container">\(v_i\)</span>。这意味着，Key 上很小的量化误差，一旦改变了 <span displaypfx="inline-" class="mathjax-container">\(q\cdot k_i\)</span> 的相对大小，就可能在 softmax 之后被放大成明显不同的注意力分布。也正因为如此，普通“逐坐标尽量还原”的量化器未必就能得到好的注意力质量，因为它优化的是向量外形，而注意力真正依赖的是内积排序与相对间隔。</p>
<p>从这个角度看，TurboQuant 更像一种<span style="background-color: #c0c0c0;">面向在线内积估计的向量量化</span>，而不是普通的低比特存储。它特别适合 KV Cache 这类在线场景：缓存必须边生成边写入，解码时又要反复读取并参与 <span displaypfx="inline-" class="mathjax-container">\(QK^\top\)</span> 计算，因此量化器不能只在离线重建误差上表现好，还必须在在线注意力分数上尽量稳定。</p>
<div class="blog_h4"><span class="graybg">核心直觉</span></div>
<p>TurboQuant 的核心直觉可以概括为一句话：<span style="background-color: #c0c0c0;">先把向量变成更容易量化的形状，再用极低成本修补量化后最关键的内积偏差</span>。如果把原始向量直接送进低比特量化器，少数高能量坐标、异常值和不均匀分布往往会主导误差；而注意力最敏感的又不是“某一维少了多少”，而是“整体内积关系是否还成立”。因此，TurboQuant 并不把压缩过程当作一次简单的数值离散化，而是分成了主压缩与内积校正两个层次。</p>
<div class="blog_h4"><span class="graybg">两阶段结构</span></div>
<p>它的整体结构可以理解为两阶段。第一阶段负责主量化（Main Quantization）：先对向量施加随机旋转（Random Rotation），再执行高质量的低比特量化。第二阶段负责残差校正（Residual Correction）：不再追求把每个坐标补得更精确，而是针对第一阶段留下的残差，额外附加一个极轻量的编码，用来降低量化后内积估计的系统偏差。这样一来，TurboQuant 的目标就从“压缩向量”推进成了“压缩后仍尽量保住注意力分数”。</p>
<p>若用概念来源去理解，这条路线可以看成“PolarQuant 风格的主量化 + QJL 风格的残差校正”的组合。前者解决“怎么把向量本体存得足够省且失真足够低”，后者解决“怎么让压缩后内积估计不要系统性跑偏”。这也是为什么 TurboQuant 看上去像一个量化方法，实际上却比普通低比特缓存更接近一个<span style="background-color: #c0c0c0;">面向注意力运算的压缩管线</span>。</p>
<div class="blog_h4"><span class="graybg">为什么先做随机旋转</span></div>
<p>随机旋转并不是为了“制造随机性”，而是为了改善向量分布的可量化性。原始 KV 向量往往各维统计特性差异很大，有的维度能量特别集中，有的维度接近冗余；直接低比特量化时，少数尖峰维度会迫使量化器把刻度对齐到这些极端值，结果是大量普通维度的有效分辨率被浪费掉。随机正交旋转之后，向量能量会更均匀地摊到各个方向上，各维统计特性更接近，量化器就更容易在固定 bit 预算下稳定工作。</p>
<p>更直观地说，随机旋转做的是“先把难量化的尖峰分布打散，再做统一压缩”。这一步并不改变向量之间真正的几何关系，却显著改变了逐坐标量化时面对的数值形态。因此，它服务的不是语义本身，而是后续低比特编码的数值条件。</p>
<div class="blog_h4"><span class="graybg">为什么还需要残差校正</span></div>
<p>如果只有第一阶段，TurboQuant 仍然只是一个“更会量化向量”的方法，还不能真正保证注意力分数稳定。问题在于：即使主量化已经把总体失真压得很低，内积估计仍可能存在系统偏差，而 softmax 对这种偏差非常敏感。于是第二阶段不再继续追求全面重建，而是把预算集中用在“修正内积”这件事上。</p>
<p>这种设计背后的判断非常重要：在注意力里，全面重建所有坐标并不是最高优先级，优先级更高的是让“谁该被关注、关注强度大概多大”不要被量化误差改写。也正因为如此，TurboQuant 的第二阶段看起来只加了很轻的一层编码，却能显著改变注意力保真度，因为它修补的是最承重的误差，而不是平均分摊误差。</p>
<div class="blog_h4"><span class="graybg">如何理解它的收益</span></div>
<p>TurboQuant 的收益并不主要体现在“把某个向量压缩得多漂亮”，而体现在<span style="background-color: #c0c0c0;">相同显存下能缓存更长上下文，或在相同上下文下支持更高并发</span>。它直接作用于解码阶段最贵的那部分状态，即各层持续增长的 KV Cache。对长上下文推理而言，这通常比单步算子优化更承重，因为一旦缓存存不下，系统就会被迫降低 batch、缩短上下文，或转向更慢的外部交换路径。</p>
<p>但这类收益能否真正转化为吞吐提升，还取决于实现细节。若压缩后每一步都要做很重的解码、解包和额外访存，理论上的存储优势就可能被运行时开销抵消。因此，TurboQuant 不只是一个“理论上更省”的方法，它是否好用还取决于推理引擎是否能把旋转、量化、残差校正与注意力计算做成足够紧的执行路径。</p>
<div class="blog_h4"><span class="graybg">与 Latent KV / MLA 的关系</span></div>
<p>因此，TurboQuant 与 Latent KV / MLA 的差异非常明确。TurboQuant 是<span style="background-color: #c0c0c0;">量化压缩</span>，核心是把同一个 KV 向量存得更省，同时尽量保住注意力内积；Latent KV / MLA 是<span style="background-color: #c0c0c0;">潜空间压缩</span>，核心是先换成更低维的内部表示再缓存。前者更接近推理栈里的压缩器，后者更接近模型架构层面的推理优化设计。它们都属于 KV Cache 压缩，但技术路线和接入条件并不相同。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">场景</td>
<td style="text-align: center;">策略</td>
<td style="text-align: center;">成本</td>
<td style="text-align: center;">延迟</td>
<td style="text-align: center;">风险/约束</td>
</tr>
</thead>
<tbody>
<tr>
<td>连续多轮对话（分钟级）</td>
<td>Prompt Caching</td>
<td>低（命中时前缀“打折”）</td>
<td>低（TTFT 改善明显）</td>
<td>要求前缀严格一致；缓存易被淘汰</td>
</tr>
<tr>
<td>跨小时/跨天复用长文档前缀</td>
<td>Context Caching / 预热</td>
<td>中（通常含存储费）</td>
<td>中-低</td>
<td>依赖服务能力；需要明确缓存生命周期与权限</td>
</tr>
<tr>
<td>大规模文档问答</td>
<td>RAG（检索增强生成）</td>
<td>低且稳定</td>
<td>稳定</td>
<td>需要索引构建、召回/重排与证据注入治理</td>
</tr>
<tr>
<td>长对话状态维护</td>
<td>滚动摘要（Rolling Summary）</td>
<td>低</td>
<td>稳定</td>
<td>摘要漂移与可验证性问题；需结构化约束</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">Speculative Decoding</span></div>
<p>推测解码（Speculative Decoding）用一个更小更快的草稿模型（Draft Model）一次提出多个候选 token，再由大模型（Target Model）并行验证并接受其中尽可能长的前缀。若接受率高，就能显著减少大模型需要执行的解码步数；若接受率低，则会退化并产生额外验证开销。实际收益取决于草稿模型速度、分布匹配程度与实现细节。</p>
<div class="blog_h2"><span class="graybg">批处理</span></div>
<p>批处理（Batching）讨论的是：如何把多个请求或多个 token 步骤拼到同一轮计算里，以提高硬件利用率。它本质上是在吞吐（Throughput）、单请求延迟（Latency）和调度复杂度之间做权衡。推理系统里常见的批处理并不只有一种，固定批处理、动态批处理和连续批处理服务的是不同流量形态。</p>
<div class="blog_h3"><span class="graybg">固定批处理（Static Batching）</span></div>
<p>固定批处理（Static Batching）是最传统的做法：先攒够固定数量的请求，再统一组成一个 batch 执行。它的优点是实现简单、执行路径稳定、硬件利用方式容易预测，因此很适合离线推理、批量评测、Embedding 批处理或输入长度相近的后台任务。</p>
<p>它的问题同样直接。第一，等待时间明显：如果系统必须凑满 batch 才发车，单请求延迟会被批量收集时间拉长。第二，padding 浪费常常很大：当同一批次中的序列长度差异明显时，短序列必须补到与长序列对齐，等于用无效 token 占用了算力。也正因为如此，固定批处理通常更适合“任务离线、流量可控、长度相近”的环境，而不适合作为通用在线 LLM 服务的主调度方式。</p>
<div class="blog_h3"><span class="graybg">动态批处理（Dynamic Batching）</span></div>
<p>动态批处理（Dynamic Batching）是在固定批处理上的现实改进。系统不再死等“凑满固定 batch”，而是在一个较短时间窗口内，把相近时间到达的请求尽量拼进同一批次里。这样做的好处是：既能保留一部分并行执行收益，又能避免固定批处理带来的长时间等待。</p>
<p>它适合中等并发的在线推理服务，尤其是在请求长度相对可控、生成长度不算太长的场景里表现不错。但它仍然主要以“请求”为单位成批，而不是以“token 步骤”为单位重组，因此当不同请求的生成过程拉得很长、长度分化越来越大时，批次内部的同步等待和 padding 问题依然存在。换句话说，动态批处理改善了固定批处理的僵硬性，但还没有真正解决 LLM 自回归解码里的异步性问题。</p>
<div class="blog_h3"><span class="graybg">连续批处理（Continuous Batching）</span></div>
<p>连续批处理（Continuous Batching）面向在线服务：请求不断到达、序列长度各不相同。与“固定 batch + padding”相比，连续批处理按 token 粒度调度，把不同请求的下一步解码拼到同一个批次里，从而提升吞吐并减少 padding 浪费。它不再把“一整条请求”视为不可拆分的调度单位，而是把系统里所有活跃序列都当作可持续重组的解码状态。</p>
<p>这类方法之所以重要，是因为 LLM 解码阶段天然是异步的：有的请求很快结束，有的请求仍在长输出，有的请求刚刚进入系统。若继续用请求级批处理，GPU 经常会被“最慢那几个序列”拖住；而连续批处理允许系统在每一步把已经完成的序列移出，再把新请求或其他活跃序列的下一步补进来，使 batch 始终维持较高利用率。</p>
<div class="blog_h4"><span class="graybg">为什么静态批处理不够</span></div>
<p>静态批处理的问题并不只是“padding 多一点”这么简单，而是它把一整条请求当成不可拆分的运行单元。假设同一批里有四个请求，其中三个很快生成结束，另一个却还要继续生成很长一段文本，那么 GPU 在后续很多步里实际上只能继续为这一个长请求服务，原先空出来的位置却不能被新请求及时填补。这样造成的不是数学错误，而是硬件空转：算力被“批次里最慢的那个请求”绑住了。</p>
<p>因此，连续批处理真正改变的不是 batch 的大小，而是<span style="background-color: #c0c0c0;">调度颗粒度</span>。传统静态批处理按“请求”调度，连续批处理按“下一 token 的解码步”调度。只要某个请求结束、被取消、或暂时被抢占，调度器就可以立刻把它从运行队列中移出，再用等待队列里的新请求补位。</p>
<div class="blog_h4"><span class="graybg">vLLM 到底在连续什么</span></div>
<p>在 vLLM 这类系统里，所谓“连续”并不是指某个 batch 永远不结束，而是指调度器会在几乎每一次 decode 迭代之后重新审视当前运行集合。若当前正在运行的请求集合记为 <span displaypfx="inline-" class="mathjax-container">\(\mathcal{R}_t\)</span>，每个请求在第 <span displaypfx="inline-" class="mathjax-container">\(t\)</span> 轮各生成一个新 token，那么在进入第 <span displaypfx="inline-" class="mathjax-container">\(t+1\)</span> 轮之前，系统就会检查：哪些请求已经生成到结束符、达到最大长度或被上层中断；哪些等待中的新请求应被拉入运行队列。于是，运行中的 batch 并不是一次性固定下来的，而是在 decode 过程中持续流动。</p>
<p>这也是为什么连续批处理本质上更像<span style="background-color: #c0c0c0;">token 级流水调度</span>，而不是传统意义上的“把若干完整请求捆成一批”。高吞吐来自这种持续补位机制：只要有位置空出来，调度器就尝试让新的工作立刻进来，从而保持 GPU 上活跃序列数尽量稳定。</p>
<div class="blog_h4"><span class="graybg">Prefill 与 Decode 如何混排</span></div>
<p>连续批处理的工程难点在于，新进来的请求并不处在和老请求相同的计算阶段。老请求通常已经进入逐 token 解码（Decode），而新请求刚到达时还需要先完成整段提示词的预填充（Prefill）。这两个阶段的计算特征并不相同：Prefill 更接近长序列的大矩阵计算，往往更偏计算密集；Decode 则更依赖 KV Cache 读取与单步增量计算，往往更偏带宽与调度密集。</p>
<p>高吞吐推理引擎的关键能力之一，就是能把这两类工作纳入同一套调度体系，而不是强制等所有 Prefill 做完才统一进入 Decode。于是，新请求在进入系统后会先经历一次 Prefill，把提示词对应的 KV 写入缓存；随后它就能在下一个调度周期加入解码队列，和其他正在生成的请求一起按 token 粒度推进。工程实现上是否在同一次前向里混合执行 Prefill 与 Decode，取决于具体 runtime 的算子与调度设计；但从系统抽象看，它们必须被统一编排，否则连续批处理就无法真正发挥价值。</p>
<div class="blog_h4"><span class="graybg">为什么必须配合分页式 KV 管理</span></div>
<p>连续批处理之所以直到近年的高吞吐推理引擎才真正成熟，一个核心原因就在 KV Cache 管理。若每条请求都必须占据一整段预留好的连续显存，那么请求不断进出时，显存会迅速碎片化：短请求结束后留下很多小空洞，长请求却可能因为拿不到足够长的一段连续内存而无法被调度进去。此时即使调度逻辑想连续补位，底层显存布局也会把它卡死。</p>
<p>这正是 Paged Attention / Paged KV 发挥作用的地方。通过把 KV Cache 切成固定大小的块，再用块表去描述逻辑上的连续序列，系统就不再要求“每个请求必须拥有一整段连续显存”。于是，块可以被细粒度分配、回收、复用和共享，连续批处理才真正有了工程基础。也正因为如此，在现代 LLM 服务栈里，Paged KV 与 Continuous Batching 几乎总是成对出现：前者解决“缓存如何活着”，后者解决“调度如何持续流动”。</p>
<p>它的实现难点也最高。连续批处理要和 KV Cache 管理、Paged Attention、块分配/回收、抢占策略和流式输出协同设计，还必须在吞吐与 P99 延迟之间做持续权衡。也正因为如此，它通常不是单独存在的一项小优化，而是高吞吐推理引擎的核心调度机制。</p>
<div class="blog_h2"><span class="graybg">推理框架</span></div>
<p>推理框架（Inference Serving Stack）把“模型权重 + 推理图 + 调度策略”封装成可部署的服务。选型时优先看三件事：是否支持你的模型与精度（Compatibility）、是否能把 KV Cache 与批处理调度做对（Scheduler + KV Management）、以及在你的硬件上能否稳定达到目标吞吐/延迟（Performance Envelope）。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">框架</td>
<td style="text-align: center;">定位</td>
<td style="text-align: center;">强项</td>
<td style="text-align: center;">代价/约束</td>
<td style="text-align: center;">适用场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>vLLM</td>
<td>生产级高吞吐推理</td>
<td>Paged KV + 连续批处理；并发与吞吐强</td>
<td>部署与调优门槛更高；更依赖 CUDA 与服务化环境</td>
<td>多租户在线服务；RAG/Agent 高并发；企业 API</td>
</tr>
<tr>
<td>TensorRT-LLM</td>
<td>NVIDIA 推理加速栈</td>
<td>内核与图级优化；低延迟上限高</td>
<td>构建/调参成本高；硬件绑定强</td>
<td>对延迟敏感的核心服务；固定模型部署</td>
</tr>
<tr>
<td>TGI（Text Generation Inference）</td>
<td>通用推理服务</td>
<td>生态成熟；易集成；支持常见部署形态</td>
<td>极致吞吐/显存利用率取决于具体模型与配置</td>
<td>快速上线；标准化 HuggingFace 模型服务</td>
</tr>
<tr>
<td>SGLang</td>
<td>面向 LLM 应用的推理与编排</td>
<td>更贴近应用侧的执行模型；对复杂推理/工具调用友好</td>
<td>需要接受其编排抽象；生态仍在快速演进</td>
<td>复杂 Agent/RAG pipeline；结构化推理任务</td>
</tr>
<tr>
<td>llama.cpp（GGUF）</td>
<td>本地/边缘推理</td>
<td>CPU/小 GPU 友好；量化生态完善；分发简单</td>
<td>吞吐与模型规模受硬件限制；在线并发能力有限</td>
<td>个人/离线实验；边缘设备；小规模服务</td>
</tr>
<tr>
<td>Ollama</td>
<td>本地运行与分发工具</td>
<td>安装简单；模型拉取、管理与切换顺手</td>
<td>更偏单机体验而非高并发服务性能</td>
<td>个人原型验证；本地开发；小规模离线使用</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">Ollama 与 vLLM</span></div>
<p>如果只看最常见的部署分叉，Ollama 与 vLLM 实际上分别代表了两种完全不同的目标函数。Ollama 解决的是“如何让个人开发者或小团队在本地几乎零门槛地把模型跑起来”；vLLM 解决的则是“如何让同一份模型在服务端硬件上稳定承载更多请求，并把 KV Cache、批处理和显存调度做到足够高效”。前者更像本地模型工厂，后者更像生产环境推理引擎。</p>
<p>部署体验上的差异最直接。Ollama 把模型下载、量化版本管理、本地 API 与交互入口统一封装起来，目标是尽量减少环境配置摩擦；vLLM 则仍然更像一个服务端运行时，需要围绕 Python / CUDA / PyTorch 版本、模型路径、并发参数、显存预算和服务启动方式做明确配置。二者并不是“谁更先进”的关系，而是在为不同的使用者优化：Ollama 优先优化可用性，vLLM 优先优化服务效率。</p>
<p>性能侧的分界点通常出现在并发与显存管理。低并发、单人本地交互时，Ollama 往往已经足够顺畅，而且对量化模型和消费级显卡更友好；当请求数上升、上下文变长、需要更稳定地榨出 GPU 吞吐时，vLLM 的 PagedAttention、连续批处理（Continuous Batching）和更系统化的 KV 管理优势就会明显体现出来。前一节所说那种按 token 步骤持续补位的调度机制，正是 vLLM 一类高吞吐引擎的核心能力之一。换句话说，Ollama 更偏“把一次请求跑得足够方便”，vLLM 更偏“把很多请求一起跑得足够经济”。</p>
<p>因此，可以把这条选型规则压缩成一句话：<span style="background-color: #c0c0c0;">本地开发、个人使用、边缘设备与隐私优先场景更偏向 Ollama；企业服务、高并发 API、多租户平台和多卡扩展场景更偏向 vLLM</span>。若一个项目未来很可能从“个人原型”走向“在线服务”，最常见的工程路径也往往是先用 Ollama 快速验证模型行为，再迁移到 vLLM 一类更强的服务端推理栈。</p>
<div class="blog_h3"><span class="graybg">vLLM</span></div>
<p>vLLM 是面向大模型推理的高吞吐推理引擎（Inference Engine），核心目标是在显存受限的情况下提升并发与吞吐。它的代表性设计是 PagedAttention：把 KV Cache 按固定大小切成块（Blocks），用块表（Block Table）把“逻辑连续的序列”映射到“物理上不必连续的显存块”。</p>
<p>这样做的收益是：按需分配 KV、降低内存碎片（Fragmentation），并支持前缀共享（Prefix Sharing）：当多个请求共享同一段前缀（例如相同系统提示词/相同文档前缀）时，可以复用同一份缓存块，从而节省显存并降低 Prefill 成本。</p>
<p>vLLM 的真正价值并不只在某个单独算子，而在于它把<span style="background-color: #c0c0c0;">KV Cache 管理、连续批处理、调度和服务接口</span>统一成了面向高并发场景的整体系统。具体到调度层，它做的正是前文展开的那种 token 级连续批处理：每一轮 decode 之后重审运行队列，把已经结束的序列移出，再让等待中的新请求完成 Prefill 并尽快补入后续 decode 周期。对企业级 API、RAG 服务、多轮聊天机器人或多租户平台而言，请求通常不会整齐地同时开始和结束；连续批处理能让系统在 decode 过程中持续吸纳新请求，而不是死板地按静态 batch 切分，这正是它在生产环境里更占优势的原因。</p>
<p>它的边界也很清楚。vLLM 本身不是给普通用户做交互体验优化的工具，而是给工程团队做吞吐、延迟、监控和资源利用率优化的 runtime。于是它更适合有明确服务化目标的团队：需要 API 暴露、监控指标、批处理调优、多 GPU 并行和较强可观测性时，vLLM 往往比本地导向工具更自然；但在单机小显存、低并发、快速原型阶段，它未必是最省力的第一选择。</p>
<div class="blog_h3"><span class="graybg">TensorRT-LLM</span></div>
<p>TensorRT-LLM 是面向 NVIDIA GPU 的大模型推理加速栈，重点在于高效算子（Kernel）与图级优化（Graph-level Optimization）：通过更强的算子融合、更贴近硬件的精度与布局选择（例如 FP16/BF16/FP8、weight-only quantization），把通用 Transformer 计算编译成更高吞吐、更低延迟的推理执行计划。</p>
<p>它更偏“部署与性能工程”路径：需要围绕目标 GPU、精度与 batch/seq 长度做构建与调优；换来的是更稳定的延迟与更高的吞吐上限。</p>
<div class="blog_h3"><span class="graybg">Ollama</span></div>
<p>Ollama 是面向本地开发的模型运行与分发工具：提供统一的模型拉取、量化版本管理与本地推理接口，降低“把模型跑起来”的工程摩擦。它更适合个人/小团队在本机进行原型验证与离线实验；若追求高并发在线服务，通常会使用更专业的推理引擎与服务编排。</p>
<p>它的核心价值并不在于把吞吐推到极限，而在于把本地使用路径做得足够短：模型名称、下载、版本切换、交互入口和本地 API 都被统一包装起来。对个人学习、隐私敏感的小规模应用、本地插件开发或“先验证模型行为再说”的探索阶段，这种低门槛通常比极致性能更重要。</p>
<p>也正因为如此，Ollama 对消费级机器往往更友好。单卡、小显存、量化模型、本地命令行交互，这些都是它的舒适区；但一旦目标转向多租户、高并发、细粒度监控和多卡线性扩展，它就不再是最自然的中心组件。更准确地说，Ollama 优化的是<span style="background-color: #c0c0c0;">单机可用性与本地开发体验</span>，而不是生产环境下的系统吞吐极限。</p>
<div class="blog_h3"><span class="graybg">TGI（Text Generation Inference）</span></div>
<p>TGI（Text Generation Inference）是面向 HuggingFace 模型生态的通用推理服务：提供标准化的 HTTP/gRPC 接口与常见的批处理/流式输出能力，适合把“能跑的模型”快速变成“可用的服务”。它的价值在于工程整合与稳定性，而不是把每个场景都推到极致性能。</p>
<div class="blog_h3"><span class="graybg">SGLang</span></div>
<p>SGLang 强调“把推理当作可编排的程序执行”：当应用需要多步生成、结构化输出、工具调用或复杂控制流时，推理框架不仅要快，还要能把控制逻辑表达清楚并与 KV Cache 调度协同。它更像是服务端的“推理 DSL + runtime”。</p>
<div class="blog_h3"><span class="graybg">llama.cpp（GGUF）</span></div>
<p>llama.cpp 是一套本地/边缘推理 runtime：围绕 GGUF/量化权重与 CPU/GPU 混合执行做了大量工程优化。它在“把模型放到普通机器上跑起来”这一目标上极具性价比，但并不追求云端多租户场景下的极限吞吐。</p>
<div class="blog_h1"><span class="graybg">上下文工程（Context Engineering）</span></div>
<div class="blog_h2"><span class="graybg">从提示词工程到上下文工程</span></div>
<p>提示词工程（Prompt Engineering）最早强调的是“怎么写一句更有效的话”，关注点集中在措辞、顺序、示例和限制条件。随着大模型应用从单轮问答扩展到智能体（AI Agent）、RAG、多工具调用与长会话系统，工程重点逐渐转向上下文工程（Context Engineering）：真正决定效果的，不再只是某一句提示词本身，而是<span style="background-color: #c0c0c0;">系统提示、用户输入、示例、检索结果、对话历史、工具返回、结构化状态与输出约束如何被整体组织成一次模型调用</span>。</p>
<p>因此，上下文工程可以看作比提示词工程更大的外层概念。提示词仍然重要，但它只是上下文中的一个组件；在生产系统里，更关键的问题通常是“哪些信息应该进入上下文、以什么顺序进入、保留多久、如何压缩、何时替换、如何约束输出”。这一变化反映的不是术语时尚，而是应用形态已经从“写一句 prompt”演进为“设计一套输入装配系统”。</p>
<div class="blog_h2"><span class="graybg">上下文学习</span></div>
<p>上下文学习（In-context Learning）指模型在不更新参数的前提下，仅凭当前输入中的任务说明、示例与约束完成新任务。它依赖的是上下文中的条件信息，而不是额外微调。</p>
<div class="blog_h3"><span class="graybg">零样本（Zero-shot）</span></div>
<p>零样本（Zero-shot）是在不给示例的情况下，直接通过任务描述让模型完成目标。例如“判断以下影评是正面还是负面，只输出标签”。它的优点是成本低、迁移快；缺点是任务边界一旦含糊，模型更容易自由发挥。</p>
<div class="blog_h3"><span class="graybg">少样本（Few-shot）</span></div>
<p>少样本（Few-shot）是在提示词中附带少量示例，让模型在当前上下文里归纳输入输出模式。它特别适合标签定义不够直观、格式要求严格或任务带有领域习惯的场景。示例的价值不只是“给答案”，更是显式定义任务边界、异常情况与输出风格。</p>
<div class="blog_h2"><span class="graybg">提示词结构</span></div>
<p>一个可维护的提示词通常不是一段散乱文本，而是若干功能块的组合。把这些块拆开，能够显著提高复用性、可调试性与一致性。</p>
<div class="blog_h3"><span class="graybg">角色定位</span></div>
<p>角色定位（Role）定义模型在本次任务中的身份与职责边界，例如“你是严谨的法律信息抽取器”或“你是面向儿童解释科学概念的讲解者”。它的作用是设定默认行为策略，而不是替代具体任务指令。</p>
<div class="blog_h3"><span class="graybg">指令</span></div>
<p>指令（Instruction）直接说明模型要完成什么任务，是提示词中最核心的控制块。好的指令应当明确目标、操作步骤、禁止事项与完成标准，避免把多个含糊目标混在同一层表达里。</p>
<div class="blog_h3"><span class="graybg">上下文</span></div>
<p>上下文（Context）提供完成任务所需的背景信息，例如产品文档、会话历史、用户约束、检索片段或前一步工具返回。它回答的是“模型基于什么信息工作”，而不是“模型该怎么工作”。</p>
<div class="blog_h3"><span class="graybg">输出格式</span></div>
<p>输出格式（Output Format）规定结果应该长什么样，例如纯文本、项目符号、JSON、表格或固定字段。格式要求越明确，后续系统越容易解析、验证与链接到其它模块。</p>
<div class="blog_h3"><span class="graybg">受众</span></div>
<p>受众（Audience）决定解释深度、术语密度与默认背景知识。例如 ELI5（Explain Like I'm 5）指面向完全没有背景知识的读者，需要更浅白的表达；面向资深工程师时，则应保留专业术语、边界条件与实现细节。</p>
<div class="blog_h3"><span class="graybg">语气</span></div>
<p>语气（Tone）控制表达风格，例如正式、简洁、鼓励式、审慎式或客服式。它影响的是话语风格和风险表达方式，但不应改变事实本身或任务判断标准。</p>
<div class="blog_h3"><span class="graybg">数据</span></div>
<p>数据（Data）指与任务本身直接相关的材料，例如待分类文本、待总结文档、用户表单、日志片段、表格记录或检索回来的证据。很多提示词失败并不是“不会写提示词”，而是没有把真正决定任务结果的数据放进上下文。</p>
<div class="blog_h2"><span class="graybg">上下文构建</span></div>
<div class="blog_h3"><span class="graybg">系统提示（System Prompt）</span></div>
<p>系统提示（System Prompt）是整个上下文的最高层行为约束，通常用于定义角色、原则、边界和长期稳定规则。它应该稳定、简短且高信号，承载通用策略，而不适合塞入频繁变化的任务细节。</p>
<div class="blog_h3"><span class="graybg">对话历史与记忆</span></div>
<p>多数大模型 API 是无状态（Stateless）的：模型在一次调用中只“看到”你发送的输入（Prompt），不会自动记住上一次调用的对话内容。因此对话应用通常需要把对话历史（Conversation History）连同当前用户输入一起发给模型。</p>
<p>这会带来两个直接后果：</p>
<ul>
<li>token 占用会随着历史增长而上升（直到触达上下文窗口上限）。</li>
<li>并非“发得越多越好”：冗余历史会稀释注意力、提高成本，并可能引入过时或冲突信息。</li>
</ul>
<p>工程上常见的记忆（Memory）分层是：</p>
<ul>
<li>短期记忆（Short-term Memory）：保留最近 N 轮原文对话，确保局部连贯。</li>
<li>工作记忆（Working Memory）：把对话状态压缩成结构化摘要，例如用户偏好、已确认事实与当前任务约束。</li>
<li>长期记忆（Long-term Memory）：把历史片段写入外部存储（向量库/数据库），需要时检索回填。</li>
</ul>
<div class="blog_h3"><span class="graybg">工具调用上下文</span></div>
<p>工具调用上下文（Tool Context）指模型在调用搜索、数据库、代码执行器或业务 API 后得到的中间结果。它的关键不只是“把结果贴回模型”，而是把工具返回整理成模型真正可用的状态：保留必要字段、去掉噪声、标明来源与时间，并避免把原始日志整段塞进上下文。</p>
<div class="blog_h3"><span class="graybg">渐进式披露</span></div>
<p>渐进式披露（Progressive Disclosure）强调：不要在一开始把所有文档、规则、工具说明和历史对话一次性塞进上下文，而应只注入当前步骤真正需要的那一层信息。模型先看到最小可行上下文，只有当任务推进到下一层时，才继续展开更具体的约束、领域知识或执行细节。</p>
<p>这样做的原因不只是节省 token，更是为了保持推理质量。多组工程实践都观察到，上下文利用率一旦超过大约 40%，模型就可能从“聚焦求解”进入“信息过载”状态，开始出现幻觉、循环、格式错误或低质量实现。渐进式披露的目标，就是让 Agent 长时间停留在这个甜蜜区，而不是被无关材料拖进噪声区。</p>
<div class="blog_h2"><span class="graybg">上下文窗口管理</span></div>
<div class="blog_h3"><span class="graybg">Token 预算</span></div>
<p>Token 预算（Token Budget）是把上下文窗口（Context Window）当作一种稀缺资源来管理：一次请求的输入 token 与输出 token 都要计入模型的最大长度限制，并直接影响延迟与成本。</p>
<p>经验做法是把 prompt 切成可控的几块：系统提示尽量短且稳定；对话历史只保留近期原文；把长期信息通过检索（Retrieval）按需注入。</p>
<div class="blog_h3"><span class="graybg">压缩与摘要</span></div>
<p>压缩与摘要（Compression &amp; Summarization）的核心不是把文本“变短”，而是把信息从逐字转录（Transcript）变成可被模型继续使用的状态（State）。常见策略：</p>
<ul>
<li>滚动摘要（Rolling Summary）：每轮对话后更新一段固定长度的当前状态。</li>
<li>层级摘要（Hierarchical Summarization）：长文先分块总结，再总结摘要。</li>
<li>结构化记忆（Structured Memory）：用 JSON 或表格保存关键字段（偏好、约束、已决策项），避免自然语言摘要漂移。</li>
</ul>
<p>摘要要可验证：优先保留可操作事实（约束、数值、名称、决策），少写主观评价。</p>
<div class="blog_h3"><span class="graybg">长文本处理策略</span></div>
<p>当最新一次对话依赖很久以前的信息时，最可靠的方法不是无限追加历史，而是检索增强：把历史切成片段并建立索引（向量索引、关键词索引、主题标签），在每次请求前先检索与当前问题最相关的片段，再把这些片段注入上下文。</p>
<p>在当前智能体开发里，最主流的组合通常是：<span style="background-color: #c0c0c0;">滑动窗口 + 滚动摘要 + 向量检索（RAG）</span>。早期的外部记忆网络（Memory Networks / Neural Turing Machine）更多是研究范式，工程落地上更常见的是向量数据库与检索管线。</p>
<div class="blog_h2"><span class="graybg">结构化输出</span></div>
<div class="blog_h3"><span class="graybg">JSON Schema 约束</span></div>
<p>JSON Schema 约束把“输出应该长什么样”前置为机器可验证的结构定义，例如字段名、字段类型、必填项、枚举值与嵌套关系。它的价值在于把格式控制从“模型尽量照做”提升到“系统可以检查对不对”，从而显著降低后处理复杂度。</p>
<div class="blog_h3"><span class="graybg">函数调用（Function Calling）</span></div>
<p>函数调用（Function Calling）把模型输出从自由文本转成“选择哪个工具、填写哪些参数”的结构化决策。它的本质不是让模型直接执行函数，而是让模型先生成一个可被程序消费的调用意图，再由外部系统负责权限校验、真实执行与结果回填。</p>
<div class="blog_h2"><span class="graybg">高级提示词策略</span></div>
<p>这里讨论的高级提示词策略，指的是仅通过提示词设计改变模型求解过程，而不借助额外框架、搜索控制器或推理编排器。它们本质上仍属于上下文工程：通过改变输入结构，让模型在一次或少数几次调用中显式展开中间推理步骤。</p>
<div class="blog_h3"><span class="graybg">思维链（Chain-of-Thought）</span></div>
<p>思维链（Chain-of-Thought, CoT）的核心做法是：在提示词中明确要求模型先分步分析，再给出最终答案。例如，可以把任务写成“先列出关键事实，再逐步推理，最后只输出结论”。若任务本身较复杂，也可以在 few-shot 示例里直接展示“问题 ➡ 分步推理 ➡ 最终答案”的格式，让模型在上下文中模仿这种求解模式。</p>
<p>在纯提示词使用方式下，CoT 的关键不是一句固定咒语，而是把推理过程结构化地写进输出要求。例如，可以把输出格式限定为“步骤 1 / 步骤 2 / 结论”，或要求模型先检查条件、再排除候选、最后汇总结论。这样做通常有利于多步算术、条件判断、规则推导与长链依赖任务；但对简单任务、严格结构化输出任务或成本敏感场景，强行要求展开思维链反而会增加 token 开销与噪声。</p>
<div class="blog_h3"><span class="graybg">思维树（Tree of Thoughts）</span></div>
<p>思维树（Tree of Thoughts, ToT）可以看作思维链的分支化版本：提示词不只要求模型给出一条线性推理路径，而是要求它先生成多个候选思路，再比较这些思路，最后选择更优的一条继续展开。在不借助框架的前提下，这种策略仍可以通过提示词实现，例如要求模型“先给出三个可能方案，分别说明优缺点，再选择最合理的方案给出最终答案”。</p>
<p>纯提示词版 ToT 的本质，是把“分支生成 + 分支比较 + 继续展开”都压进一次或少数几次模型调用里。它适合开放式规划、方案比较、复杂写作提纲和策略搜索这类任务，因为这类任务往往不存在唯一直接路径。代价同样明显：提示词更长，输出更长，模型也更容易在分支之间漂移。因此，ToT 更适合高价值、需要比较多个候选方案的任务，而不适合作为所有请求的默认模式。</p>
<div class="blog_h1"><span class="graybg">Harness Engineering</span></div>
<p>Harness Engineering（驾驭工程）研究的是：当模型推理与生成能力已经足够强时，决定 Agent 能否稳定完成复杂任务的，越来越不是模型权重本身，而是模型外围的整套工作系统。Harness 指模型之外的所有代码、配置与执行逻辑——工具接口、状态管理、上下文装配、架构约束、验证机制、回滚策略与持续清理，全部属于这一层。裸模型只能接收输入、输出文本；只有当 Harness 为它提供状态、工具、约束和反馈回路后，它才能成为一个持续干活的 Agent。</p>
<p>目前 Harness Engineering 落地最广、讨论最充分的场景仍然是 Coding Agent，因此本章中的很多方法首先来自代码生成、调试、验证、跨会话交接和多 Agent 编排的工程实践。但它并不局限于写代码。凡是 Agent 需要长期持有状态、调用外部工具、在约束下执行任务、接受反馈并持续纠偏的场景，都可以应用同样的方法论，例如研究 Agent、数据分析 Agent、运维自动化 Agent、业务流程 Agent 以及多模态交互系统。随着 Agent 从“生成回答”走向“持续执行任务”，Harness Engineering 也会从 Coding Agent 的经验集合，扩展为更一般的 Agent 系统工程。</p>
<p>下文将沿着“为什么 Agent 会失效 ➡ 如何搭建控制面 ➡ 如何长期治理系统 ➡ 工程师角色如何变化”这条主线展开。</p>
<div class="blog_h2"><span class="graybg">三层模型和五大支柱</span></div>
<p>Prompt Engineering（提示词工程）、Context Engineering（上下文工程）与 Harness Engineering 不是相互替代的关系，而是层层外扩的系统模型：前者决定如何表达任务，中间层决定模型能看到什么，最外层决定整个系统如何执行、纠偏与长期维持质量。</p>
<div class="blog_h3"><span class="graybg">三层模型</span></div>
<div class="blog_h4"><span class="graybg">提示词工程</span></div>
<p>提示词工程回答的是“如何把任务说清楚”。它关注指令措辞、角色设定、Few-shot 示例、输出格式和推理引导，目标是在单次调用里把任务表达得足够明确，让模型更容易走向期望行为。</p>
<div class="blog_h4"><span class="graybg">上下文工程</span></div>
<p>上下文工程回答的是“模型应该看到什么”。它关注记忆注入、检索回填、窗口压缩、状态摘要和工具返回整理，目标是控制输入信息的相关性与密度，使模型在有限上下文里获得完成当前任务所需的关键材料。</p>
<div class="blog_h4"><span class="graybg">Harness Engineering</span></div>
<p>Harness Engineering 回答的是“模型在什么系统里工作”。它处理的不再只是输入文本，而是工具接口、状态持久化、架构边界、验证回路、错误恢复、执行顺序与持续清理。到了这一层，关注点已经从“如何让模型回答得更好”转向“如何让 Agent 在真实系统中稳定完成工作”。</p>
<p>Harness 并不等同于某一句系统提示，也不等同于某个框架的名称。框架（Framework）、运行时（Runtime）和工具链都可以参与实现 Harness，但 Harness 这个词强调的是<span style="background-color: #c0c0c0;">模型外围整套工作系统</span>，而不是其中任何一个单独组件。</p>
<div class="blog_h3"><span class="graybg">五大支柱</span></div>
<p>从系统设计视角，一个可用的 harness 通常由五类承重件组成：上下文管理（Context Management）、工具编排（Tool Orchestration）、安全护栏（Safety Guardrails）、反馈回路（Feedback Loops）与可读性/可观测性（Legibility / Observability）。前两者解决“Agent 知道什么、能做什么”，中间两者解决“何时纠偏、如何避免越界”，最后一类解决“系统当前到底发生了什么”。</p>
<div class="blog_h4"><span class="graybg">上下文管理</span></div>
<p>上下文管理决定 Agent 在每一步究竟能看到什么信息。它的目标不是堆砌更多材料，而是把系统提示、短期记忆、长期知识、工具返回和任务状态按层组织，让模型始终工作在信息足够但不过载的区间。</p>
<div class="blog_h4"><span class="graybg">工具编排</span></div>
<p>工具编排决定 Agent 能做什么，以及如何把动作接回推理链路。搜索、数据库、代码执行器、浏览器自动化和业务 API 只有在权限、输入输出格式、调用顺序与结果回填都被设计清楚后，才会从“外挂能力”变成稳定的执行系统。</p>
<div class="blog_h4"><span class="graybg">安全护栏</span></div>
<p>安全护栏负责限制 Agent 的越界空间。它包括写权限边界、架构约束、审批节点、沙箱、敏感操作限制与结构化状态约束，作用是把“模型可能做错事”转化为“系统不允许它在关键位置随意犯错”。</p>
<div class="blog_h4"><span class="graybg">反馈回路</span></div>
<p>反馈回路负责告诉 Agent 当前结果究竟对不对。测试、lint、evaluator、Sprint Contract、健康检查和人工验收都属于这一层。没有反馈回路，Agent 只能凭语言流畅性误判成功；有了反馈回路，系统才能把错误重新送回执行链路中修复。</p>
<div class="blog_h4"><span class="graybg">可读性与可观测性</span></div>
<p>可读性与可观测性负责让 Agent 看见系统真实状态。UI、日志、指标、追踪、截图、DOM 快照和运行时事件，都是 Agent 判断“系统发生了什么”的依据。若系统对 Agent 不可见，很多问题即使存在，也只能靠盲目试错去碰。</p>
<p>Harness 的成熟度，本质上就是这五类控制面被工程化到什么程度。后文的各个部分，基本都可以看作对这五类支柱的展开：先看 Agent 为什么会系统性失效，再分别讨论上下文、编排、知识、约束、可读性、验证与熵控制如何把这些失效压回系统边界之内。</p>
<div class="blog_h2"><span class="graybg">长运行 Agent 的系统性失效</span></div>
<p>长运行 Agent（Long-Running Agent）必须跨多个上下文窗口持续工作，每个新会话在启动时对前一会话发生的事情没有任何记忆。即使使用最强的前沿模型，如果只给一个高级别的提示词，Agent 也无法稳定构建出生产质量的复杂系统。实践观察到四类典型失效：</p>
<ul>
<li><span style="background-color: #c0c0c0;">过早宣告完成（Premature Victory Declaration）</span>：在项目进行到一定阶段后，Agent 环视已完成的工作，宣告整个项目已经完成，忽略仍未实现的功能。</li>
<li><span style="background-color: #c0c0c0;">脏状态遗留（Dirty State Handoff）</span>：会话结束时遗留未修复的 bug 或未记录的进度，下一个会话必须先花大量 token 恢复工作环境，而不是推进新功能。</li>
<li><span style="background-color: #c0c0c0;">伪完成标记（False Completion）</span>：Agent 完成代码改动并运行单元测试或 curl 命令后，在没有进行端到端验证的情况下把功能标记为已完成，而该功能实际上并不能正常工作。</li>
<li><span style="background-color: #c0c0c0;">上下文焦虑（Context Anxiety）</span>：部分模型在上下文窗口填充到一定程度后，会主动收尾、提前结束任务——即便任务尚未完成，这种过早的"善后行为"会导致中途截断的工作状态。</li>
</ul>
<p>这四类失效都不能靠"换一个更好的模型"自动解决。它们是系统性问题，需要 Harness 层面的工程设计来对抗。</p>
<div class="blog_h3"><span class="graybg">失败驱动的 Harness 演化</span></div>
<p>Harness 不是一次性搭好的脚手架，而是通过失败持续生长的控制层。一个重要的工程判断是：当 Agent 在某类任务上反复失败时，正确反应通常不是先归因于“模型不够强”，而是把失败当作 Harness 缺口的定位信号。失败可能意味着工具接口缺失、文档不可达、架构边界不清、状态交接不足，或验收标准仍停留在模糊自然语言层面。</p>
<p>因此，Harness Engineering 的关键动作不是反复重试同一个 prompt，而是把失败外化成新的系统组件：增加脚本、补充文档、收紧 lint 规则、拆出更明确的 Contract、补上 evaluator 或可观测性接入。每次失败若都能回写为新的约束与支撑物，Agent 系统就会逐步从“靠经验驱动”演化为“靠机制驱动”。</p>
<div class="blog_h2"><span class="graybg">上下文管理策略</span></div>
<div class="blog_h3"><span class="graybg">上下文焦虑与两种应对策略</span></div>
<p>上下文焦虑（Context Anxiety）是指模型在感知到上下文窗口即将耗尽时，主动把任务包装成"已完成"状态——即便还有大量工作未做。Anthropic 在 Claude Sonnet 4.5 上观察到这一现象尤为明显。</p>
<p>应对上下文耗尽有两种互相对立的策略：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">策略</td>
<td style="text-align: center;">机制</td>
<td style="text-align: center;">保留连续性</td>
<td style="text-align: center;">消除上下文焦虑</td>
<td style="text-align: center;">代价</td>
</tr>
</thead>
<tbody>
<tr>
<td>压缩（Compaction）</td>
<td>将早期对话摘要替换原文，同一 Agent 继续运行</td>
<td>是</td>
<td>否（焦虑可能持续）</td>
<td>摘要信息损失</td>
</tr>
<tr>
<td>重置（Context Reset）</td>
<td>清空上下文，新 Agent 从结构化交接物（Handoff Artifact）重启</td>
<td>否（全新起点）</td>
<td>是</td>
<td>需要构造高质量交接物；增加编排复杂度与延迟</td>
</tr>
</tbody>
</table>
<p>对于 Sonnet 4.5，仅靠 Compaction 不足以支撑长任务，Context Reset 成为 Harness 设计的必要组件。随着 Opus 4.6 的推出，其长上下文检索能力显著提升、上下文焦虑基本消除，Context Reset 可以从 Harness 中移除——这正是 Harness 组件应随模型能力动态调整的典型示例。</p>
<p>一个很有价值的经验规律是：上下文窗口并不是“越满越好”。多组实践都观察到，随着无关文档、工具说明和历史对话不断堆积，Agent 的推理质量会先升后降。工程上可以把这理解为上下文利用率的“甜蜜区间”：模型需要足够信息才能稳定工作，但一旦被冗余材料淹没，就会出现幻觉、循环、格式错误或低质量实现。Harness 的任务不是把更多 token 塞进去，而是让 Agent 在任意时刻只看到当前步骤真正需要的信息。</p>
<div class="blog_h3"><span class="graybg">跨会话状态传递</span></div>
<p>长运行 Agent 在会话之间传递状态，不能依赖模型的内部记忆，必须依靠外化的持久化制品（Persistent Artifacts）。实践中形成了一套固定的状态传递套件：</p>
<ul>
<li><span style="background-color: #c0c0c0;">进度文件（Progress File）</span>：纯文本日志，记录每个会话完成了什么、遇到了什么问题、下一步是什么。每个会话开始时读取，结束时更新。</li>
<li><span style="background-color: #c0c0c0;">功能列表文件（Feature List）</span>：结构化的功能需求清单，初始全部标记为未完成，Agent 逐项实现并通过测试后方可标记通过。使用 JSON 而非 Markdown——实验发现模型不当覆写或修改 JSON 文件的概率远低于 Markdown 文件。</li>
<li><span style="background-color: #c0c0c0;">启动脚本（init.sh）</span>：由初始化代理预先写好的环境恢复入口，负责安装依赖、启动必要服务并打印访问入口。后续 fresh session 不再重新猜测项目如何运行，而是把它作为标准恢复脚本，在需要时检查并调用。</li>
<li><span style="background-color: #c0c0c0;">Git 历史</span>：每个会话以描述性的 commit message 结束。Git 历史既是进度时间线，也是恢复机制——Agent 可以通过 <pre class="crayon-plain-tag">git revert</pre> 从错误改动中恢复。</li>
</ul>
<p>这套设计借鉴的是人类软件工程师的轮班交接实践：进度文件对应交班笔记，git commit 对应有记录的工作移交，init.sh 对应标准化的环境搭建步骤，功能列表对应待办看板。</p>
<p>当单个 Agent 已经能够跨会话持续推进后，下一个问题就不再是“如何记住过去”，而是“如何把复杂任务拆给更合适的执行者”。这正是多 Agent 架构出现的背景。</p>
<div class="blog_h2"><span class="graybg">多 Agent 架构模式</span></div>
<div class="blog_h3"><span class="graybg">GAN 启发的 Generator–Evaluator 结构</span></div>
<p>让单个 Agent 对自己的输出进行评估会产生<span style="background-color: #c0c0c0;">自评偏差（Self-Evaluation Bias）</span>：即使输出质量明显一般，模型也倾向于给出积极评价。这一问题在主观任务上尤为突出，在可验证任务上同样存在。</p>
<p>一个有效的应对结构来自生成对抗网络（Generative Adversarial Network, GAN）的启发：将生成者和评估者分离成两个独立 Agent。</p>
<pre class="crayon-plain-tag">[Generator Agent] &larr;── feedback ──[Evaluator Agent]
        │                                 │
        └──────── output artifact ────────┘</pre>
<p>关键发现：调教一个独立的评估者使其保持怀疑态度，远比让生成者对自己的输出保持批判性更为可行。评估者仍然是 LLM，仍然有宽容倾向，但针对评估者进行专项提示调优的效果可以稳定收敛，而自评调优往往无效。</p>
<div class="blog_h3"><span class="graybg">Planner–Generator–Evaluator 三 Agent 架构</span></div>
<p>在 Generator–Evaluator 基础上增加一个 Planner Agent，构成完整的三 Agent 架构：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center; width: 15%;">Agent</td>
<td style="text-align: center; width: 20%;">输入</td>
<td style="text-align: center; width: 20%;">输出</td>
<td style="text-align: center;">设计要点</td>
</tr>
</thead>
<tbody>
<tr>
<td>Planner</td>
<td>1–4 句用户提示</td>
<td>完整产品规格书</td>
<td>只关注产品上下文与高层技术设计，不指定实现细节（过度具体的规格会将错误级联到下游）；被提示在规格中寻找机会融入 AI 功能</td>
</tr>
<tr>
<td>Generator</td>
<td>产品规格 + Sprint Contract</td>
<td>可运行代码</td>
<td>按 Sprint 增量构建（早期模型）或持续运行（强模型）；完成 Sprint 后先自评，再交 Evaluator 审查；拥有版本控制权限</td>
</tr>
<tr>
<td>Evaluator</td>
<td>运行中的应用 + Sprint Contract 验收标准</td>
<td>通过/失败判定 + 具体可行的问题报告</td>
<td>通过 Playwright MCP 实际操作运行中的应用；设有硬性阈值，任一标准未达到则 Sprint 失败并返回修复</td>
</tr>
</tbody>
</table>
<p>Agent 之间的通信完全基于文件：一个 Agent 写文件，另一个读文件并响应。这种方式简单、可追溯、不依赖框架内部状态。</p>
<div class="blog_h3"><span class="graybg">初始化 Agent 与编码 Agent 的分离</span></div>
<p>对于需要跨多个上下文窗口持续工作的场景，一种实用模式是将第一次会话与后续会话用不同的提示驱动：</p>
<ul>
<li><span style="background-color: #c0c0c0;">初始化 Agent（Initializer Agent）</span>：仅在第一个会话运行，负责构建整套脚手架：根据项目规格生成 init.sh、功能列表 JSON、初始 git commit 和进度文件，把“项目如何启动、功能如何追踪、状态如何恢复”一次性外化成可执行制品。</li>
<li><span style="background-color: #c0c0c0;">编码 Agent（Coding Agent）</span>：从第二个会话起运行，先读取进度文件、功能列表和最近的 git 历史，再按需调用 init.sh 恢复环境，每次只实现一个功能，并以"干净状态（Clean State）"结束会话。</li>
</ul>
<p>两个"Agent"在技术上是同一套系统提示和工具集，区别仅在于初始用户提示不同——这是一种提示策略，而非架构分离。</p>
<p>这种设计的关键，不是简单多了一个 shell 脚本，而是把“如何把项目跑起来”从高 token 成本的推断问题，转成低成本、可重复、可审计的脚本执行问题。后续每个 fresh session 的标准启动序列因此可以稳定下来：先用 <pre class="crayon-plain-tag">pwd</pre> 与最近的 <pre class="crayon-plain-tag">git log</pre> 确认当前工作状态，读取进度文件和功能列表定位下一项任务，再检查并在需要时调用 <pre class="crayon-plain-tag">init.sh</pre> 恢复依赖、服务和访问入口。模型不再反复猜测包管理器、启动顺序、端口和运行命令，上下文预算因此可以集中到真正的实现与验证上。</p>
<div class="blog_h3"><span class="graybg">Sprint Contract：预协议的完成定义</span></div>
<p>Sprint Contract 是 Generator 与 Evaluator 在任何代码被写下之前，对"这一阶段完成的标准"达成共识的协议制品：Generator 提出将要构建什么以及如何验证成功，Evaluator 审查并确认，双方迭代直到达成一致。其目的是弥合高层用户故事与可测试实现之间的鸿沟，防止模糊需求在实现阶段演变为争议。</p>
<p>任务拆分解决了“谁来做”，但还没有解决“知识从哪里来、如何保持最新”。一旦 Agent 工作跨越多个会话、多个角色和多个代码域，知识组织方式本身就会成为 Harness 的一部分。</p>
<div class="blog_h2"><span class="graybg">知识库工程（Repository as System of Record）</span></div>
<div class="blog_h3"><span class="graybg">给 Agent 地图而非手册</span></div>
<p>在大型、持续演化的代码库中，上下文管理的首要挑战不是"如何塞入更多信息"，而是"如何让 Agent 在需要时找到正确信息"。"一个巨型 AGENTS.md 文件"的方案会失败，原因是多方面的：</p>
<ul>
<li>上下文是稀缺资源。一个巨大的指令文件会挤占任务本身、代码和相关文档的空间，导致 Agent 要么遗漏关键约束，要么对错误的目标进行优化。</li>
<li>过多的指导等于没有指导。当所有内容都被标记为"重要"时，Agent 会退化成局部模式匹配，而不是有目的地导航。</li>
<li>单体文档会迅速腐烂。人类停止维护，Agent 无法分辨哪些规则仍然有效，文件变成一个充满过期规则的吸引力陷阱。</li>
</ul>
<p>有效的替代方案是：<span style="background-color: #c0c0c0;">AGENTS.md 充当目录（Table of Contents），而非百科全书</span>。一份约 100 行的精简 AGENTS.md 作为上下文的入口，通过指针将 Agent 引导至结构化 docs/ 目录中的具体知识源。</p>
<p>AGENTS.md 还有一个更重要的角色：它不应是一次写完后长期冻结的静态文档，而应是失败驱动的活反馈循环。每当 Agent 因为命令使用错误、目录理解偏差、架构约束遗漏或工具接入方式不清而出错，最直接的修复方式往往就是把这类经验回写进 AGENTS.md 或其指向文档。这样，文档不再只是说明书，而是把历史失败压缩成未来会话可直接继承的系统记忆。</p>
<pre class="crayon-plain-tag">AGENTS.md            &larr; 约100行，地图角色
ARCHITECTURE.md
docs/
├── design-docs/
│   ├── index.md
│   └── core-beliefs.md
├── exec-plans/
│   ├── active/
│   └── completed/
├── generated/
│   └── db-schema.md
├── product-specs/
│   └── index.md
├── references/
│   ├── design-system-reference-llms.txt
│   └── ...
├── DESIGN.md
├── FRONTEND.md
├── PLANS.md
└── QUALITY_SCORE.md</pre>
<p>这种结构实现了渐进披露（Progressive Disclosure）：Agent 从一个小而稳定的入口开始，被引导到它需要的具体知识，而不是在启动时被所有信息淹没。</p>
<div class="blog_h3"><span class="graybg">组织级 Golden Path</span></div>
<p>当团队反复构建相似类型的系统时，Harness 不应只停留在单项目经验，而应上升为组织级的服务模板（Service Template）或黄金路径（Golden Path）。现实中的软件形态并不是无限多样的，常见项目通常集中在少数几类技术拓扑：前端应用、后端服务、数据流水线、内部工具。若能把每类拓扑常用的目录结构、启动脚本、验证流水线、可观测性接入、架构约束和 Agent 指令打包成模板，新项目就不必从零设计 Harness。</p>
<p>这类模板的价值不只是“脚手架复用”，更在于组织把高频工程判断沉淀成标准化控制面。团队成员在真实项目中学到的约束、回滚经验和验证方法，可以持续回流到模板；模板更新后，又会反过来提升后续所有项目的默认质量。Harness 一旦进入这个阶段，便不再只是某个工程师的个人技巧，而是组织级生产力资产。</p>
<div class="blog_h3"><span class="graybg">Agent 可见性边界</span></div>
<p>从 Agent 的视角来看，任何它在运行时无法在上下文中访问的知识，实际上不存在。存在于 Google Docs、Slack 线程或人脑中的决策，对 Agent 来说与从未发生过没有区别。唯一对 Agent 有效的知识是代码库中版本化的制品：代码、Markdown、Schema、可执行计划。</p>
<p>这一约束倒逼团队把越来越多的上下文推入代码库：对齐团队架构决策的 Slack 讨论、产品原则、工程规范，都需要以 Agent 可读的形式在仓库中存在。这不仅是文档实践，更是系统设计约束。</p>
<div class="blog_h3"><span class="graybg">机械化知识维护</span></div>
<p>知识库的有效性需要机械化强制执行，而不能依靠人工自律。专用的 linter 和 CI 任务验证知识库是否最新、交叉引用是否完整、结构是否正确。一个周期性运行的"文档园丁 Agent（doc-gardening agent）"扫描文档与实际代码行为之间的偏差，发现过期或不一致的内容后自动开启修复 Pull Request。</p>
<p>知识工程解决的是“找得到信息”，架构约束工程解决的是“即使知道该做什么，也不能随意乱做”。两者结合，Agent 才既有方向感，也有边界感。</p>
<div class="blog_h2"><span class="graybg">架构约束工程</span></div>
<div class="blog_h3"><span class="graybg">分层领域架构</span></div>
<p>Agent 在具有严格边界和可预测结构的环境中工作效果最好。在全 Agent 生成的代码库中，这一原则需要在工程层面提前落地：等到有数百名工程师时再引入架构规则，通常已经太晚了——而在 Agent 驱动的代码库中，混乱会以比人类团队快得多的速度蔓延。</p>
<p>一种行之有效的结构是<span style="background-color: #c0c0c0;">分层领域架构（Layered Domain Architecture）</span>：每个业务域被划分为固定的层集合，依赖方向被严格验证，仅允许有限的跨层边。例如，在一个业务域内部，代码只能沿固定顺序向前依赖（Types → Config → Repo → Service → Runtime → UI）；跨切面关注点（认证、连接器、遥测、特性标志）通过单一显式接口进入。其他所有依赖方向都被机械地禁止。</p>
<div class="blog_h3"><span class="graybg">机械化强制执行</span></div>
<p>架构规则通过定制 linter 和结构测试强制执行，而不是依赖文档和代码审查中的人工判断。关键实践：</p>
<ul>
<li>linter 的错误信息被设计成直接包含修复指令，从而将约束违反转化为 Agent 可消费的上下文。</li>
<li>在对人类团队而言显得迂腐的细粒度规则，对 Agent 来说是乘数效应：一旦编码，立即在所有代码上生效，无需逐一审查。</li>
<li>约束划定边界，边界内部的实现方式允许 Agent 自由选择——这类似于大型平台工程组织的管理模式：集中强制边界，局部授权自治。</li>
</ul>
<p>这套约束还包括"品味不变量（Taste Invariants）"：结构化日志、Schema 与类型命名规范、文件大小限制、平台特定的可靠性要求。这些规则用静态分析强制执行，将人类工程师的审美判断固化成可机器检查的规则。</p>
<div class="blog_h3"><span class="graybg">结构化状态与写权限约束</span></div>
<p>对 Agent 可以修改的内容施加精确的写权限，是防止状态腐蚀的重要手段。典型模式：功能列表文件中，Agent 只允许修改 <pre class="crayon-plain-tag">passes</pre> 字段，不得删除或改写任何测试条目。这种狭义写权限用强措辞的指令约束来传达："删除或编辑测试是不可接受的行为，这会导致功能遗漏或引入 bug。"</p>
<p>此外，在数据边界处解析数据形状（而不是 YOLO 式地推断数据结构），使用类型化 SDK 或 Schema 验证库，是保持 Agent 可以安全推理的代码库形态的基础约束。</p>
<p>边界清晰之后，系统仍然需要对 Agent “可见”。否则即使约束被写得很严，Agent 也只能在黑箱里试错，无法高效定位运行时问题。</p>
<div class="blog_h2"><span class="graybg">Agent 可读性（Legibility）</span></div>
<p>随着代码吞吐量提升，人工 QA 容量成为瓶颈。解决路径不是增加人力，而是让应用自身的 UI、日志和指标对 Agent 直接可读，使 Agent 能够自主复现 bug、验证修复并对应用行为进行推理。</p>
<div class="blog_h3"><span class="graybg">应用可读性</span></div>
<p>使应用对 Agent 可读的关键手段：</p>
<ul>
<li><span style="background-color: #c0c0c0;">每个 git worktree 独立启动一个应用实例</span>：Agent 可以针对自己的变更启动隔离的应用版本，互不干扰。</li>
<li><span style="background-color: #c0c0c0;">Chrome DevTools Protocol（CDP）接入</span>：将 CDP 接入 Agent 运行时，并封装操作 DOM 快照、截图和页面导航的技能，使 Agent 能够驱动浏览器、复现 bug 并验证修复，而不依赖人工截图传递。</li>
<li><span style="background-color: #c0c0c0;">Playwright/Puppeteer MCP</span>：在多 Agent 架构中，评估 Agent 使用浏览器自动化工具实际操作运行中的应用，而不是静态分析代码。这使得"应用看起来工作"与"应用实际可用"之间的差距得以被发现。需注意：浏览器原生弹窗（alert modal）对这类工具不可见，这是已知盲区。</li>
</ul>
<div class="blog_h3"><span class="graybg">可观测性栈</span></div>
<p>将完整的可观测性栈暴露给 Agent，使其可以查询日志、指标和追踪数据，是将模糊性能目标转化为 Agent 可执行任务的关键。每个 worktree 配备临时的本地可观测性栈（完成后自动销毁），Agent 可通过 LogQL 查询日志、PromQL 查询指标、TraceQL 查询链路追踪。</p>
<p>有了这种接入能力，"确保服务启动在 800ms 以内完成"或"这四条关键用户路径中没有 span 超过两秒"这类提示就变得可执行——Agent 可以直接查询数据、定位问题根源并实施修复，而不需要人工解读日志然后描述问题。</p>
<p>不过，看得见系统状态还不等于能正确判断任务是否完成。可读性回答的是“发生了什么”，验证闭环回答的是“这样是否算成功”。</p>
<div class="blog_h2"><span class="graybg">验证闭环</span></div>
<div class="blog_h3"><span class="graybg">主观评判的量化</span></div>
<p>对于没有二元正确性检验的任务（如界面设计、用户体验），需要将主观判断转化为具体的、可评分的标准，才能构建有效的验证闭环。一套已验证有效的前端设计评分框架包含四个维度：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">维度</td>
<td style="text-align: center;">评估内容</td>
<td style="text-align: center;">权重</td>
</tr>
</thead>
<tbody>
<tr>
<td>Design Quality（设计质量）</td>
<td>配色、字体、布局、视觉意象是否融合为一个有清晰气质和独特身份的整体，而非各部分的简单堆砌</td>
<td>高</td>
</tr>
<tr>
<td>Originality（原创性）</td>
<td>设计决策是否经过定制，还是使用了模板布局、库默认值、AI 生成惯例模式（紫色渐变白色卡片、未经修改的 stock 组件）</td>
<td>高</td>
</tr>
<tr>
<td>Craft（工艺）</td>
<td>字体层级、间距一致性、颜色对比度等技术执行质量</td>
<td>低（模型默认已做得较好）</td>
</tr>
<tr>
<td>Functionality（功能性）</td>
<td>用户能否理解界面功能、找到主要操作、完成任务而无需猜测</td>
<td>低（模型默认已做得较好）</td>
</tr>
</tbody>
</table>
<p>重要发现：评估者提示中的措辞本身（如"最好的设计是博物馆级别的"）会直接影响生成器的输出风格——不仅仅在评估反馈阶段生效，在第一次生成时就已经开始引导模型远离通用默认值。评估者需通过 Few-shot 示例加详细评分解析进行校准，以保持判断一致性并减少分数漂移。</p>
<div class="blog_h3"><span class="graybg">端到端测试与健康检查门控</span></div>
<p>单元测试和 curl 命令验证的是代码片段，而不是用户体验。端到端测试的原则是：<span style="background-color: #c0c0c0;">像人类用户一样测试</span>——通过浏览器自动化工具实际操作运行中的应用，才能发现"代码逻辑看似正确但功能整体不可用"的问题。</p>
<p>实施健康检查门控（Health-Check Gate）：在每个会话开始时，Agent 必须先运行基础端到端功能测试，确认应用处于正常工作状态，再开始实现新功能。如果发现应用处于损坏状态，应先修复，否则新功能会在损坏的基础上叠加，问题会更难追溯。</p>
<p>干净状态（Clean State）的定义：每个会话结束时，代码库应处于可合并到主分支的状态——无重大 bug，代码有序且有文档，后续开发者（或 Agent）可以直接开始新功能，而无需先清理遗留的乱局。</p>
<div class="blog_h3"><span class="graybg">功能验证缺口</span></div>
<p>Harness 案例里常见一个容易被忽略的盲点：系统也许已经很好地约束了内部质量，却未必充分验证了外部行为是否真的符合产品意图。目录结构、架构 lint、单元测试、Clean State 和持续回收都很重要，但这些机制首先保证的是内部一致性，而不是最终功能一定可用。</p>
<p>因此，成熟的 Harness 必须把<span style="background-color: #c0c0c0;">功能验证（Functionality Verification）</span>视为独立承重件，而不是默认附属物。真正可靠的系统需要对“用户是否真的能完成任务”建立单独的验证回路，例如任务级验收标准、浏览器自动化、独立评估 Agent、人工 spot check，或基于运行时信号的行为检查。能持续写代码只是起点；能持续验证行为，才是 Harness 真正闭环的标志。</p>
<p>即使单次任务已经具备了约束、可读性和验证，长期运行后的系统仍会累积漂移。Harness 因此不仅要管每一轮执行是否正确，还要管整个代码库是否在时间尺度上持续失真。</p>
<div class="blog_h2"><span class="graybg">熵控制与垃圾回收</span></div>
<p>Agent 会复制代码库中已存在的模式——即使这些模式并不理想。随着时间推移，全 Agent 生成的代码库会发生不可避免的漂移：重复工具函数扩散、文档与代码行为不同步、架构规则被逐渐侵蚀。早期的应对方式是每周花费 20% 的团队时间手动清理，但这完全不可扩展。</p>
<p>有效的替代方案是将"黄金原则（Golden Principles）"直接编码进代码库，并建立周期性的自动化垃圾回收流程：</p>
<ul>
<li>在代码库中明确记录有主见的、机械可检查的原则（如：优先使用共享工具包而非手写 helper；必须在边界处解析数据形状，不得基于猜测的结构构建逻辑）。</li>
<li>周期性运行后台 Agent 任务，扫描原则违反、更新质量评分、开启针对性重构 Pull Request。</li>
<li>大多数这类 PR 可以在一分钟内完成审查并自动合并。</li>
</ul>
<p>技术债务与高息贷款相似：持续小额偿还几乎总是优于积累后集中清算。人类工程师的品味判断被捕获一次，随后在每一行代码上持续强制执行——这是 Agent 驱动开发模式下特有的杠杆效应。</p>
<p>但这些组件本身并不是一成不变的。任何垃圾回收策略、验证门控或多 Agent 编排，背后都隐含着对当前模型能力的判断；一旦模型变化，Harness 的承重结构也需要重新评估。</p>
<div class="blog_h2"><span class="graybg">Harness 随模型演化（Load-Bearing Analysis）</span></div>
<p>Harness 中的每一个组件都编码了一个假设：模型目前无法独立完成这件事，所以需要这层脚手架。这些假设有两个失效原因：</p>
<ul>
<li>假设从一开始就是错的——模型其实能做到，但工程师没有测试验证就加了脚手架。</li>
<li>假设因模型能力提升而过时——昨天需要 Sprint 分解的任务，今天的模型可以持续完成而不失去连贯性。</li>
</ul>
<p>因此，评估哪些 Harness 组件是真正的<span style="background-color: #c0c0c0;">承重结构（Load-Bearing Components）</span>——每当新模型发布时，剥除不再有效的部分，添加利用新能力的新部分——是 Harness 工程师持续的工作职责。</p>
<p>一个典型的演化轨迹：Claude Sonnet 4.5 需要 Context Reset + Sprint 分解 + 完整三 Agent 架构；Opus 4.5 在三 Agent 架构下表现更好，Sprint 结构依然必要；Opus 4.6 消除了上下文焦虑，Generator 可以持续运行超过两小时而无需 Sprint 分解，Evaluator 从"始终必要"变为"在任务超出模型原生能力时才有价值"。这套 Harness 对于 Sonnet 4.5 来说恰到好处，对于 Opus 4.6 来说则是过度设计。</p>
<p>一个重要的元原则：<span style="background-color: #c0c0c0;">随着模型能力提升，有趣的 Harness 组合空间不会缩小，而是移动</span>。工程师的工作不是等待模型强大到不需要 Harness，而是持续找到下一个新颖的组合。</p>
<p>一旦系统的主要难点从“写出代码”转移到“设计并维护这套控制面”，工程师的职责也会随之改变。</p>
<div class="blog_h2"><span class="graybg">工程师角色的演变</span></div>
<p>在 Harness Engineering 语境下，工程师的工作重心发生了系统性上移。原本直接产出代码的工作，转变为设计 Agent 工作的环境、指定意图与验收标准、构建使 Agent 能可靠工作的反馈闭环，以及将人类判断和品味持续编码进系统。</p>
<p>核心原则可以概括为一句话：<span style="background-color: #c0c0c0;">Humans steer, agents execute</span>。这不是人类退出工程流程，而是人类在更高的抽象层次上工作：确定优先级、将用户反馈转化为验收标准、验证结果。当 Agent 遇到障碍时，工程师的响应不是"再试一次"，而是"缺少什么能力？如何让这个需求变得对 Agent 可理解且可强制执行？"</p>
<p>随着代码吞吐量的提升，许多传统工程规范变得适得其反。PR 在高吞吐系统中应该是短命的；测试 flakiness 有时更适合通过后续补丁修复而非阻塞合并；纠错的成本极低，等待的成本极高。这与低吞吐人工团队的工作假设完全相反。</p>
<p>不过，这些方法目前验证得最充分的，仍然主要是绿地项目或受控实验环境。一旦进入历史包袱深重的老系统，Harness 的建设顺序与成本结构都会发生变化。</p>
<div class="blog_h2"><span class="graybg">棕地改造的难题</span></div>
<p>当前公开的成功 Harness 案例大多发生在绿地项目、受控实验或可以从零搭脚手架的环境中。真正更难的问题，是如何把 Harness Engineering 引入一个已经运行多年、缺少架构边界、测试质量不稳定、文档残缺且历史包袱沉重的棕地代码库。这里最大的风险不是 Agent 不会写代码，而是现有系统本身已经缺乏足够清晰的控制面，Agent 一旦接手，只会更快复制并放大原有混乱。</p>
<p>因此，棕地改造不能照搬绿地模板，通常需要渐进式引入：先补最关键的边界约束，再建立最小可用的知识入口、验证门控和可观测性接入，最后才考虑多 Agent 编排或更高自治级别。换言之，Harness 在棕地场景里的首要任务不是提升速度，而是先把代码库变成一个对 Agent 可理解、可约束、可验证的环境。</p>
<div class="blog_h2"><span class="graybg">参考案例</span></div>
<p>OpenAI Codex 内部实验（2026 年 2 月）：三名工程师在约五个月内，通过零人工手写代码的流程，构建了一个拥有内部日常用户和外部 alpha 测试用户的内部 beta 产品。代码库规模约一百万行，覆盖应用逻辑、测试、CI 配置、文档、可观测性和内部工具。约 1500 个 PR 被合并，平均吞吐为每名工程师每天 3.5 个 PR，且随团队扩张吞吐还在提升。核心投入集中在：精简的 AGENTS.md 目录结构、分层领域架构 + 机械化 linter、应用 + 可观测性对 Agent 的完整可读性、黄金原则与周期性垃圾回收 Agent。</p>
<p>Anthropic 复古游戏编辑器对比实验（2026 年）：使用 Claude Opus 4.5，对同一个 prompt "构建一个 2D 复古游戏制作工具"进行两种模式的比较。单 Agent 单次运行：20 分钟，花费 9 美元，结果是布局浪费空间、核心玩法无法运行。完整三 Agent Harness（Planner + Generator + Evaluator，含 Sprint Contract）：6 小时，花费 200 美元，结果是 Planner 将一句 prompt 扩展为横跨 10 个 Sprint 的 16 项功能规格，游戏核心机制正常运行，并额外实现了精灵动画、AI 辅助关卡设计等超出原始 prompt 的功能。成本约为单次运行的 22 倍，但输出质量远超。</p>
<p>Anthropic 浏览器 DAW 实验（2026 年）：使用 Claude Opus 4.6（已移除 Sprint 分解），通过 prompt "构建一个基于 Web Audio API 的全功能浏览器 DAW"，三轮构建 + QA 循环总计 3 小时 50 分钟，花费 124.70 美元。最终产物包含可用的编排视图、混音台和传输控制；内置 AI Agent 可通过自然语言提示设置节拍/调性、演奏旋律、建立鼓轨、调节混音和添加混响，完成一段完整的短曲创作。</p>
<div class="blog_h1"><span class="graybg">检索增强生成（RAG）</span></div>
<p>检索增强生成（Retrieval-Augmented Generation, RAG）把“外部知识”通过检索在推理时注入到上下文里：先检索，再生成。它解决的不是模型能力问题，而是信息可得性与可控性问题：把知识放在可更新的外部存储里，而不是指望模型参数记住一切。</p>
<p>真正落地到生产后，RAG 很快会从“模型 + 向量库”的玩具结构，变成一套完整的知识处理系统。一个更接近现实的抽象是：<span style="background-color: #c0c0c0;">知识源接入、文档获取、分块、增强、索引、召回、融合、重排、上下文回拼、生成</span>。其中任何一步做得粗糙，最终回答质量都会明显退化。</p>
<p>从工程视角看，RAG 的核心不只是“把文本切块后丢进向量数据库”，而是把知识库建成一个可持续演化的外部记忆系统。这里通常至少会区分三层对象：知识库（Base）负责来源配置、召回策略与索引策略；文档（Document）负责内容文件、语言、状态与分块配置；块（Chunk）负责可检索的最小语义单元及其摘要、问题、向量、命中统计等元数据。再往上，很多系统还会加一个目录（Catalog）层，把知识按业务树、产品线、权限域或租户边界组织起来。这种分层的意义在于：<span style="background-color: #c0c0c0;">RAG 检索的不是“一个文本文件夹”，而是一个带治理、带生命周期、带配置继承的知识对象系统</span>。</p>
<div class="blog_h2"><span class="graybg">生产 RAG 的系统视角</span></div>
<p>一个成熟的知识库系统通常会显式区分知识源（Source）与检索目标（Target）。知识源回答“内容从哪里来”，例如上传文件、网页抓取、代码仓库、目录扫描；检索目标回答“内容最终由谁提供召回能力”，可以是系统自建索引，也可以是外部知识平台。这样设计的好处是把“接入来源”与“服务出口”解耦：同一套文档采集和治理流程，可以接到不同的检索后端，而不用把采集逻辑和向量库实现绑死。</p>
<p>另一个关键决策是把知识处理做成异步流水线，而不是同步上传即就绪。现实中的知识库往往需要经历扫描、下载、解析、分块、摘要生成、问题生成、向量化、索引构建等多个阶段。把这些阶段拆开，一方面是因为它们耗时、依赖不同资源且失败模式不同；另一方面是为了支持恢复、重试、并发控制与增量更新。于是，生产 RAG 往往天然带有一个状态机：知识库有自己的状态，文档有自己的状态，块也有自己的状态。这个状态机的作用是把长链路处理过程显式化，使每个阶段都能被观察、重跑和局部修复。</p>
<p>教程中的“上传文档 ➡ embedding ➡ 检索 ➡ 回答”只覆盖了最小闭环。真实系统里的知识库会持续新增、修改、下线、重建索引，并不断调整切分方式和召回参数。因此，RAG 更接近“搜索系统 + 知识处理中台 + 生成模型”的组合，而不是一次性的离线脚本。</p>
<div class="blog_h2"><span class="graybg">分块策略（Chunking）</span></div>
<p>分块（Chunking）决定了“检索的最小单位”。块太大：召回相关信息但携带大量噪声；块太小：召回片段零散，缺上下文导致生成不稳。分块应与文档结构、问题类型、上下文窗口预算一起设计。</p>
<div class="blog_h3"><span class="graybg">固定大小分块</span></div>
<p>固定大小分块（Fixed-size Chunking）按 token/字符数切片，简单稳定，适合结构较弱的纯文本与大规模离线构建。常配合重叠窗口（Overlap）避免跨边界信息断裂。</p>
<div class="blog_h3"><span class="graybg">语义分块</span></div>
<p>语义分块（Semantic Chunking）用段落/标题/语义边界切分，目标是让一个 chunk 自洽（Self-contained）。它通常更适合技术文档与带层级结构的内容，但需要更复杂的解析与规则。</p>
<div class="blog_h3"><span class="graybg">递归分块</span></div>
<p>递归分块（Recursive Chunking）先按大结构切（章节/标题），再对子块继续切（段落/句子/固定大小），兼顾结构与长度约束，是很多工程实现的折中方案。</p>
<p>生产系统里的分块通常远比这三类更细。除了固定大小、递归和语义切分，还会按 Markdown、HTML、PDF、代码、表格、JSON、LaTeX、句子、段落、滑动窗口等内容类型选择不同 chunker。这背后的设计判断非常重要：<span style="background-color: #c0c0c0;">分块不是一个通用算法参数，而是内容理解策略的一部分</span>。代码和 API 文档更适合按语法结构或标题层级切；表格和 PDF 更需要保留版面边界；Markdown/HTML 则需要保留层次结构，甚至形成父子 chunk 树。</p>
<p>这类设计会直接改变召回质量。若把所有内容都按固定长度切块，标题、层级、页面边界、代码块和表格结构都会被抹平；若系统保存 chunk 的父子关系、边界分隔符、正文起止位置、重叠前后文与结构元数据，那么检索命中的就不再只是孤立片段，而是文档结构中的一个明确位置。此时的 RAG 更接近结构化文档检索。</p>
<p>Agentic Chunking 进一步把 LLM 引入切分阶段，由模型判断哪些内容应属于同一语义单元。它在复杂文档上可能带来更强的语义完整性，但代价同样明显：构建成本高、可重复性差、调试难度大，提示词质量会直接影响索引结果。因此它更适合作为高价值内容的增强型切分，而不适合作为默认的全量基础设施。</p>
<div class="blog_h2"><span class="graybg">向量数据库</span></div>
<p>向量数据库（Vector Database）存储每个 chunk 的向量表示（Embedding Vector）及其元数据（Metadata），支持近似最近邻（Approximate Nearest Neighbor, ANN）检索。向量并不“包含全文”，它是语义相似度的索引键；检索结果仍然需要回源拿到原文片段。</p>
<p>工程上需要区分“向量索引（Vector Index）”与“向量数据库（Vector Database）”：前者强调 ANN 检索算法与数据结构；后者强调持久化、增量更新、元数据过滤（Metadata Filtering）、分片/副本与多租户（Multi-tenancy）等系统能力。</p>
<p>生产知识库通常会同时维护多个索引平面：chunk 正文索引、chunk 摘要索引、chunk 问题索引，必要时还有文档摘要索引。它们对应的是不同的检索语义：正文索引擅长直接召回原文；摘要索引更适合长文压缩后的主题级匹配；问题索引则把 chunk 改写成更贴近用户查询的可检索意图。因此，生产 RAG 的索引设计本质上是“同一内容的多种检索视图”。</p>
<p>双后端架构也是常见现实。高质量知识库常把 Milvus/Qdrant 一类向量系统与 Elasticsearch/OpenSearch 一类全文检索系统并行使用：前者负责稠密向量、稀疏向量与 ANN 检索，后者负责 BM25、字段过滤和业务搜索。这种组合对应的是信号分工：向量擅长语义匹配，词项系统擅长精确关键词与过滤。真正的难点随之转移到一致性和融合上：索引命名要对齐，更新要同步，删除要清理，跨系统分数不能直接比较。</p>
<div class="blog_h3"><span class="graybg">实现形态与选型</span></div>
<p>向量检索组件通常有三种部署形态：向量索引库（如 FAISS/ScaNN）偏“库”；向量数据库（如 Milvus/Qdrant/Weaviate）偏“服务”；全文检索引擎（如 Elasticsearch/OpenSearch）则以 BM25 为核心并逐步补齐向量检索能力。许多生产系统采用“双引擎”组合：<span style="background-color: #c0c0c0;">向量库负责高性能 ANN，全文检索负责复杂过滤与关键词召回</span>，最终在融合/重排阶段统一排序。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">形态</td>
<td style="text-align: center;">代表实现</td>
<td style="text-align: center;">优势</td>
<td style="text-align: center;">局限</td>
<td style="text-align: center;">适用场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>向量索引库（In-process Vector Index）</td>
<td>FAISS / ScaNN</td>
<td>单机性能极强；集成简单；易做离线批构建</td>
<td>分布式/多租户/权限/运维能力弱；元数据过滤能力有限</td>
<td>原型验证；单机检索；离线候选生成</td>
</tr>
<tr>
<td>向量数据库（Vector Database Service）</td>
<td>Milvus / Qdrant / Weaviate</td>
<td>持久化 + CRUD；支持元数据过滤；更易做水平扩展</td>
<td>需要运维；延迟/吞吐取决于索引与集群配置</td>
<td>生产 RAG；增量更新频繁；需要过滤/权限</td>
</tr>
<tr>
<td>全文检索 + 向量检索（Hybrid Search Engine）</td>
<td>Elasticsearch / OpenSearch</td>
<td>BM25 + 过滤能力强；生态成熟；适合业务检索</td>
<td>向量能力与性能细节高度依赖具体版本/配置；向量索引形态可选项较少</td>
<td>强关键词依赖；复杂过滤/字段检索；混合检索一体化</td>
</tr>
<tr>
<td>组合架构（Vector DB + Search Engine）</td>
<td>Milvus/Qdrant + ES</td>
<td>各取所长；向量与词项召回分别优化；融合/重排可控</td>
<td>系统更复杂；需要去重、打分对齐与一致性策略</td>
<td>对检索质量要求高，且有复杂过滤/排序逻辑</td>
</tr>
<tr>
<td>托管服务（Managed Service）</td>
<td>Pinecone / 各云厂商向量服务</td>
<td>运维成本低；弹性扩缩容；通常带权限与监控</td>
<td>成本较高；可控性与部署形态受限</td>
<td>快速上线；团队缺乏检索基础设施经验</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">ANN 索引与过滤</span></div>
<p>ANN 索引（ANN Index）在“速度、召回率（Recall）与内存”之间权衡。检索侧常见痛点是“向量相似度可算，但业务过滤很难做快”：元数据过滤会破坏 ANN 的近邻结构，使得系统需要在“先过滤再向量检索”和“先向量检索再过滤”之间做策略选择。</p>
<p>在生产知识库里，过滤通常不是可选项，而是第一等公民。目录树（Catalog Tree）、启用状态、文档类型、租户边界、语言、内容类型、业务域前缀，都可能成为过滤条件。也正因为如此，许多系统会把 catalog 前缀过滤、enabled 标志、source_type/source_id 等字段同时写入 Milvus 和 ES，两边都能先做过滤再做召回。否则，召回质量再高，也可能把“本不该返回的内容”混进上下文。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">索引/策略</td>
<td style="text-align: center;">核心思路</td>
<td style="text-align: center;">优点</td>
<td style="text-align: center;">代价</td>
</tr>
</thead>
<tbody>
<tr>
<td>Flat（Exact）</td>
<td>全量计算相似度</td>
<td>精确；实现最简单</td>
<td>规模上去后延迟/成本不可接受</td>
</tr>
<tr>
<td>HNSW</td>
<td>分层小世界图近邻搜索</td>
<td>高召回、低延迟；对在线增量友好</td>
<td>内存占用较高；过滤条件复杂时性能波动</td>
</tr>
<tr>
<td>IVF / IVF-PQ</td>
<td>先粗聚类（倒排），再在桶内搜索；PQ 进一步压缩</td>
<td>更省内存；适合大规模离线构建</td>
<td>更新成本高；召回/延迟依赖参数调优</td>
</tr>
<tr>
<td>DiskANN / Hybrid Memory</td>
<td>把索引/向量放到 SSD，内存放导航结构</td>
<td>降低内存压力；可支持更大规模</td>
<td>I/O 成为瓶颈；工程复杂度更高</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">检索策略</span></div>
<div class="blog_h3"><span class="graybg">稠密检索（向量相似度）</span></div>
<p>稠密检索（Dense Retrieval）用向量相似度（如余弦相似度或内积）做 top-k 召回，擅长语义匹配与同义改写，但可能漏掉精确关键词（如错误码、版本号）。</p>
<p>它还有一个常被低估的前提：embedding 模型的训练分布必须与目标领域足够接近。若把主要基于互联网通用语料训练出来的嵌入模型，直接拿去检索法律、医疗、专利或企业内部术语密集的文本，向量空间中的“相似”往往就不再等于业务上的“相关”，召回质量会明显下降。此时更稳妥的做法通常是引入领域微调 embedding、混合检索，或让 sparse / full-text 路径共同兜底。</p>
<div class="blog_h3"><span class="graybg">全文检索（BM25 / ES）</span></div>
<p>全文检索（Lexical Retrieval）以 BM25 等词项匹配为核心，擅长精确匹配与关键词召回。它对拼写、专有名词、数字更敏感，但对语义改写不够鲁棒。</p>
<div class="blog_h3"><span class="graybg">稀疏检索（Sparse Retrieval / SPLADE）</span></div>
<p>稀疏检索（Sparse Retrieval）用“稀疏向量”表示文本：向量维度对应词项（或子词），权重表示该词项对匹配的贡献。与 BM25 相比，稀疏检索的权重来自模型学习而非纯统计；与稠密检索相比，它保留了词项级可解释性与对罕见词/数字的敏感性。</p>
<p>典型路线包括 SPLADE（Sparse Lexical and Expansion Model）：通过学习得到“词项扩展（Lexical Expansion）”，让语义相关但不共词的文本仍能在词项空间相遇。工程上，稀疏检索常作为混合检索的一路召回信号，而不是单独替代 BM25。</p>
<div class="blog_h3"><span class="graybg">混合检索</span></div>
<p>混合检索（Hybrid Retrieval）把稠密检索与全文检索结合：先分别召回，再融合排序（如加权、去重、学习排序）。这是目前最稳健的通用策略之一。</p>
<p>在更完整的知识库系统里，混合检索不只是“dense + BM25”两路，而可能是 <span style="background-color: #c0c0c0;">chunk / summary / question</span> 三类检索目标与 <span style="background-color: #c0c0c0;">dense / sparse / fulltext</span> 三类检索路径的组合矩阵。换句话说，生产 RAG 召回的是多个“信号通道”，而不是单一相似度函数。它的优势是覆盖面更强：原文命中、主题命中、问句改写命中可以互补；但代价也很直接：路径暴涨、分数更不可比、日志更难读、回归测试更复杂。</p>
<p>这类系统往往还会按知识库能力做“有效方法裁剪”。例如，用户请求 fullhybrid，但某个知识库只建了 dense 和 sparse 索引，那么实际就只能退化成 hybrid；某个库没有开启 question recall，就不该在该路径上浪费资源。这个细节反映出一个成熟的 RAG 判断：<span style="background-color: #c0c0c0;">检索策略不是全局写死的，而是知识库级配置与请求级策略共同决定的</span>。</p>
<div class="blog_h4"><span class="graybg">分数融合（Score Fusion）</span></div>
<p>混合检索的难点不在“多路召回”，而在“分数不可比（Incomparable Scores）”：BM25 分数、余弦相似度/内积分数、稀疏向量分数往往不在同一尺度，不能直接相加。常见融合策略：</p>
<ul>
<li>归一化后加权：对各路分数做 min-max / z-score 等归一化，再做加权求和或加权乘积。</li>
<li>基于排序的融合：不依赖原始分数尺度，例如 RRF（Reciprocal Rank Fusion）：<span displaypfx="inline-" class="mathjax-container">\(\mathrm{score}(d)=\sum_{s}\frac{1}{k+\mathrm{rank}_s(d)}\)</span>。</li>
<li>学习排序（Learning to Rank）：把各路分数与特征（BM25、embedding sim、字段匹配、长度等）作为特征，训练一个排序模型做融合。</li>
</ul>
<p>生产系统通常更偏爱基于排序的融合，而不是直接拼原始分数。这是因为 ES 的 <span displaypfx="inline-" class="mathjax-container">\(_score\)</span>、Milvus 稠密距离、Milvus 稀疏 BM25 分数往往来自完全不同的评分体系。RRF 或加权 RRF 的价值就在于鲁棒：它不要求各后端分数可比，只要求各路径给出相对顺序。若再往上追求精度，则会在粗召回后接一个模型重排器，对融合后的候选做统一判断。</p>
<div class="blog_h3"><span class="graybg">相关性阈值</span></div>
<p>相关性阈值（Relevance Threshold）决定的是：召回结果里哪些片段值得继续送给模型，哪些片段应该被直接丢弃。很多 RAG 系统的问题并不是“完全没召回到东西”，而是把大量边缘相关、低置信度甚至明显错误的片段一并塞进上下文，结果让生成阶段被噪声牵着走。</p>
<p>因此，top-k 本身通常不够；工程上还需要结合相似度阈值、分数差值阈值或最小证据数量规则共同判断。阈值设得过低，会把噪声片段混进上下文；阈值设得过高，又可能让系统在真实有答案时错误地进入“未检索到结果”分支。成熟系统往往会把阈值当作知识库级和请求级都可调的参数，并配合离线标注集或在线反馈持续校准。</p>
<div class="blog_h2"><span class="graybg">查询改写</span></div>
<p>查询改写（Query Rewriting）解决的问题是：用户真正表达的检索意图，经常并不等于用户字面上输入的那句话。尤其当用户提问冗长、上下文依赖强、代词很多、问题里混有解释性语句而不是检索关键词时，直接拿原问题去召回，往往不如先把问题改写成更适合检索的形式。</p>
<p>查询改写的核心不是“把句子润色得更漂亮”，而是把问题重新组织成更适合索引命中的检索表达，例如补全省略主语、显式写出实体名、拆开复合问题、提取关键词、或把对话上下文中的隐含约束展开成可检索文本。</p>
<div class="blog_h3"><span class="graybg">多查询 RAG</span></div>
<p>多查询 RAG（Multi-Query RAG）会先把一个原始问题改写成多个语义相近但表述不同的检索查询，再分别召回并合并结果。它的动机很直接：单一路径的 query 很容易受措辞影响，而多个改写版本可以覆盖不同的关键词、别名、问题角度与表达方式，从而提高召回覆盖率。</p>
<div class="blog_h3"><span class="graybg">多跳 RAG</span></div>
<p>多跳 RAG（Multi-hop RAG）适合那些答案依赖多段证据链的查询。它通常不会指望一次检索就拿到最终答案，而是先检索第一跳证据，再根据第一跳结果生成下一跳 query，继续检索后续证据。于是，检索过程本身就变成了一条“检索 ➡ 读证据 ➡ 改写下一问 ➡ 再检索”的链式推理流程。</p>
<div class="blog_h3"><span class="graybg">智能体 RAG</span></div>
<p>智能体 RAG（Agentic RAG）则把查询改写提升为一个可反复决策的过程：模型不只改写一次 query，而是根据当前召回质量、证据缺口、工具反馈与上下文状态，决定是否继续搜索、换一种问法、切换知识库、触发重排，或干脆停止检索进入回答阶段。它的重点已经不只是“改写 query”，而是让检索成为 Agent 控制流的一部分。</p>
<div class="blog_h2"><span class="graybg">重排序（Reranking）</span></div>
<p>重排序（Reranking）在“召回”（Recall）之后提升“相关性排序”（Precision@k）：用更强但更慢的模型对候选片段重新打分。</p>
<p>成熟 RAG 系统里的 rerank 还常分成两层。第一层是每个知识库内部的局部重排：对该库自己的候选做加权、RRF 或模型精排。第二层是多知识库结果合并后的全局重排：当查询同时打到多个知识库时，再在总候选集合上做一次统一排序。这样做的动机很现实：单库内部的分数可比较，并不代表跨库结果也天然可比较；若不做第二层，全局 top-k 往往会被某个打分体系更“激进”的知识库垄断。</p>
<div class="blog_h3"><span class="graybg">Cross-Encoder Reranker</span></div>
<p>Cross-Encoder Reranker 把 query 与候选 chunk 拼接后输入同一个编码器，用注意力做细粒度匹配，相关性通常更高，但计算更慢，适合对 top-k（例如 50→10）做精排。</p>
<p>典型输入形式是 <pre class="crayon-plain-tag">[CLS] query [SEP] doc [SEP]</pre>（或对应模型的特殊 token）。在 BERT 类模型中，token type embeddings（也称 segment embeddings）可用于区分两段文本；随后 Transformer 的全连接自注意力允许 query token 与 doc token 在每一层发生深度交互，这正是 cross-encoder “cross” 的本质。</p>
<p>打分通常取 <span displaypfx="inline-" class="mathjax-container">\([\mathrm{CLS}]\)</span> 位置的最终隐向量 <span displaypfx="inline-" class="mathjax-container">\(h_{\mathrm{CLS}}\)</span> 过一个线性层得到相关性分数（或二分类 logits）。与双编码器（Bi-Encoder / Dual Encoder）相比，cross-encoder 更准但无法离线预计算候选向量，因此在线成本更高。</p>
<div class="blog_h3"><span class="graybg">LLM Reranker</span></div>
<p>LLM Reranker 用大模型直接判断相关性或做对比选择，能融合更复杂的语义与任务约束，但成本更高且需要防止“自信乱判”。工程上常用它做少量候选的最终筛选。</p>
<div class="blog_h3"><span class="graybg">生成式重排序（Generative Reranking）</span></div>
<p>生成式重排序（Generative Reranking）把“相关性打分”转写为生成任务：把 query 与 doc 拼接输入解码器（Decoder-only）或编码器-解码器（Encoder–Decoder），让模型生成一个标签 token（例如 <pre class="crayon-plain-tag">Yes/No</pre>）或一个短评分文本，并用生成概率作为分数。</p>
<p>常见的点式打分形式是二分类 log-odds：令 <span displaypfx="inline-" class="mathjax-container">\(y\in\{0,1\}\)</span> 表示是否相关，则</p>
<span displaypfx="" class="mathjax-container">\[\mathrm{score}(q,d)=\log p_\theta(y=1\mid q,d)-\log p_\theta(y=0\mid q,d)\]</span>
<p>相比传统 BERT 类 cross-encoder，生成式 reranker 往往更擅长处理长文本匹配、复杂约束与隐含推理；工程上常用“轻量级 LLM + 领域数据微调”来获得显著增益。</p>
<div class="blog_h3"><span class="graybg">列表式重排序（Listwise Reranking）</span></div>
<p>列表式重排序（Listwise Reranking）把多个候选 doc 一次性送入模型，在同一个上下文窗口中共同建模“相对顺序”，而不是对每个（query, doc）独立跑一次 forward。直觉上，它让候选之间在 self-attention 中“同台竞争”，从而提升排序一致性，并降低多次推理带来的总开销。</p>
<p>实现上常见做法是：把候选 doc 按截断长度拼接成一个 list，要求模型输出每个 doc 的分数或名次；也有实现会在末尾使用一个专门的“汇聚 token”对每个 doc 读取表示。Listwise 方法的工程约束更强：候选数、每段 doc 的截断长度与模型上下文窗口会直接决定可吞吐的 top-k。</p>
<div class="blog_h3"><span class="graybg">训练目标与 Hard Negatives</span></div>
<p>精排训练的难点是“区分相似但不相关”：需要硬负样本（Hard Negatives）来逼迫模型学习细粒度差异。硬负样本通常来自第一阶段召回：对同一 query，取检索 top-k 中不相关的候选作为 negatives（也可混入 BM25 negatives、in-batch negatives、或对抗式 negatives）。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">目标</td>
<td style="text-align: center;">形式</td>
<td style="text-align: center;">直觉</td>
<td style="text-align: center;">常见风险</td>
</tr>
</thead>
<tbody>
<tr>
<td>点式（Pointwise）</td>
<td>学习 <span displaypfx="inline-" class="mathjax-container">\(s(q,d)\)</span> 或 <span displaypfx="inline-" class="mathjax-container">\(p(y=1\mid q,d)\)</span></td>
<td>把相关性当作二分类/回归</td>
<td>分数标定（Calibration）难；对“相对顺序”监督较弱</td>
</tr>
<tr>
<td>对式（Pairwise）</td>
<td>学习 <span displaypfx="inline-" class="mathjax-container">\(s(q,d^+)&gt;s(q,d^-)\)</span></td>
<td>直接优化排序边际（Margin）</td>
<td>负样本质量决定上限；采样偏差可能放大</td>
</tr>
<tr>
<td>列表式（Listwise）</td>
<td>对候选列表联合优化（如 softmax over list）</td>
<td>更贴近 NDCG/MRR 等排序指标</td>
<td>计算与实现复杂；受上下文窗口约束</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">蒸馏（Knowledge Distillation）</span></div>
<p>蒸馏（Knowledge Distillation）把一个强但慢的教师模型（Teacher）产生的打分/偏好，转移给一个更快的学生模型（Student）。在重排序里，蒸馏最常见的目标不是“压缩参数”，而是<span style="background-color: #c0c0c0;">把 LLM 级别的相关性判断能力下放到可规模化的 reranker</span>，使线上能承载更大的 top-k、更高的 QPS 或更低的 P99。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">Teacher → Student</td>
<td style="text-align: center;">监督信号</td>
<td style="text-align: center;">常见损失</td>
<td style="text-align: center;">收益</td>
<td style="text-align: center;">主要风险</td>
</tr>
</thead>
<tbody>
<tr>
<td>LLM / 生成式 reranker → Cross-Encoder</td>
<td>软分数（log-odds / graded）或偏好对</td>
<td>回归（MSE）/ 对式（pairwise logistic）</td>
<td>显著降成本；保留较强语义与推理能力</td>
<td>学生上限受教师约束；教师偏差会被复制</td>
</tr>
<tr>
<td>Cross-Encoder → Bi-Encoder</td>
<td>相对顺序/分数</td>
<td>对比学习（InfoNCE）/ 蒸馏排序边际</td>
<td>把精排能力“下放”到召回，提高召回质量</td>
<td>需要大量 hard negatives；对领域漂移敏感</td>
</tr>
<tr>
<td>LLM → 数据标注（作为训练集构造器）</td>
<td>弱标注标签 + 解释/证据</td>
<td>按目标任务训练（点式/对式/列表式）</td>
<td>快速构造大规模领域数据；迭代快</td>
<td>噪声标注；需抽样人工复核与在线 A/B 验证</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">返回内容不是只给命中文本</span></div>
<p>RAG 检索阶段的最终产物并不一定只是“命中的那一小段 chunk”。很多高质量系统会把返回内容模式做成显式策略：只返回命中块正文、返回去掉 overlap 的核心正文、返回命中块加邻近上下文、或按文档中的位置范围把相关块递归拼回更完整的片段。这样做的原因是：模型生成真正需要的是<span style="background-color: #c0c0c0;">足够完整且边界清晰的证据上下文</span>，而不是孤立句子。</p>
<p>这里的关键判断是：检索命中的是索引单元，但喂给模型的应是生成单元。索引单元倾向短小、便于召回；生成单元则要兼顾上下文完整性、token 预算和可引用性。若两者完全绑定，系统通常会在召回精度与生成可读性之间来回拉扯。</p>
<div class="blog_h2"><span class="graybg">从项目设计能看到的几条经验</span></div>
<p>第一，RAG 的上限不只由 embedding 模型决定，而是由“分块质量、索引视图、融合策略、回拼策略、状态治理”共同决定。把注意力只放在换一个更强 embedding 上，通常抓不住主矛盾。</p>
<p>第二，知识库不是静态文件集合，而是带状态机的外部知识资产。只有显式区分 base、document、chunk，并让它们有自己的生命周期，系统才可能支持增量更新、失败恢复、重建和审计。</p>
<p>第三，生产 RAG 常常天然走向双后端和多路径召回。这个方向的收益是更稳健的覆盖率，代价是系统复杂度、索引一致性成本和观测难度都会明显上升。因此它适合高价值知识库，不一定适合所有团队的第一版系统。</p>
<p>第四，把摘要和问题也视作可检索对象，是一个非常务实的设计。它等于承认“原文表述”与“用户提问方式”经常不一致，于是用知识增强知识本身的可检索性。但它也会带来额外索引、额外 embedding 成本与额外一致性问题，因此只有在召回质量确实成为瓶颈时，这种多视图索引才值得引入。</p>
<p>第五，外部知识平台同步并不等于“复制一份数据就结束”。一旦系统允许一部分知识由外部平台托管、另一部分知识由内部系统自建索引，那么“谁是事实源、谁负责更新、谁负责删除、谁负责召回”就必须在架构上说清楚。否则最容易出现的不是召回不到，而是召回出重复、过期或跨系统不一致的内容。</p>
<div class="blog_h3"><span class="graybg">主流选型对比</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">阶段</td>
<td style="text-align: center;">常见架构</td>
<td style="text-align: center;">代表实现</td>
<td style="text-align: center;">核心逻辑</td>
<td style="text-align: center;">速度</td>
<td style="text-align: center;">精度</td>
</tr>
</thead>
<tbody>
<tr>
<td>召回</td>
<td>双编码器（Bi-Encoder）</td>
<td>BGE-Embedding<br />text-embedding-3</td>
<td>向量相似度</td>
<td>极快</td>
<td>中</td>
</tr>
<tr>
<td>精排（稳健基线）</td>
<td>交叉编码器（Cross-Encoder）</td>
<td>BAAI<br />bge-reranker-v2-m3</td>
<td><span displaypfx="inline-" class="mathjax-container">\([q;d]\)</span> 深度交互</td>
<td>中</td>
<td>高</td>
</tr>
<tr>
<td>精排（顶配）</td>
<td>生成式 / 列表式（LLM-based）</td>
<td>BGE-Reranker-v2-Gemma<br />Jina-Reranker-v3</td>
<td>生成式打分或 listwise 竞争</td>
<td>较慢</td>
<td>极高</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">生成与融合</span></div>
<p>生成与融合（Generation &amp; Fusion）的关键在于：把检索到的证据（Evidence）以可控格式注入 prompt，并要求模型“基于证据回答”。常见做法包括：</p>
<ul>
<li>Stuffing：把 top-k 片段直接拼接进上下文（最简单，最易超预算）。</li>
<li>Map-Reduce：先对各片段分别提炼要点，再汇总生成最终答案（更稳，成本更高）。</li>
<li>Refine：先用少量证据生成初稿，再逐片段增量修订（适合长证据链）。</li>
</ul>
<div class="blog_h1"><span class="graybg">智能体（Agent）</span></div>
<p>检索增强生成（RAG）解决的是“模型缺少外部知识”的问题；智能体（Agent）解决的是“模型如何在真实环境中持续完成任务”的问题。一个真正可用的 Agent 不再是单次 prompt 的包装，而是一个带有状态（State）、工具（Tools）、记忆（Memory）与控制流（Control Flow）的运行时系统。它能够观察环境、决定下一步动作、调用外部能力、根据反馈修正策略，并在多轮交互中维持任务连续性。</p>
<div class="blog_h2"><span class="graybg">核心概念</span></div>
<p>从工程视角看，Agent 的本质是“以大模型为决策核心的软件系统”。模型负责理解、推理与生成；外层系统负责注入上下文、执行动作、保存状态、施加约束，并把环境反馈重新送回模型。只有这几层协同起来，模型才会从一次性回答器变成可持续工作的执行体。</p>
<div class="blog_h3"><span class="graybg">计划-执行</span></div>
<p>计划-执行（Plan-Execute）是智能体最常见的控制流之一：先把“用户目标”分解为一组可操作步骤（计划），再逐步调用工具/模型完成这些步骤（执行），并把中间结果写回状态（State）。当环境反馈与计划假设不一致时，触发再计划（Replan）。工程上，它的价值是把一次长输出变成可观测（Observable）、可重试（Retryable）、可中断（Interruptible）的多步过程。</p>
<div class="blog_h3"><span class="graybg">感知-推理-行动循环</span></div>
<p>感知-推理-行动（Perception-Reasoning-Action）循环是 Agent 的基本闭环。感知阶段读取环境状态，例如用户输入、网页 DOM、数据库返回值、日志、文件内容或工具回执；推理阶段根据当前目标、约束和历史状态决定下一步策略；行动阶段把决策落实为具体操作，例如搜索、调用 API、写文件、点击界面或向下游智能体发消息；随后再读取新反馈，进入下一轮循环。这个闭环的关键不在“会不会思考”，而在“每一轮是否拿到了正确观测、是否执行了正确动作、是否把结果正确写回状态”。</p>
<p>因此，Agent 设计首先是状态机（State Machine）设计。很多失败并不是模型推理能力不足，而是观测不完整、动作不可验证、状态写回混乱，导致下一轮推理建立在错误世界模型之上。一个好的 Agent runtime 会显式记录当前目标、最近动作、动作结果、异常信息与下一步计划，从而让每一轮循环都建立在稳定状态上。</p>
<div class="blog_h3"><span class="graybg">工具使用（Tool Use）</span></div>
<p>工具使用（Tool Use）让语言模型突破“只会生成文本”的边界，把外部系统接成自己的感官与执行器。工具既可以是只读型能力，例如搜索、数据库查询、文件读取；也可以是动作型能力，例如发送请求、执行 shell、操作浏览器、写入代码仓库。模型本身不直接执行这些动作，而是输出结构化调用意图，再由外层系统负责真正执行、捕获结果并返回。</p>
<p>工程上，Tool Use 的难点不在“把工具接上”，而在“把工具定义清楚”。一个好的工具接口需要明确输入 schema、输出 schema、权限边界、失败语义和重试策略。工具定义越模糊，模型越容易产生参数幻觉（Argument Hallucination）、错误调用和无效循环；工具定义越稳定，模型越容易学会把工具当作程序原语来组合。</p>
<div class="blog_h3"><span class="graybg">记忆系统</span></div>
<p>记忆系统（Memory System）负责解决跨步骤与跨会话连续性问题。没有记忆的模型每次调用都像“重新开机”；有记忆的 Agent 才能累积上下文、复用历史决策、根据过去错误调整行为。记忆并不等同于“把更多 token 塞进上下文窗口”，而是要把不同时间尺度、不同重要性的状态放进不同存储层，再按需检索。</p>
<div class="blog_h4"><span class="graybg">短期记忆（上下文窗口）</span></div>
<p>短期记忆（Short-term Memory）通常由上下文窗口（Context Window）承载，保存当前任务最活跃的信息：系统指令、最近几轮对话、正在执行的计划、刚拿到的工具结果、当前工作草稿等。它的优势是读取成本低、和当前推理绑定最紧；它的限制也同样明显：容量昂贵、注意力会稀释、信息越多不代表效果越好。</p>
<p>因此，短期记忆的核心工作不是“堆满”，而是“裁剪”。优秀的 Agent 会把原始轨迹压缩成摘要（Summary）、状态（State）和待办项（Next Actions），只保留当前决策真正需要的变量。短期记忆承担的是工作记忆（Working Memory）的职责：它服务当前这一轮推理，而不是承担长期知识库的角色。</p>
<div class="blog_h4"><span class="graybg">长期记忆（外部存储）</span></div>
<p>长期记忆（Long-term Memory）存放在上下文窗口之外，常见载体包括向量数据库、关系数据库、对象存储、文档库、日志系统、代码仓库、任务看板与事件流。它保存的是“现在不必全部加载，但未来可能需要回忆”的信息，例如用户偏好、历史案例、项目规范、过去失败总结、已验证的事实与中间产物。</p>
<p>长期记忆要解决三个问题：写什么、怎么检索、何时遗忘。写入过多会让存储退化为噪声池；只做向量相似度检索又容易漏掉结构化约束与时间关系。成熟做法通常会把结构化状态、全文检索、向量召回与人工定义的索引结合起来，让 Agent 既能“语义回忆”，也能“精确定位”。</p>
<div class="blog_h4"><span class="graybg">记忆类型：情景 / 语义 / 程序</span></div>
<p>目前主流 Agent 设计常借用认知科学中的三类记忆划分：情景记忆（Episodic Memory）、语义记忆（Semantic Memory）与程序记忆（Procedural Memory）。这三类记忆对应的是“发生过什么”“知道什么”“怎么做”。把它们混在一个存储里，检索和写入策略很快就会失控；分层建模则更容易设计稳定的读写规则。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">记忆类型</td>
<td style="text-align: center;">保存内容</td>
<td style="text-align: center;">典型载体</td>
<td style="text-align: center;">工程价值</td>
</tr>
</thead>
<tbody>
<tr>
<td>情景记忆（Episodic）</td>
<td>具体事件、行动轨迹、失败案例、会话历史</td>
<td>任务日志、轨迹摘要、审计记录</td>
<td>支持回放、复盘、反思与重试</td>
</tr>
<tr>
<td>语义记忆（Semantic）</td>
<td>稳定知识、规则、事实、领域概念</td>
<td>知识库、文档索引、结构化事实表</td>
<td>支持检索、问答、约束判断</td>
</tr>
<tr>
<td>程序记忆（Procedural）</td>
<td>工作流程、技能脚本、工具使用模式</td>
<td>系统提示、工具说明、可复用 playbook</td>
<td>把“会做事”沉淀为稳定操作习惯</td>
</tr>
</tbody>
</table>
<p>其中，程序记忆对工程系统尤其关键。很多所谓“Agent 经验”最终都会沉淀成程序记忆，例如某个 API 的调用顺序、某类错误的排查流程、某个项目的提交流程。Harness Engineering 本质上就是把这些隐性经验逐步外化成可持续复用的程序记忆。</p>
<div class="blog_h2"><span class="graybg">推理与规划</span></div>
<p>推理（Reasoning）解决的是“当前这一步怎么想”；规划（Planning）解决的是“整个任务怎么走”。现代 Agent 往往同时需要二者：局部步骤要有足够的推理精度，长期任务要有阶段化计划与中途重规划能力。近两年的研究基本沿着四条路线收敛：交错式思考与行动、反思式自我修正、显式规划后求解，以及基于搜索的多路径探索。</p>
<div class="blog_h3"><span class="graybg">ReAct</span></div>
<p>ReAct（Reason + Act）是当前 Agent 范式最有影响力的起点之一。它把“思考轨迹”和“动作轨迹”交错起来：模型先给出一小步 reasoning，再发起 action，读取 observation 后继续下一步 reasoning。与只做链式思维（Chain-of-Thought）的做法相比，ReAct 的优势在于每一步都能接触外部环境，因此能在事实不确定、需要探索、需要工具辅助的任务上持续修正路径。</p>
<p>ReAct 的核心价值并不是让模型“想得更多”，而是让推理过程与环境反馈形成闭环。它也有明显代价：若没有停机条件、步数上限和工具约束，轨迹很容易冗长、循环或过度调用工具。因此，ReAct 更像 Agent 控制流的原型范式，生产系统通常会在其外层再加计划、验证、预算控制与错误恢复机制。</p>
<div class="blog_h3"><span class="graybg">Reflection（自我反思）</span></div>
<p>Reflection（自我反思）把“错误”变成下一轮决策的输入。其基本思想是：一次执行失败后，不只返回原始错误信号，还额外生成一段高信息密度的经验总结，说明失败原因、已验证无效的路径、下一次应当修改的策略。这样，模型获得的不是一个抽象的 reward，而是一段可以直接进入上下文的文字化梯度。</p>
<p>从研究谱系看，Reflexion 将这种思路系统化为 verbal reinforcement learning：把环境反馈转写成文本反思，并存入 episodic memory，供后续回合检索。工程上，这类方法通常以“执行后 Critic”“失败总结卡片”“经验回放摘要”等形式落地。它最适合高代价试错任务，例如代码修复、网页操作、长任务代理；前提是系统能可靠地区分“失败是策略问题”还是“失败只是偶然噪声”。</p>
<div class="blog_h3"><span class="graybg">Plan-and-Solve</span></div>
<p>Plan-and-Solve 的思想很直接：先显式列出求解计划，再根据计划执行。它针对的是零样本链式思维常见的 missing-step 问题，即模型直接开始求解时容易跳步骤、漏约束或在中途漂移。把“先规划、后执行”做成两个阶段后，模型的注意力会先聚焦在任务分解，再聚焦在逐步完成，从而显著降低长任务中的结构性遗漏。</p>
<p>这一思路在工程系统里几乎已经成为默认模式。很多 Coding Agent、Research Agent 与 Workflow Agent 都把 planner 和 executor 拆成不同阶段，甚至拆成不同角色。原因很简单：计划是一种低成本、高杠杆的中间产物。它既能让人类在写代码前审查方向，也能让系统在执行中持续对照“当前动作是否仍服务于原始目标”。</p>
<div class="blog_h3"><span class="graybg">MCTS（蒙特卡洛树搜索）</span></div>
<p>MCTS（Monte Carlo Tree Search，蒙特卡洛树搜索）把 Agent 的决策过程从“单路径生成”升级为“多路径探索”。系统不再只让模型给出一个下一步动作，而是展开多个候选分支，对不同轨迹进行模拟、评估和回传分值，再把计算预算集中到更有希望的分支上。对于需要长程依赖、路径选择、试探性探索的任务，这种显式搜索比单次贪心生成更稳健。</p>
<p>Language Agent Tree Search（LATS）是这一路线的代表性工作，它把 MCTS 与语言模型结合，让模型同时承担候选动作生成、状态评估和自我反思等角色。代价同样明确：搜索带来更高 token 成本、更复杂的状态管理和更重的工程编排。因此，MCTS 更适合高价值、高不确定性任务，例如复杂推理、博弈式决策、困难编程与网页任务，而不是每个简单问答都默认启用的通用套路。</p>
<div class="blog_h2"><span class="graybg">工具与行动</span></div>
<p>如果说推理模块决定“应该做什么”，那么工具层决定“究竟能做到什么”。当前主流 Agent 平台基本收敛出几类高频动作接口：结构化函数调用、受控代码执行、Web 检索、图形界面操作，以及把外部工具和资源标准化接入的协议层。它们共同构成了 Agent 的执行面。</p>
<div class="blog_h3"><span class="graybg">Function Calling</span></div>
<p>Function Calling（函数调用）是最基础、最通用的 Tool Use 形式。模型输出的不再是自然语言描述，而是一个满足 schema 的调用请求，例如函数名、参数对象、调用意图；应用侧接收到请求后真正执行代码，并把结果作为 tool result 返回。这样，模型负责“决定调用什么”，应用负责“保证执行正确”。</p>
<p>Function Calling 的真正意义是把自由文本接口收窄为结构化程序接口。只要参数 schema 足够清晰，模型就能稳定学会把外部系统当成可组合原语。这也是今天大部分 Agent 框架的底层抽象：上层看起来是“智能体会用工具”，底层通常仍是“模型在做结构化函数选择与参数填充”。</p>
<div class="blog_h3"><span class="graybg">Code Interpreter</span></div>
<p>Code Interpreter（代码解释器）让模型在受控沙箱中执行代码，从而把“语言推理”转换为“可验证计算”。它特别适合数据清洗、表格处理、统计分析、绘图、文件转换、轻量脚本自动化与需要精确数值结果的任务。与纯文本推理相比，Code Interpreter 的优势在于中间状态可执行、可复现、可检查。</p>
<p>这类能力本质上是把 Python 或其他运行时变成模型的外部工作台。模型负责提出程序，沙箱负责执行，系统再把 stdout、文件产物、异常栈和图像反馈给模型。只要隔离、资源限制和文件边界设置得当，Code Interpreter 往往是让 Agent “从会解释变成会计算”的最直接跃迁。</p>
<div class="blog_h3"><span class="graybg">Web 搜索</span></div>
<p>Web 搜索（Web Search）解决的是模型知识静态化问题。基础模型参数中固化的是训练时刻之前的分布，而 Agent 经常需要回答最新价格、最新政策、最新产品文档、最新论文或当前网页状态。把搜索能力接入后，模型可以先检索，再基于来源生成回答，并在必要时附带引用。</p>
<p>工程上，Web 搜索不是“把搜索结果贴给模型”这么简单。系统还需要处理查询重写、来源筛选、去重、可信度排序、片段抽取与多源融合。对于需要最新信息的任务，搜索应当被视为一等能力，而不是可有可无的外挂；否则 Agent 的错误会表现为“推理看起来正确，但事实已经过时”。</p>
<div class="blog_h3"><span class="graybg">Computer Use</span></div>
<p>Computer Use（计算机使用）让 Agent 直接在图形界面层面操作软件与网页，例如看截图、定位元素、移动鼠标、键入内容、滚动页面与读取屏幕变化。它的价值在于：当目标系统没有现成 API，或者真正业务流程本来就发生在浏览器/桌面界面中时，Agent 仍然可以像人类操作员一样完成任务。</p>
<p>这类能力也是最脆弱的一类动作接口。界面结构变化、网络延迟、弹窗遮挡、验证码、权限确认和安全风险都会放大失败概率。因此，Computer Use 应被视为“最后一层兼容性接口”：没有 API 时才使用，并始终运行在隔离环境中，为高风险动作保留人工确认与回滚机制。</p>
<div class="blog_h3"><span class="graybg">MCP（模型上下文协议）</span></div>
<p>MCP（Model Context Protocol，模型上下文协议）试图把“给模型接工具与上下文”这件事标准化。它定义了一套客户端-服务端协议，让宿主应用可以连接多个 MCP server，并以统一方式暴露工具（Tools）、资源（Resources）与提示模板（Prompts）。这样，模型不必为每个外部系统重新学习私有接线方式，应用也不必为每种模型重复造一套集成层。</p>
<p>在工程语义上，MCP 解决的是“工具可移植性”和“上下文供给标准化”。一个搜索 server、一个数据库 server、一个设计文档 server，只要遵循同一协议，就能被不同 Agent host 复用。当前生态已经把 MCP 从“工具协议”扩展成“上下文协议”：除了调用动作，也强调让模型按需读取结构化资源，而不是把所有材料一次性塞进 prompt。</p>
<p>截至 2026 年 3 月，MCP 官方稳定协议版本为 2025-11-25，2026 路线图正在推进会话扩展（session handling）、Server Cards、Registry 与 agent-to-agent 协作能力。对应用开发者而言，更重要的不是版本号本身，而是它标志着一件事：Agent 运行时正在从各家私有 Tool API，逐步走向可协商、可复用、可观测的开放接口层。</p>
<div class="blog_h2"><span class="graybg">多智能体系统</span></div>
<p>单智能体并不天然落后，多智能体（Multi-agent）也不天然更先进。是否拆成多个 agent，取决于任务是否存在天然的角色分工、上下文隔离需求、并行空间与独立验证价值。很多简单任务用一个强单体 agent 加好工具即可；只有当单一上下文开始拥塞、单角色难以兼顾规划与执行、或多个分支可以并行推进时，多智能体系统才真正体现优势。</p>
<div class="blog_h3"><span class="graybg">角色分工</span></div>
<p>多智能体系统的核心不是“多”，而是“分工明确”。最常见的基本三角是 Planner、Executor 与 Critic：Planner 负责分解目标，Executor 负责实际操作，Critic 负责验证与挑错。这个结构的本质是在系统内部显式制造不同视角，避免同一个上下文同时承担规划、执行与审查，最终让错误在更早阶段暴露出来。</p>
<div class="blog_h4"><span class="graybg">Planner</span></div>
<p>Planner 负责把模糊目标转成结构化任务图，例如目标拆解、依赖关系、优先级、完成标准与失败回退条件。一个好的 Planner 不直接沉迷于实现细节，而是尽快产出可被执行层消费的中间表示，例如任务列表、阶段计划、验收清单或执行 DAG。这样，后续 agent 的上下文会更短、更稳定。</p>
<div class="blog_h4"><span class="graybg">Executor</span></div>
<p>Executor 负责把计划变成具体动作：调用 API、写代码、运行测试、检索资料、修改文件、操作界面。它通常需要最严格的权限控制，因为真正改变外部世界的是执行层，而不是规划层。把 Executor 的工具范围、写入范围和预算边界限定清楚，往往比继续优化模型 prompt 更能提升整体可靠性。</p>
<div class="blog_h4"><span class="graybg">Critic</span></div>
<p>Critic 负责验证“结果是否正确”，而不是复述“过程看起来合理”。它可以检查事实一致性、运行测试、比对输出格式、审查架构约束、回放浏览器流程，或要求执行层补充证据。Critic 的价值在于给系统引入独立的否决权：没有独立验证的多智能体系统，本质上仍然只是把同一种错误复制了几遍。</p>
<div class="blog_h3"><span class="graybg">协作模式</span></div>
<p>角色定义清楚之后，下一步是决定这些角色如何协作。常见模式主要有顺序执行、并行执行与层级委派。三者区别不在名称，而在状态如何传递、谁拥有控制权、失败后如何收敛，以及系统是否允许多个 agent 同时写入同一世界状态。</p>
<div class="blog_h4"><span class="graybg">顺序执行</span></div>
<p>顺序执行（Sequential Execution）是最稳的模式：上一步完成并产出明确结果后，下一步才开始。它最适合依赖链强、可验证点清晰的流程，例如“先规划，再实现，再审查，再修复”。优点是状态简单、问题定位容易；缺点是吞吐量受限，前一环的错误会整体阻塞后续流程。</p>
<div class="blog_h4"><span class="graybg">并行执行</span></div>
<p>并行执行（Parallel Execution）用于把独立子任务同时推进，例如多个资料检索、多个候选方案探索、多个代码模块并行实现。它能显著提高吞吐，但前提是任务切分足够干净，或者系统拥有足够好的合并策略。并行化的真正难点不是“同时开几个 agent”，而是“如何避免它们争抢同一上下文、重复劳动或互相覆盖结果”。</p>
<div class="blog_h4"><span class="graybg">层级委派</span></div>
<p>层级委派（Hierarchical Delegation）由一个上层 supervisor 或 manager 统一掌控目标和全局状态，再把子任务委派给不同 specialist。它适合长任务和复杂流程，因为全局控制权集中在上层，局部上下文压力则下沉到子 agent。代价是 supervisor 很容易成为瓶颈，因此需要明确委派粒度、回收机制与升级路径，避免系统退化成“一个总管在不停转述消息”。</p>
<div class="blog_h2"><span class="graybg">主流框架</span></div>
<p>Agent Framework 解决的是“如何把模型能力组织成长期可运行的软件系统”。主流框架的差异主要体现在三个层面：控制流表达方式、状态管理方式，以及多智能体协作的原生支持程度。选型时最重要的不是功能列表，而是它是否契合你的系统拓扑：你需要图式工作流、事件驱动编排、还是平台托管的工具执行与追踪能力。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">框架/形态</td>
<td style="text-align: center;">控制流模型</td>
<td style="text-align: center;">强项</td>
<td style="text-align: center;">代价</td>
<td style="text-align: center;">适用</td>
</tr>
</thead>
<tbody>
<tr>
<td>LangChain / LangGraph</td>
<td>链式/图式（Graph）</td>
<td>组件化；易组合 RAG、工具与记忆；LangGraph 适合复杂状态机</td>
<td>抽象层较多；需要明确工程边界</td>
<td>生产 RAG/Agent pipeline</td>
</tr>
<tr>
<td>AutoGen</td>
<td>事件驱动多智能体（Event-driven）</td>
<td>消息传递清晰；适合团队式协作与研究</td>
<td>系统设计自由度高；需要自定治理边界</td>
<td>多智能体实验；复杂协作流程</td>
</tr>
<tr>
<td>CrewAI</td>
<td>角色 + 任务流水线</td>
<td>任务编排直观；适合“岗位分工”式流程</td>
<td>可控性取决于框架提供的扩展点</td>
<td>面向业务流程的多角色 Agent</td>
</tr>
<tr>
<td>OpenAI Responses API / Agents SDK</td>
<td>平台托管（Managed）</td>
<td>内建工具、Tracing、Handoff 与托管能力完善</td>
<td>更依赖平台抽象；深度定制时需额外设计 runtime</td>
<td>快速构建生产 Agent；希望复用官方工具栈</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">LangChain / LangGraph</span></div>
<p>LangChain 偏组件化抽象，LangGraph 偏图式控制流。前者适合把模型、检索器、工具、记忆等模块快速拼接起来；后者适合把复杂 Agent 流程显式建模为图（Graph），把循环、分支、检查点和人机介入点写成状态转移。对于生产系统，LangGraph 的意义尤其明显：它让“Agent 不是一段 prompt，而是一个状态机”这件事可以被直接编码。</p>
<p>在多智能体方面，LangGraph 当前更强调基于工具调用（tool-calling）的 supervisor 模式，而不是把所有 agent 都包装成自由对话体。原因很实际：当协作关系被写成显式图结构后，上下文边界、路由条件和失败恢复都会更好控制。</p>
<div class="blog_h3"><span class="graybg">AutoGen</span></div>
<p>AutoGen 的长处在于把多智能体协作建模为显式消息系统。不同 agent 可以作为独立节点收发消息、触发工具、交换中间状态，因此很适合研究“团队式智能体”以及需要复杂路由的实验性系统。新版 AutoGen 也更强调事件驱动（Event-driven）与可扩展运行时，而不只是几个人工角色互相聊天。</p>
<p>它的代价也非常明确：自由度越高，系统边界越需要开发者自己画清楚。权限、可观测性、记忆持久化与异常恢复如果没有在框架外补齐，多智能体对话很容易演化成难以调试的消息洪流。</p>
<div class="blog_h3"><span class="graybg">CrewAI</span></div>
<p>CrewAI 把多智能体系统表达为“角色（Role）+ 任务（Task）+ 工具（Tool）”的业务编排语言，适合把组织分工直接映射到系统设计中。它的上手成本较低，尤其适合内容生成、运营流程、分析流水线这类天然按岗位拆分的任务。</p>
<p>这类框架的优势是表达直观，代价是底层控制流往往被隐藏得更深。只要任务依赖、重试策略、共享状态和权限边界开始复杂化，就需要确认框架是否允许你把隐含运行时重新显式化。</p>
<div class="blog_h3"><span class="graybg">OpenAI Responses API / Agents SDK</span></div>
<p>从当前官方工具栈看，OpenAI 已经把 Responses API 作为构建 agent-like application 的统一接口，并以 Agents SDK 提供更轻量的编排、handoff、guardrail 与 tracing 能力。这一路线的核心价值是把常见基础设施平台化：函数调用、Web 搜索、Computer Use、Code Interpreter、Tracing 与多智能体 handoff 都可以沿着同一套接口组织。</p>
<p>托管式能力的优点是默认路径短、集成成本低、官方工具之间协同更顺滑；缺点是当业务开始需要深度定制的状态机、自定义持久化策略或异构执行环境时，应用仍然需要在 SDK 之上补一层自己的 runtime。换句话说，托管平台减少的是“起步成本”，而不是彻底消灭 Agent 工程本身。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/ai-knowledge-quick-ref">人工智能知识速查（理论）</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/ai-knowledge-quick-ref/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>OpenClaw学习笔记</title>
		<link>https://blog.gmem.cc/claw</link>
		<comments>https://blog.gmem.cc/claw#comments</comments>
		<pubDate>Wed, 01 Apr 2026 12:05:22 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[AI]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=41991</guid>
		<description><![CDATA[<p>四个月，343,000 颗星 2025 年 11 月 24 日，一个名为 OpenClaw 的开源项目在 GitHub 上悄然创建。四个月后，它的 Star 数突破 343,000，成为近年来增速最快的非聚合类开源项目之一，超过了 React、Vue 和 Tailwind CSS 在同等时间段内的增长速度。这条陡峭的增长曲线背后，是一个清晰的价值主张：让每个人都能在自己的设备上运行一个完全属于自己的 AI 助手。 <a class="read-more" href="https://blog.gmem.cc/claw">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/claw">OpenClaw学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">四个月，343,000 颗星</span></div>
<p>2025 年 11 月 24 日，一个名为 <span style="background-color: #c0c0c0;">OpenClaw</span> 的开源项目在 GitHub 上悄然创建。四个月后，它的 Star 数突破 343,000，成为近年来增速最快的非聚合类开源项目之一，超过了 React、Vue 和 Tailwind CSS 在同等时间段内的增长速度。这条陡峭的增长曲线背后，是一个清晰的价值主张：<strong>让每个人都能在自己的设备上运行一个完全属于自己的 AI 助手</strong>。</p>
<p>OpenClaw 的一句话定位是 <em>"Your own personal AI assistant. Any OS. Any Platform. The lobster way. "</em>。这句话的核心不在于功能描述，而在于哲学立场——<span style="background-color: #c0c0c0;">本地优先（Local-First）</span>不是一个功能选项，而是整个系统的设计前提。在 AI 助手普遍依赖云端服务的 2025 年，这个立场击中了开发者群体中日益强烈的数据主权（Data Sovereignty）诉求。</p>
<p>"The lobster way" 不只是一句口号。龙虾（Lobster）意象贯穿了整个项目：工作流编排工具命名为 Lobster，社区成员自称 lobster，甚至 GitHub 上的口号 "EXFOLIATE! EXFOLIATE!"（蜕皮！蜕皮！）暗示项目的激进重构和快速迭代哲学——龙虾通过周期性蜕壳实现生长，OpenClaw 通过频繁的破坏性变更实现架构进化。</p>
<p>从工程视角看，OpenClaw 的核心技术栈——TypeScript ESM、pnpm monorepo、230 条 Plugin SDK 导出路径、24 个消息渠道接入——构成了一个在单一代码仓库中管理 AI Agent 平台全生命周期的案例研究。无论是 Rust/Go 工具替代传统 JS 工具链（oxfmt 替代 Prettier、oxlint 替代 ESLint、tsdown 替代 webpack）、TypeScript 原生 Go 编译器预览集成、还是 Docker 多阶段构建中对 QEMU 交叉编译的优雅降级处理——这些工程细节比功能特性更能说明项目的技术深度。</p>
<p>项目采用 <span style="background-color: #c0c0c0;">MIT 协议</span>，赞助商包括 OpenAI、NVIDIA、Vercel、Blacksmith 和 Convex——一家云端 AI 公司赞助一个本地优先的开源竞争者，这本身就是值得深思的战略信号。本文基于 OpenClaw 官方仓库（<a href="https://github.com/openclaw/openclaw">github.com/openclaw/openclaw</a>）v2026.4.1 的实际代码，从仓库结构、插件架构、渠道系统、Agent 运行时、内存系统、安全模型到多端原生应用，进行完整的技术剖析。所有代码引用均来自一手数据，不依赖二手社区资料。</p>
<div class="blog_h2"><span class="graybg">四层架构概览</span></div>
<p>在深入代码之前，先建立全局视角。OpenClaw 的架构可以用四层模型概括：最上层是 <span style="background-color: #c0c0c0;">Gateway（控制平面）</span>，以 WebSocket 服务（默认 ws://127.0.0.1:18789）承载会话管理、配置下发、Cron 调度、Webhook 和健康检查，同时托管 Control UI（Lit 3 + Vite）和 Canvas 宿主（A2UI）。第二层是 <span style="background-color: #c0c0c0;">Agent / Pi Runtime（智能体运行时）</span>，基于 @mariozechner/pi-agent-core@0.64.0，以 RPC 模式运行，支持工具流（Tool Streaming）和块流（Block Streaming），接入 25+ 模型提供者并具备 Auth 轮换和 Failover 能力。第三层是 <span style="background-color: #c0c0c0;">Channels + Skills（渠道与技能层）</span>，覆盖 24 个消息平台，通过 Plugin SDK 的 230 条契约路径与核心交互，ClawHub 市场和 before_install 安全钩子管控技能安装，Browser、Canvas、Nodes、Cron、Sessions 等一等工具也位于此层。最底层是 <span style="background-color: #c0c0c0;">Memory（记忆层）</span>，由 memory-core 的 13 个子模块组成，以本地 Markdown 文件持久化、sqlite-vec 向量搜索和 LanceDB 为存储后端，承载用户可编辑偏好与长期上下文。</p>
<p>Gateway 是唯一的控制平面入口，所有客户端（CLI、Web UI、macOS App、iOS/Android 节点）都通过 WebSocket 连接到 Gateway。Agent 运行时以 RPC 模式挂载在 Gateway 下，接收来自各渠道的消息，调用模型和工具，将结果路由回发送者所在的渠道。Skills 和 Channels 通过 Plugin SDK 的 230 条导出路径与核心交互，Memory 层则为 Agent 提供跨会话的长期记忆能力。</p>
<p>这四层的每一层都有严格的边界约束。Gateway 层通过 src/gateway/protocol/schema.ts 定义类型化 WebSocket 协议，Agent 层通过 Pi 的 RPC 接口暴露能力，Plugin SDK 是扩展与核心之间唯一合法的导入面，Memory 层通过 13 个细粒度子模块避免单体耦合。下文将逐层展开。</p>
<div class="blog_h1"><span class="graybg">项目历史与版本</span></div>
<div class="blog_h2"><span class="graybg">MoltBot → ClawdBot → OpenClaw 的命名演变</span></div>
<p>OpenClaw 并非从零开始。在当前命名之前，它先后经历了 <span style="background-color: #c0c0c0;">MoltBot</span> 和 <span style="background-color: #c0c0c0;">ClawdBot</span> 两个阶段。这段历史在代码库中留有痕迹：package.json 的 scripts 字段至今保留着 "moltbot:rpc" 命令，指向与 "openclaw:rpc" 完全相同的实现。文档域名 docs.molt.bot 也仍以 301 重定向的方式指向当前的 docs.openclaw.ai。</p>
<p>项目由奥地利开发者 <span style="background-color: #c0c0c0;">Peter Steinberger</span>（GitHub: @steipete）主导，其在仓库中拥有 14,756 个提交，远超第二贡献者（1,690 个）。Steinberger 此前以 iOS SDK 生态的贡献著称，转型为 AI Agent 平台开发者，并将高频迭代、激进重构的风格延续到了 OpenClaw 的开发中。</p>
<div class="blog_h2"><span class="graybg">日历版本号与三频道发布策略</span></div>
<p>OpenClaw 采用日历版本（Calendar Versioning）而非语义版本（Semantic Versioning）。版本格式为 vYYYY.M.D（例如 v2026.4.1），直接反映发布日期。当同一天有多个发布时，追加补丁后缀 vYYYY.M.D-N。</p>
<p>发布频道分为三层，通过 npm dist-tag 映射：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">频道</td>
<td style="text-align: center;">npm dist-tag</td>
<td style="text-align: center;">Tag 格式</td>
<td style="text-align: center;">适用场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>stable</td>
<td>latest</td>
<td>vYYYY.M.D</td>
<td>生产环境，默认安装</td>
</tr>
<tr>
<td>beta</td>
<td>beta</td>
<td>vYYYY.M.D-beta.N</td>
<td>预发布验证，macOS App 可能缺席</td>
</tr>
<tr>
<td>dev</td>
<td>dev</td>
<td>main 分支头</td>
<td>开发调试，按需发布</td>
</tr>
</tbody>
</table>
<p>切换频道使用 openclaw update --channel stable|beta|dev。Beta 发布时，npm 版本号必须携带 -beta.N 后缀，不能使用不带后缀的版本号配合 --tag beta 发布，否则版本名会被消耗掉——这是仓库 AGENTS.md 中明确记录的发布守则。</p>
<div class="blog_h2"><span class="graybg">v2026.3.31：一次密集的破坏性变更</span></div>
<p>截至本文写作时，最新的 stable 版本为 v2026.3.31（2026-03-31 发布）。该版本包含 <strong>六项破坏性变更（Breaking Changes）</strong>，密度之高反映了 OpenClaw 激进的迭代风格：</p>
<ol>
<li><strong>Nodes/exec 重构</strong>：移除了 CLI 和 Agent nodes 工具中重复的 nodes.run shell 封装器，所有节点 shell 执行统一走 exec host=node 路径。节点特有能力保留在 nodes invoke 和专用的 media/location/notify 操作上。</li>
<li><strong>Plugin SDK 遗留路径弃用</strong>：弃用旧版 provider 兼容子路径以及旧版 bundled provider setup 和 channel-runtime 兼容垫片（compatibility shims），发出迁移警告。当前文档化的 openclaw/plugin-sdk/* 入口加上本地 api.ts / runtime-api.ts 桶文件是唯一的前进路径。</li>
<li><strong>插件安装安全收紧</strong>：内建的危险代码（dangerous-code）critical 级发现和安装时扫描失败现在<strong>默认拒绝</strong>（fail closed）。此前能成功安装的部分插件现在需要显式指定 --dangerously-force-unsafe-install 标志才能继续。</li>
<li><strong>Gateway 认证收紧</strong>：trusted-proxy 模式拒绝混合共享令牌配置；本地直连回退（local-direct fallback）要求使用配置的令牌，不再隐式认证同主机调用者。</li>
<li><strong>节点命令门控</strong>：节点命令在节点配对获批（node pairing approved）之前保持禁用状态。仅完成设备配对不再足以暴露声明的节点命令。</li>
<li><strong>节点事件信任面缩减</strong>：节点发起的运行（node-originated runs）现在在缩减的受信表面（reduced trusted surface）上执行，依赖更广泛 host/session 工具访问权限的通知驱动或节点触发流程可能需要调整。</li>
</ol>
<p>这种"每个版本都可能有 Breaking Changes"的策略与日历版本号的选择相呼应——既然不提供语义化的兼容性承诺，那就用发布日期明确标识每一次快照。对于使用者，官方建议锁定到特定版本号并在升级前阅读 CHANGELOG。</p>
<div class="blog_h2"><span class="graybg">v2026.3.28：xAI 深度整合与安全强化</span></div>
<p>前一个重要版本 v2026.3.28（2026-03-29 发布）同样包含多项重要变更：</p>
<ul>
<li><strong>xAI 深度整合</strong>：bundled xAI provider 迁移至 Responses API，新增原生 x_search（Grok 网页搜索工具），并在 openclaw onboard 中集成可选的 x_search 配置步骤。</li>
<li><strong>MiniMax 图像生成</strong>：新增 MiniMax image-01 模型的图像生成和图生图编辑能力，支持宽高比控制。</li>
<li><strong>Qwen 认证迁移</strong>：移除已弃用的 qwen-portal-auth OAuth 集成，迁移至 Model Studio API Key 模式。</li>
<li><strong>Plugins/hooks 审批机制</strong>：before_tool_call 钩子新增异步 requireApproval 能力，插件可以暂停工具执行并通过 Telegram 按钮、Discord 交互、/approve 命令等渠道提示用户审批。</li>
<li><strong>Microsoft Teams 升级</strong>：迁移至官方 Teams SDK，支持 1:1 对话的流式回复和 AI 标注。</li>
<li><strong>Gateway OpenAI 兼容性</strong>：新增 /v1/models 和 /v1/embeddings 端点，使 Gateway 可以被 OpenAI 兼容的第三方工具直接调用。</li>
</ul>
<div class="blog_h1"><span class="graybg">仓库结构</span></div>
<div class="blog_h2"><span class="graybg">顶层目录布局</span></div>
<p>OpenClaw 是一个 pnpm workspace monorepo，根目录的核心布局如下：</p>
<pre class="crayon-plain-tag">openclaw/
├── src/           # 核心源码
│   ├── cli/       # CLI 命令入口与进度条
│   ├── commands/  # 各子命令实现
│   ├── gateway/   # Gateway 控制平面（含 protocol/ 子目录）
│   ├── channels/  # 核心渠道实现
│   ├── routing/   # 消息路由
│   ├── plugins/   # 插件发现、加载、注册
│   ├── plugin-sdk/# 公开插件契约（唯一合法导入面）
│   ├── infra/     # 基础设施（SQLite、文件锁等）
│   └── media/     # 媒体处理管道
├── apps/
│   ├── macos/     # SwiftUI + AppKit 菜单栏应用
│   ├── ios/       # Xcode + SwiftUI
│   └── android/   # Kotlin + Gradle
├── extensions/    # 内部扩展（bundled plugin workspace tree）
├── packages/      # 共享包
├── skills/        # 内置 Skills（随 npm 包分发）
├── ui/            # Web Control UI（Lit 3 + Vite）
├── docs/          # Mintlify 文档
├── test/          # E2E 测试
└── scripts/       # 构建/发布/检查脚本（60+个）</pre>
<p>这棵目录树揭示了 OpenClaw 的工程哲学：<span style="background-color: #c0c0c0;">核心尽量薄，边界尽量硬</span>。src/ 存放所有 TypeScript 核心代码，extensions/ 存放内建扩展（bundled plugin workspace tree），apps/ 存放三个原生客户端。三者之间的导入关系是单向的：extensions/ 只能通过 openclaw/plugin-sdk/* 调用核心能力，apps/ 通过 Gateway WebSocket 协议与核心通信。任何反向依赖都会被 CI 的架构守卫脚本拦截。</p>
<div class="blog_h2"><span class="graybg">extensions/ 与 packages/ 的区分</span></div>
<p>extensions/ 是 <span style="background-color: #c0c0c0;">bundled plugin workspace tree</span>——即随 npm 包一起发布的内建扩展。Matrix、Zalo、ZaloUser、Voice Call 等渠道插件以及诊断遥测（diagnostics-otel）都存放在此。每个扩展是一个独立的 pnpm workspace 包，拥有自己的 package.json 和 openclaw.plugin.json 清单文件。扩展的运行时依赖必须声明在自身的 dependencies 中，不能添加到根 package.json（除非核心也使用了同一依赖）。而 workspace:* 在 dependencies 中是被禁止的（因为 npm install 无法解析 workspace 协议），openclaw 本身应放入 devDependencies 或 peerDependencies，运行时通过 jiti 别名解析 openclaw/plugin-sdk。</p>
<p>packages/ 存放的则是纯粹的共享库包，不含插件清单，不走插件加载管线。它们提供跨包复用的工具函数和类型定义。</p>
<div class="blog_h2"><span class="graybg">skills/ 与 docs/ 的角色</span></div>
<p>skills/ 目录存放<span style="background-color: #c0c0c0;">内置 Skills（Bundled Skills）</span>——它们随 npm 包一起分发，安装后即可使用。与 ClawHub 上的第三方 Skill 不同，内置 Skill 不需要 clawhub install，也不走 before_install 安全检查管线。每个 Skill 由一个 SKILL.md 文件描述，该文件在 Agent 运行时被注入到系统提示中。</p>
<p>docs/ 使用 <span style="background-color: #c0c0c0;">Mintlify</span> 框架构建，部署在 docs.openclaw.ai。文档内部链接采用根相对路径（如 [Config](/configuration)），不带 .md 扩展名。文档支持中文翻译，中文版位于 docs/zh-CN/，由 scripts/docs-i18n 脚本自动生成，辅以术语表 docs/.i18n/glossary.zh-CN.json 和翻译记忆 docs/.i18n/zh-CN.tm.jsonl 保证术语一致性。</p>
<div class="blog_h2"><span class="graybg">scripts/ — 60+ 个构建与运维脚本</span></div>
<p>scripts/ 目录包含 60 多个独立脚本文件，加上 package.json 中的 198 个 npm scripts 入口，构成了 OpenClaw 极其精细的构建自动化体系。脚本按用途可分为以下几类：</p>
<ul>
<li><strong>构建脚本</strong>：tsdown-build.mjs（主构建入口）、runtime-postbuild.mjs（构建后处理）、bundle-a2ui.sh（Canvas A2UI 打包）、ui.js（Web UI 构建）</li>
<li><strong>代码检查脚本</strong>：check-extension-plugin-sdk-boundary.mjs（扩展导入边界检查，三种模式）、check-plugin-extension-import-boundary.mjs（核心不得反向导入扩展内部）、check-no-pairing-store-group-auth.mjs（安全认证审计）</li>
<li><strong>发布脚本</strong>：openclaw-npm-release-check.ts（发布前校验）、plugin-npm-release-plan.ts（插件发布计划）、openclaw-npm-postpublish-verify.ts（发布后验证）</li>
<li><strong>平台脚本</strong>：package-mac-app.sh（macOS 打包）、ios-configure-signing.sh（iOS 签名）、build-release-aab.ts（Android AAB 构建）</li>
<li><strong>测试脚本</strong>：test-parallel.mjs（并行测试编排器）、test-live.mjs（真实 API Key 测试）、8 个 e2e/*.sh Docker E2E 测试场景</li>
<li><strong>运维脚本</strong>：committer（原子提交工具，取代手动 git add/commit）、restart-mac.sh（macOS Gateway 重启）、clawlog.sh（macOS 统一日志查询）</li>
</ul>
<div class="blog_h2"><span class="graybg">依赖版本：47 个运行时 + 22 个开发时</span></div>
<p>OpenClaw 的依赖控制极为精简。根 package.json 仅声明 <span style="background-color: #c0c0c0;">47 个运行时依赖</span>和 <span style="background-color: #c0c0c0;">22 个开发时依赖</span>。关键依赖的版本锁定如下：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">依赖</td>
<td style="text-align: center;">版本</td>
<td style="text-align: center;">用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>@mariozechner/pi-agent-core</td>
<td>0.64.0</td>
<td>Agent 运行时核心</td>
</tr>
<tr>
<td>@agentclientprotocol/sdk</td>
<td>0.17.1</td>
<td>ACP 协议 SDK</td>
</tr>
<tr>
<td>@modelcontextprotocol/sdk</td>
<td>1.29.0</td>
<td>MCP 协议 SDK</td>
</tr>
<tr>
<td>matrix-js-sdk</td>
<td>41.3.0-rc.0</td>
<td>Matrix 渠道</td>
</tr>
<tr>
<td>playwright-core</td>
<td>1.58.2</td>
<td>浏览器控制</td>
</tr>
<tr>
<td>sqlite-vec</td>
<td>0.1.9</td>
<td>向量存储</td>
</tr>
<tr>
<td>sharp</td>
<td>^0.34.5</td>
<td>图像处理</td>
</tr>
<tr>
<td>hono</td>
<td>4.12.9</td>
<td>HTTP 框架</td>
</tr>
<tr>
<td>express</td>
<td>^5.2.1</td>
<td>兼容层</td>
</tr>
<tr>
<td>zod</td>
<td>^4.3.6</td>
<td>运行时校验</td>
</tr>
<tr>
<td>ws</td>
<td>^8.20.0</td>
<td>WebSocket</td>
</tr>
<tr>
<td>undici</td>
<td>^7.24.6</td>
<td>HTTP 客户端</td>
</tr>
</tbody>
</table>
<p>开发依赖中最值得关注的是 @typescript/native-preview@7.0.0-dev.20260331.1——这是 TypeScript 官方的 Go 语言重写版预览，OpenClaw 已将其集成为 pnpm tsgo 命令。vitest@4.1.2 搭配 @vitest/coverage-v8 提供覆盖率检测，tsdown@0.21.7 取代 webpack/rollup 作为打包器，oxfmt@0.43.0 和 oxlint@1.58.0 则分别替代 Prettier 和 ESLint。这套工具链的选型思路清晰：用 Rust/Go 编写的原生工具替换 JavaScript 编写的传统方案，以获得数量级的性能提升。</p>
<p>所有带有 pnpm.patchedDependencies 的依赖必须使用精确版本号（不允许 ^ 或 ~ 前缀），且依赖补丁需要显式审批。此外，仓库明确规定"永远不要更新 Carbon 依赖"——这是一条写入 AGENTS.md 的硬性规则。</p>
<div class="blog_h1"><span class="graybg">核心目录</span></div>
<p>上一章给出了 src/ 的一级目录骨架。本章逐一展开每个子目录的内部设计，以代码结构和依赖关系为主线，解释 OpenClaw 核心源码的工程分层。</p>
<div class="blog_h2"><span class="graybg">src/cli/ — CLI 命令入口与进度渲染</span></div>
<p>src/cli/ 是整个 OpenClaw 命令行工具的入口层。它不包含任何业务逻辑，只负责两件事：解析命令行参数并路由到 src/commands/ 中的具体实现，以及在终端渲染结构化进度反馈。</p>
<p>进度反馈的核心位于 src/cli/progress.ts。该模块同时使用两套机制：</p>
<p>第一套是 <span style="background-color: #c0c0c0;">OSC 进度序列</span>（Operating System Command Progress Sequences）。这是一组终端转义码，允许在支持 ConPTY 的 Windows Terminal、iTerm2 和部分 Linux 终端中直接在标题栏或标签页上显示百分比进度条。progress.ts 通过向 stdout 写入 \x1b]9;4;1;{percent}\x07 序列驱动操作系统级别的进度指示器，这使得即使终端窗口被最小化，用户仍可在任务栏中看到安装进度。</p>
<p>第二套是 <span style="background-color: #c0c0c0;">@clack/prompts</span>，一个轻量级的交互式终端 UI 库。OpenClaw 用它实现 onboard 向导中的步骤指示器、多选菜单和确认提示。@clack/prompts 的 spinner 与 OSC 进度可以并行工作——spinner 渲染在 stdout 的当前行，OSC 序列渲染在终端标题栏，两者互不干扰。</p>
<pre class="crayon-plain-tag">// src/cli/progress.ts 核心逻辑简化示意
import { spinner } from '@clack/prompts';

export function emitOscProgress(percent: number): void {
  process.stdout.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
}

export function clearOscProgress(): void {
  process.stdout.write(`\x1b]9;4;0;\x07`);
}

export async function withProgress<T>(
  label: string,
  task: (update: (pct: number) => void) => Promise<T>
): Promise<T> {
  const s = spinner();
  s.start(label);
  const result = await task((pct) => {
    emitOscProgress(pct);
    s.message(`${label} (${pct}%)`);
  });
  clearOscProgress();
  s.stop(`${label} ✔`);
  return result;
}</pre>
<div class="blog_h2"><span class="graybg">src/commands/ — 子命令实现与 Onboard 向导</span></div>
<p>src/commands/ 中的每个文件对应一个顶级 CLI 子命令。文件命名遵循 {command}.ts 模式，如 start.ts、stop.ts、update.ts、onboard.ts、config.ts、plugin.ts。</p>
<p>其中最复杂的是 onboard.ts，即首次运行向导。Onboard 向导的执行流程为：检测系统环境（Node.js 版本、平台、包管理器）→ 选择消息渠道（Telegram/Discord/Slack 等）→ 输入渠道凭据（Bot Token 等）→ 选择 AI Provider（OpenAI/Anthropic/Ollama 等）→ 输入 Provider API Key → 写入配置文件 ~/.openclaw/config.yaml → 执行首次 npm install --omit=dev 安装所选渠道的扩展依赖。整个流程由 @clack/prompts 驱动，每个步骤都有 spinner 和进度条反馈。</p>
<div class="blog_h2"><span class="graybg">src/gateway/ — Gateway 控制平面</span></div>
<p>src/gateway/ 是 OpenClaw 的中枢。它在本地启动一个 WebSocket 服务（默认监听 ws://127.0.0.1:18789），充当所有渠道、插件、原生客户端和 Control UI 之间的 <span style="background-color: #c0c0c0;">单一控制平面</span>（Single Control Plane）。</p>
<p>目录结构大致如下：</p>
<pre class="crayon-plain-tag">src/gateway/
├── server.ts          # WebSocket 服务器生命周期
├── router.ts          # 协议消息分发
├── session.ts         # 会话管理
├── presence.ts        # 在线状态
├── config.ts          # 运行时配置 Hot-reload
├── cron.ts            # 定时任务调度
├── webhooks.ts        # 外部 webhook 接入
├── auth.ts            # 认证模型
├── health.ts          # /healthz, /readyz 端点
├── openai-compat.ts   # /v1/models, /v1/embeddings 兼容层
└── protocol/
    ├── schema.ts      # 协议 Schema 聚合入口
    └── schema/        # 按领域拆分的 Schema 定义文件
        ├── sessions.ts
        ├── nodes.ts
        ├── channels.ts
        └── ...</pre>
<p>protocol/ 子目录是 Gateway 的类型层。所有 WebSocket 消息都经由 protocol/schema.ts 聚合导出的 TypeScript 类型定义进行序列化和反序列化。schema/ 内的文件按领域组织（sessions、nodes、channels 等），每个文件导出请求/响应的 Zod Schema 或 TypeScript interface。这套 Schema 同时被用于 Swift codegen——macOS/iOS 原生应用中的 Gateway 客户端代码由构建脚本从这些 TypeScript 类型自动生成对应的 Swift struct。</p>
<p>Session 管理（session.ts）维护所有活跃会话的内存状态，包括会话 ID、关联渠道、关联 Agent、消息队列深度、最后活跃时间等。Presence（presence.ts）跟踪所有已连接客户端的在线状态，支持原生应用和 Web UI 实时显示哪些渠道处于在线。Cron（cron.ts）提供基于 cron 表达式的定时任务调度，用于周期性检查渠道连接状态和执行清理任务。Webhooks（webhooks.ts）为 Telegram webhook 模式和 Slack Events API 等需要 HTTP 回调的渠道提供端点注册。</p>
<div class="blog_h2"><span class="graybg">src/channels/ — 核心渠道实现</span></div>
<p>src/channels/ 并非一个单一目录——OpenClaw 将核心渠道的代码分散在 src/ 下的多个一级目录中。具体映射关系为：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>渠道</td>
<td>源码位置</td>
<td>底层依赖</td>
</tr>
</thead>
<tbody>
<tr>
<td>Telegram</td>
<td>src/telegram/</td>
<td>grammY</td>
</tr>
<tr>
<td>Discord</td>
<td>src/discord/</td>
<td>discord.js</td>
</tr>
<tr>
<td>Slack</td>
<td>src/slack/</td>
<td>@slack/bolt</td>
</tr>
<tr>
<td>Signal</td>
<td>src/signal/</td>
<td>signal-cli (Java 子进程)</td>
</tr>
<tr>
<td>iMessage</td>
<td>src/imessage/</td>
<td>BlueBubbles HTTP API / 原生 imsg</td>
</tr>
<tr>
<td>WhatsApp</td>
<td>src/web/</td>
<td>Baileys (WhatsApp Web 协议)</td>
</tr>
</tbody>
</table>
<p>src/channels/ 本身作为聚合层存在，定义了所有渠道必须实现的 <span style="background-color: #c0c0c0;">统一消息抽象</span>接口和路由表。每个渠道目录内部的文件结构大致对称：一个 adapter 文件负责将平台 SDK 的事件映射为统一的 inbound 消息格式，一个 sender 文件负责将统一的 outbound 格式转换回平台特定的 API 调用。</p>
<div class="blog_h2"><span class="graybg">src/routing/ — 消息路由引擎</span></div>
<p>消息路由引擎（src/routing/）是渠道系统和 Agent 运行时之间的中间层。它根据配置文件中的路由规则，将 inbound 消息分发到正确的 Agent 实例。路由维度包括：渠道类型、账号 ID、发送者 peer ID、群组 ID、消息内容匹配模式。多 Agent 场景下，路由引擎负责将不同渠道/账号/群组的消息隔离到不同 Agent 的会话中。</p>
<div class="blog_h2"><span class="graybg">src/plugins/ — 插件发现、加载与注册</span></div>
<p>src/plugins/ 是插件系统的运行时宿主，不是插件本身。它包含四个核心模块：</p>
<p><strong>Discovery</strong>：扫描 extensions/ workspace 和 ~/.openclaw/plugins/ 中已安装的 npm 包，查找包含 openclaw.plugin.json 清单文件的包。</p>
<p><strong>Manifest Validation</strong>：用 Zod Schema 严格校验 openclaw.plugin.json 的结构。清单文件中的 id、channel.id、install.npmSpec 等字段必须符合预定义的格式。</p>
<p><strong>Loader</strong>：对通过校验的插件执行动态 import()，加载其入口模块并调用约定的生命周期钩子。</p>
<p><strong>Registry</strong>：维护全局插件注册表，记录每个已加载插件的类型、状态、能力声明。Registry 支持运行时热插拔——新安装的插件可以在不重启 Gateway 的情况下被 discovery → validate → load → register。</p>
<p><strong>Contract Enforcement</strong>：通过 ESLint 规则在构建时确保插件只通过 openclaw/plugin-sdk/* 导入公开 API。任何直接引用 src/ 内部模块的插件都会在 CI 中被拦截。</p>
<div class="blog_h2"><span class="graybg">src/plugin-sdk/ — 唯一合法的插件导入面</span></div>
<p>src/plugin-sdk/ 是 OpenClaw 面向所有外部扩展的 <span style="background-color: #c0c0c0;">唯一公开 API 面</span>。package.json 的 exports 字段精确地声明了 230 条命名导出子路径，每一条都是一个稳定的契约。这 230 条子路径是插件开发的全部合法导入来源——没有例外。该目录的详细分析见下一章。</p>
<div class="blog_h2"><span class="graybg">src/infra/ — 基础设施层</span></div>
<p>src/infra/ 封装与操作系统交互的底层能力。核心组件包括：基于 <span style="background-color: #c0c0c0;">better-sqlite3</span> 的本地持久化层（存储会话历史、插件状态、用户配置等），以及基于 proper-lockfile 的文件锁机制——确保同一台机器上不会出现两个 OpenClaw Gateway 实例同时操作同一个数据目录。SQLite 数据库文件默认位于 ~/.openclaw/data/openclaw.db。</p>
<div class="blog_h2"><span class="graybg">src/media/ — 媒体处理管道</span></div>
<p>src/media/ 实现了统一的媒体处理管道（Media Processing Pipeline）。当渠道接收到图片、音频、视频或文件消息时，该管道负责：下载原始媒体 → 格式检测 → 必要时进行转码（如 Opus → WAV 用于语音转文字）→ 存储到本地缓存 → 生成引用 URL 供 Agent 使用。管道设计为可插拔的，media plugin 可以注册自定义的 processor 处理特定 MIME 类型。</p>
<div class="blog_h1"><span class="graybg">Plugin SDK</span></div>
<p>OpenClaw 的插件系统以 src/plugin-sdk/ 为核心，通过 package.json 的 exports 字段向外部暴露了 230 条精确的命名子路径。这是一个经过严格设计的 <span style="background-color: #c0c0c0;">契约体系</span>（Contract System）——它同时定义了插件能做什么和不能做什么。</p>
<div class="blog_h2"><span class="graybg">230 条导出子路径的分类与结构</span></div>
<p>package.json 的 exports 字段格式如下：</p>
<pre class="crayon-plain-tag">{
  "exports": {
    "./plugin-sdk/channel-types": "./src/plugin-sdk/channel-types.ts",
    "./plugin-sdk/channel-inbound": "./src/plugin-sdk/channel-inbound.ts",
    "./plugin-sdk/channel-reply-pipeline": "./src/plugin-sdk/channel-reply-pipeline.ts",
    "./plugin-sdk/channel-send-result": "./src/plugin-sdk/channel-send-result.ts",
    "./plugin-sdk/channel-dm-security": "./src/plugin-sdk/channel-dm-security.ts",
    "./plugin-sdk/provider-types": "./src/plugin-sdk/provider-types.ts",
    "./plugin-sdk/provider-registry": "./src/plugin-sdk/provider-registry.ts",
    "./plugin-sdk/memory-core-types": "./src/plugin-sdk/memory-core-types.ts",
    "./plugin-sdk/memory-core-store": "./src/plugin-sdk/memory-core-store.ts",
    "./plugin-sdk/plugin-manifest": "./src/plugin-sdk/plugin-manifest.ts",
    "./plugin-sdk/plugin-lifecycle": "./src/plugin-sdk/plugin-lifecycle.ts",
    "./plugin-sdk/runtime-config": "./src/plugin-sdk/runtime-config.ts",
    "./plugin-sdk/runtime-events": "./src/plugin-sdk/runtime-events.ts",
    "./plugin-sdk/media-types": "./src/plugin-sdk/media-types.ts",
    "./plugin-sdk/media-processor": "./src/plugin-sdk/media-processor.ts",
    "./plugin-sdk/speech-types": "./src/plugin-sdk/speech-types.ts",
    "./plugin-sdk/speech-engine": "./src/plugin-sdk/speech-engine.ts"
    // ... 共 230 条
  }
}</pre>
<p>这 230 条子路径按前缀可划分为以下类别：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>前缀</td>
<td>数量（约）</td>
<td>职责</td>
</tr>
</thead>
<tbody>
<tr>
<td>channel-*</td>
<td>~45</td>
<td>渠道类型定义、inbound/outbound 消息、DM 安全策略、群组行为、分块策略</td>
</tr>
<tr>
<td>provider-*</td>
<td>~35</td>
<td>AI Provider 接口、模型注册、能力声明、流式响应协议</td>
</tr>
<tr>
<td>memory-core-*</td>
<td>~20</td>
<td>内存系统核心类型、存储接口、向量索引</td>
</tr>
<tr>
<td>plugin-*</td>
<td>~25</td>
<td>插件清单格式、生命周期钩子、能力声明</td>
</tr>
<tr>
<td>runtime-*</td>
<td>~40</td>
<td>运行时配置、事件总线、日志、错误类型、会话上下文</td>
</tr>
<tr>
<td>media-*</td>
<td>~15</td>
<td>媒体类型、处理器接口、转码管道</td>
</tr>
<tr>
<td>speech-*</td>
<td>~10</td>
<td>语音识别/合成引擎接口</td>
</tr>
<tr>
<td>其他 (tool-*, skill-*, util-* 等)</td>
<td>~40</td>
<td>工具/技能插件接口、通用工具类型</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">导入边界规则：唯一合法导入面</span></div>
<p>OpenClaw 的核心架构约束是：所有外部扩展（extensions/ workspace 中的包以及第三方 npm 包）<strong>只能从 openclaw/plugin-sdk/* 导入</strong>。不允许直接引用 src/ 内部模块，不允许使用相对路径跨越包边界，不允许引用未在 exports 中声明的路径。</p>
<p>这条规则通过四条自定义 ESLint 规则在 CI 中强制执行：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>Lint 规则</td>
<td>作用</td>
</tr>
</thead>
<tbody>
<tr>
<td>lint:extensions:no-plugin-sdk-internal</td>
<td>禁止 extensions/ 中的代码导入 plugin-sdk 的内部实现文件（非 exports 声明路径）</td>
</tr>
<tr>
<td>lint:extensions:no-relative-outside-package</td>
<td>禁止 extensions/ 中的代码使用相对路径引用包外部的文件</td>
</tr>
<tr>
<td>lint:extensions:no-src-outside-plugin-sdk</td>
<td>禁止 extensions/ 中的代码直接引用 src/ 下非 plugin-sdk 的任何模块</td>
</tr>
<tr>
<td>lint:plugins:no-extension-imports</td>
<td>禁止 src/ 核心代码反向引用 extensions/ 中的模块（防止反向依赖）</td>
</tr>
</tbody>
</table>
<p>这四条规则共同构成了一个严格的 <span style="background-color: #c0c0c0;">依赖防火墙</span>：核心代码和扩展代码之间的边界是单向的、受控的、可审计的。</p>
<div class="blog_h2"><span class="graybg">五种插件类型</span></div>
<p>OpenClaw 定义了五种插件类型，每种对应 plugin-sdk 中的一组子路径：</p>
<p><strong>Channel Plugin（渠道插件）</strong>：实现一个新的消息平台适配器。必须提供 channel-inbound 和 channel-reply-pipeline 的完整实现。清单文件中必须声明 channel.id。</p>
<p><strong>Provider Plugin（Provider 插件）</strong>：接入一个新的 AI 模型提供商。需实现 provider-types 中定义的接口，包括模型列举、Chat Completion 流、Embedding 等。</p>
<p><strong>Tool Plugin（工具插件）</strong>：为 Agent 添加新的可调用工具。通过 tool-* 子路径注册工具定义，包括 JSON Schema 参数描述和执行函数。</p>
<p><strong>Skill Plugin（技能插件）</strong>：预打包的复合能力（如 "搜索网页并总结"），可包含多个 tool 的编排逻辑。</p>
<p><strong>Media Plugin（媒体插件）</strong>：注册自定义的媒体处理器，处理特定 MIME 类型的文件。</p>
<div class="blog_h2"><span class="graybg">插件清单：openclaw.plugin.json</span></div>
<p>每个插件的元数据由包根目录下的 openclaw.plugin.json 声明：</p>
<pre class="crayon-plain-tag">{
  "id": "openclaw-channel-matrix",
  "version": "2026.4.1",
  "type": "channel",
  "channel": {
    "id": "matrix",
    "displayName": "Matrix",
    "supportsGroups": true,
    "supportsDM": true
  },
  "install": {
    "npmSpec": "@openclaw/channel-matrix@latest"
  },
  "minCoreVersion": "2026.3.1",
  "entrypoint": "./dist/index.js"
}</pre>
<p>关键字段：id 是全局唯一标识符；channel.id 在 type 为 channel 时必须提供，用于路由表匹配；install.npmSpec 指定安装时使用的 npm 包标识符；minCoreVersion 声明兼容的最低 OpenClaw 核心版本。</p>
<div class="blog_h2"><span class="graybg">本地桶文件：api.ts 与 runtime-api.ts</span></div>
<p>src/plugin-sdk/ 内部有两个重要的 <span style="background-color: #c0c0c0;">桶文件</span>（Barrel File）：api.ts 和 runtime-api.ts。</p>
<p>api.ts 聚合所有纯类型导出——接口定义、类型别名、枚举等。它是编译时依赖，不包含任何运行时代码。runtime-api.ts 聚合需要运行时实现的模块——工厂函数、注册器、事件发射器等。两者的分离确保了：如果插件只需要类型信息（如纯粹的 TypeScript 类型守卫），可以仅依赖 api.ts，不会引入任何运行时代码，保持 tree-shaking 友好。</p>
<div class="blog_h2"><span class="graybg">插件安装与依赖约束</span></div>
<p>插件安装通过 npm install --omit=dev 执行，只安装生产依赖。关键约束：插件的 package.json 中禁止使用 workspace:* 协议作为 dependencies——这是因为第三方插件安装在用户机器上时不处于 monorepo workspace 上下文中，workspace:* 会解析失败。CI 中有专门的检查脚本拦截此类违规。</p>
<div class="blog_h2"><span class="graybg">遗留 Provider 兼容子路径的弃用</span></div>
<p>v2026.3.31 是一个 <span style="background-color: #c0c0c0;">Breaking Change</span> 版本。此前，plugin-sdk 中保留了一组以 provider-compat-* 为前缀的遗留子路径，用于向后兼容早期 Provider 接口。v2026.3.31 正式移除了这些路径。依赖旧接口的第三方 Provider 插件必须迁移到新的 provider-* 子路径。迁移指南位于 docs/migration/v2026.3.31-provider-compat.md。</p>
<div class="blog_h1"><span class="graybg">Gateway 架构</span></div>
<p>Gateway 是 OpenClaw 的核心运行时进程。它不是一个可选组件——所有渠道消息、Agent 调度、插件通信、原生客户端交互都经由 Gateway 路由。理解 Gateway 就是理解 OpenClaw 的运行时全貌。</p>
<div class="blog_h2"><span class="graybg">单一本地控制平面</span></div>
<p>Gateway 的设计哲学是 <span style="background-color: #c0c0c0;">Single Local Control Plane</span>——本地机器上只有一个 Gateway 实例运行，它是所有组件的通信枢纽。启动命令 openclaw start 实际上就是启动 Gateway 进程。Gateway 在 ws://127.0.0.1:18789（默认端口）上监听 WebSocket 连接，同时在同一端口提供 HTTP 端点。</p>
<p>所有组件都是 Gateway 的客户端：渠道适配器（Telegram bot、Discord bot 等）在内部通过 WebSocket 向 Gateway 报告 inbound 消息；Agent 运行时从 Gateway 接收任务并返回响应；原生应用（macOS、iOS、Android）通过 WebSocket 连接 Gateway 获取实时状态；Control UI（Web 界面）同样是一个 WebSocket 客户端。</p>
<div class="blog_h2"><span class="graybg">类型化协议：protocol/schema</span></div>
<p>Gateway 的 WebSocket 协议是完全类型化的。协议定义位于 src/gateway/protocol/schema.ts，它从 src/gateway/protocol/schema/ 目录中聚合导出所有子模块。每个子模块对应一个协议领域：</p>
<pre class="crayon-plain-tag">// src/gateway/protocol/schema/sessions.ts
import { z } from 'zod';

export const SessionPatchRequest = z.object({
  method: z.literal('sessions.patch'),
  params: z.object({
    sessionId: z.string(),
    patch: z.object({
      thinkingLevel: z.enum(['off','minimal','low','medium','high','xhigh']).optional(),
      activeAgent: z.string().optional(),
      queueMode: z.enum(['sequential','parallel']).optional(),
    }),
  }),
});

export const SessionPatchResponse = z.object({
  result: z.object({
    sessionId: z.string(),
    applied: z.record(z.unknown()),
  }),
});

// src/gateway/protocol/schema/nodes.ts
export const NodeListRequest = z.object({
  method: z.literal('node.list'),
  params: z.object({
    filter: z.object({
      type: z.enum(['channel','agent','plugin','tool']).optional(),
      status: z.enum(['online','offline','error']).optional(),
    }).optional(),
  }),
});

export const NodeDescribeRequest = z.object({
  method: z.literal('node.describe'),
  params: z.object({ nodeId: z.string() }),
});

export const NodeInvokeRequest = z.object({
  method: z.literal('node.invoke'),
  params: z.object({
    nodeId: z.string(),
    action: z.string(),
    payload: z.unknown(),
  }),
});</pre>
<p>协议采用类 JSON-RPC 的请求/响应模式。核心方法包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>方法</td>
<td>用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>sessions.patch</td>
<td>修改会话参数（thinking level、活跃 agent、队列模式等）</td>
</tr>
<tr>
<td>sessions.list</td>
<td>列举所有活跃会话及其状态</td>
</tr>
<tr>
<td>node.list</td>
<td>列举所有已注册节点（渠道、Agent、插件、工具）</td>
</tr>
<tr>
<td>node.describe</td>
<td>获取指定节点的详细信息和能力声明</td>
</tr>
<tr>
<td>node.invoke</td>
<td>向指定节点发送操作指令（如要求渠道发送消息、要求 Agent 执行任务）</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">Swift Codegen</span></div>
<p>macOS/iOS 原生应用需要与 Gateway 通信。为保证 TypeScript 协议定义与 Swift 客户端代码的一致性，OpenClaw 在构建流程中包含一个 <span style="background-color: #c0c0c0;">Swift codegen</span> 步骤。构建脚本解析 src/gateway/protocol/schema/ 中的 Zod Schema，自动生成对应的 Swift Codable struct 和 enum。生成的代码位于 apps/macos/Generated/ 和 apps/ios/Generated/。这意味着协议变更只需修改 TypeScript Schema，Swift 侧自动同步，不存在手动同步遗漏的风险。</p>
<div class="blog_h2"><span class="graybg">认证模型</span></div>
<p>Gateway 支持三种认证模式，按优先级排列：</p>
<p><strong>trusted-proxy</strong>：Gateway 信任来自特定代理（如 Nginx、Cloudflare Tunnel）的请求，依据代理注入的 HTTP 头进行身份识别。这是生产环境推荐模式。</p>
<p><strong>local-direct</strong>：当 WebSocket 连接来自 127.0.0.1 时，跳过认证直接授权。这是本地开发和单机部署的默认行为。</p>
<p><strong>gateway token</strong>：通过配置文件设置的静态 Token，客户端在 WebSocket 握手时通过 Authorization: Bearer <token> 头携带。用于远程访问场景。</p>
<p>v2026.3.31 引入了一个重要的安全变更：<span style="background-color: #c0c0c0;">trusted-proxy 模式下，如果检测到多个客户端使用同一个 shared-token，Gateway 将拒绝连接</span>。此前这种 "多人共享一个 token" 的配置虽然不推荐但能工作，新版本将其升级为硬性错误。这是因为 shared-token 场景下无法区分不同用户的会话，会导致消息路由混乱。</p>
<div class="blog_h2"><span class="graybg">OpenAI 兼容端点与健康检查</span></div>
<p>Gateway 在 HTTP 层暴露了一组 <span style="background-color: #c0c0c0;">OpenAI 兼容端点</span>：</p>
<p>/v1/models：返回当前配置的所有可用模型列表，格式兼容 OpenAI List Models API。这使得任何兼容 OpenAI API 的客户端（如 Cursor、Continue 等）可以直接将 OpenClaw Gateway 作为模型提供方。</p>
<p>/v1/embeddings：提供文本向量化接口，格式兼容 OpenAI Embeddings API。后端可路由到实际配置的 Embedding Provider（OpenAI、Ollama 本地模型等）。</p>
<p>健康检查端点遵循 Kubernetes 惯例：</p>
<p>/healthz：存活探针（Liveness Probe），只要 Gateway 进程在运行就返回 200。</p>
<p>/readyz：就绪探针（Readiness Probe），只有当至少一个渠道连接成功且 Agent 运行时已初始化时才返回 200。适用于负载均衡器判断节点是否可以接收流量。</p>
<div class="blog_h2"><span class="graybg">Control UI 与 Bridge Protocol</span></div>
<p>Gateway 直接serve一个 Web 管理界面——<span style="background-color: #c0c0c0;">Control UI</span>。该 UI 使用 <span style="background-color: #c0c0c0;">Lit 3</span>（Web Components 框架）+ <span style="background-color: #c0c0c0;">Vite</span>（构建工具）开发，源码位于 ui/ 目录。构建产物在发布时嵌入 Gateway 的静态资源中，通过 HTTP 直接访问（默认 http://127.0.0.1:18789）。Control UI 本身也是一个 WebSocket 客户端，与 Gateway 保持长连接以实现实时状态更新。</p>
<p>Bridge Protocol（桥接协议）的规范文档位于 docs/gateway/bridge-protocol.md，它定义了原生应用与 Gateway 之间的通信约定——包括消息编码格式、心跳机制、重连策略、事件订阅模型。这份文档是原生应用开发者的核心参考。</p>
<div class="blog_h1"><span class="graybg">渠道系统</span></div>
<p>OpenClaw 在 v2026.4.1 中支持 24 个消息渠道。渠道系统的核心工程挑战在于：如何将 24 个各具特色、API 风格迥异的消息平台，抽象为一套统一的 inbound/outbound 消息模型，同时保留每个平台的独特能力。</p>
<div class="blog_h2"><span class="graybg">24 渠道全景</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>渠道</td>
<td>底层实现</td>
<td>类型</td>
</tr>
</thead>
<tbody>
<tr>
<td>WhatsApp</td>
<td>Baileys (WhatsApp Web 逆向协议)</td>
<td>核心渠道 (src/web/)</td>
</tr>
<tr>
<td>Telegram</td>
<td>grammY</td>
<td>核心渠道 (src/telegram/)</td>
</tr>
<tr>
<td>Slack</td>
<td>@slack/bolt</td>
<td>核心渠道 (src/slack/)</td>
</tr>
<tr>
<td>Discord</td>
<td>discord.js</td>
<td>核心渠道 (src/discord/)</td>
</tr>
<tr>
<td>Signal</td>
<td>signal-cli (Java 子进程)</td>
<td>核心渠道 (src/signal/)</td>
</tr>
<tr>
<td>BlueBubbles (iMessage)</td>
<td>BlueBubbles HTTP API</td>
<td>核心渠道 (src/imessage/)，推荐方式</td>
</tr>
<tr>
<td>iMessage (legacy imsg)</td>
<td>原生 AppleScript/osascript</td>
<td>核心渠道，已标记为遗留</td>
</tr>
<tr>
<td>Google Chat</td>
<td>Google Chat API</td>
<td>内置扩展</td>
</tr>
<tr>
<td>IRC</td>
<td>irc-framework</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Microsoft Teams</td>
<td>Teams SDK (v2026.3.28 升级后版本)</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Matrix</td>
<td>matrix-js-sdk + @matrix-org/crypto-wasm</td>
<td>内置扩展 (extensions/)</td>
</tr>
<tr>
<td>Feishu (飞书)</td>
<td>Feishu Open API</td>
<td>内置扩展</td>
</tr>
<tr>
<td>LINE</td>
<td>@line/bot-sdk</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Mattermost</td>
<td>Mattermost REST API + WebSocket</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Nextcloud Talk</td>
<td>Nextcloud Talk API</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Nostr</td>
<td>nostr-tools</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Synology Chat</td>
<td>Synology Chat Webhook</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Tlon</td>
<td>Tlon API</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Twitch</td>
<td>tmi.js</td>
<td>内置扩展</td>
</tr>
<tr>
<td>Zalo</td>
<td>Zalo Official Account API</td>
<td>内置扩展 (extensions/)</td>
</tr>
<tr>
<td>Zalo Personal</td>
<td>Zalo Personal API (ZaloUser)</td>
<td>内置扩展 (extensions/)</td>
</tr>
<tr>
<td>Voice Call</td>
<td>VoIP/SIP 集成</td>
<td>内置扩展 (extensions/)</td>
</tr>
<tr>
<td>WeChat (微信)</td>
<td>@tencent-weixin/openclaw-weixin (iLink Bot API)</td>
<td>官方合作插件</td>
</tr>
<tr>
<td>WebChat</td>
<td>Gateway 内置 WebSocket 聊天</td>
<td>核心渠道</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">渠道契约类型</span></div>
<p>渠道系统的类型契约由三个核心文件定义：</p>
<p>types.plugin.ts：面向插件开发者的公开类型。渠道插件必须实现的接口在此定义，包括 ChannelAdapter（渠道适配器）、ChannelSender（消息发送器）、ChannelConfig（渠道配置 Schema）。</p>
<p>types.core.ts：核心内部类型，不通过 plugin-sdk 导出。包含路由表条目、会话绑定关系、内部消息信封（Envelope）格式。</p>
<p>types.adapters.ts：适配器辅助类型，定义各平台 SDK 事件到统一 inbound 格式的映射接口。</p>
<div class="blog_h2"><span class="graybg">统一消息抽象</span></div>
<p>统一消息抽象是渠道系统的核心设计。它由三个 plugin-sdk 子路径定义：</p>
<p>channel-inbound：定义所有渠道 inbound 消息的统一结构。无论消息来自 WhatsApp、Telegram 还是 Discord，经过渠道适配器处理后都被转换为相同的 InboundMessage 类型。该类型包含：channelId、peerId（发送者标识）、groupId（群组标识，DM 时为 null）、content（文本/媒体/混合内容）、replyTo（引用消息 ID）、timestamp、rawEvent（平台原始事件，用于渠道特定逻辑）。</p>
<p>channel-reply-pipeline：定义 Agent 响应经过的处理管道。管道阶段包括：内容格式化（Markdown → 平台特定格式）→ 长消息分块（per-channel chunking）→ 媒体附件处理 → 平台 API 调用。</p>
<p>channel-send-result：定义消息发送结果的统一结构，包括平台返回的消息 ID、发送状态（成功/失败/部分成功）、错误信息。</p>
<div class="blog_h2"><span class="graybg">群组路由：@提及门控与回复标签</span></div>
<p>在群组场景中，Agent 默认不响应所有消息——这会导致群组中的噪声。OpenClaw 实现了 <span style="background-color: #c0c0c0;">@提及门控</span>（Mention Gating）：只有当消息中包含对 Bot 的 @mention 时，Agent 才会处理该消息。这一行为可通过配置按渠道/群组覆盖为 always 模式（响应所有消息）。</p>
<p>回复标签（Reply Tags）解决了另一个群组问题：当多条消息同时进入时，Agent 的回复需要标记其对应的原始消息。在 Telegram 中这通过 reply_to_message_id 实现，在 Discord 中通过 Message Reference 实现，在 Slack 中通过 Thread TS 实现。渠道适配器负责将统一的 replyTo 字段映射为平台特定的回复机制。</p>
<p>长消息分块（Per-channel Chunking）是另一个平台差异处理点。Telegram 单条消息上限 4096 字符，Discord 为 2000 字符，WhatsApp 约 65536 字符。channel-reply-pipeline 中的分块阶段根据目标渠道的限制，将超长 Agent 响应拆分为多条消息，同时确保代码块、Markdown 列表等结构不被截断在中间。</p>
<div class="blog_h2"><span class="graybg">DM 安全策略</span></div>
<p>私聊（DM）场景有独立的安全模型，由 channel-dm-security 子路径定义。核心是 dmPolicy 配置项，支持三种模式：</p>
<p>pairing：用户必须先发送配对码（Pairing Code）才能激活 DM 对话。配对码由 openclaw pair 命令生成，一次性使用。这是最安全的模式。</p>
<p>allowlist：只有 allowFrom 配置中列出的用户 ID/手机号才能发起 DM 对话。</p>
<p>open：任何人都可以直接发起 DM 对话。仅建议在可控环境（如内网部署）中使用。</p>
<div class="blog_h2"><span class="graybg">渠道特定亮点</span></div>
<p><strong>WhatsApp</strong>：基于 Baileys 库，使用 WhatsApp Web 协议。首次连接需要扫描 QR 码完成登录，QR 码在终端中以 ASCII art 形式渲染，同时在 Control UI 中以图片形式展示。会话凭证持久化到本地文件系统，后续启动自动恢复。</p>
<p><strong>Telegram</strong>：支持两种运行模式——长轮询（Long Polling，默认）和 <span style="background-color: #c0c0c0;">Webhook 模式</span>。Webhook 模式下，Gateway 注册一个公开 HTTPS 端点接收 Telegram 推送，延迟更低但需要公网可达的地址（通常通过 Cloudflare Tunnel 或 ngrok 实现）。grammY 框架提供了完整的 Bot API 类型安全封装。</p>
<p><strong>Discord</strong>：支持原生 Slash Commands（/ask、/image 等）和纯文本命令两种交互模式。discord.js 提供了丰富的事件模型，OpenClaw 利用其 Message Component 能力实现了交互式按钮和选择菜单。</p>
<p><strong>Microsoft Teams</strong>：v2026.3.28 版本对 Teams 集成进行了重大升级，迁移到新版 Teams SDK。新版本支持流式回复（Streaming Replies），Agent 的响应可以实时逐字显示在 Teams 对话中，并带有 AI 注释标签（AI Annotation），让用户明确知道当前回复来自 AI。</p>
<p><strong>WeChat（微信）</strong>：通过官方合作渠道实现，使用 @tencent-weixin/openclaw-weixin 包，底层接入 iLink Bot API。当前仅支持私聊消息，不支持群聊。v2.x 版本要求 OpenClaw 核心版本 ≥ 2026.3.22。</p>
<div class="blog_h1"><span class="graybg">Agent 运行时</span></div>
<p>OpenClaw 的 Agent 运行时构建在 <span style="background-color: #c0c0c0;">Pi Agent</span> 之上。这不是一个自研的 Agent 框架，而是对外部库 @mariozechner/pi-agent-core@0.64.0 和 @mariozechner/pi-ai@0.64.0 的深度集成。Pi 生态还包括 pi-coding-agent（代码生成专用 Agent）和 pi-tui（终端 UI）。</p>
<div class="blog_h2"><span class="graybg">RPC 模式：工具流与块流</span></div>
<p>Pi Agent 以 <span style="background-color: #c0c0c0;">RPC 模式</span>运行时，支持两种流式输出协议：</p>
<p><strong>Tool Streaming（工具流）</strong>：Agent 调用工具时，工具的执行过程和中间结果以流式方式返回。例如，Agent 调用搜索工具时，搜索结果的每一条都作为一个流事件推送，而不是等待所有结果返回后才一次性输出。</p>
<p><strong>Block Streaming（块流）</strong>：Agent 的文本响应以块为单位流式输出。一个 "块" 可以是一个段落、一个代码块或一个列表。块流比逐 token 流更适合消息渠道场景——渠道适配器可以在每个块完成时立即发送，而不是积累整个响应后发送，也避免了逐 token 发送导致的频繁 API 调用。</p>
<div class="blog_h2"><span class="graybg">Session 模型</span></div>
<p>OpenClaw 的 Session 模型是理解消息路由的关键。每个 Agent 维护多个独立的会话（Session），会话之间完全隔离：</p>
<p><strong>DM Session</strong>：与每个私聊用户的对话构成一个独立会话。会话由 (agentId, channelId, peerId) 三元组唯一标识。</p>
<p><strong>Group Session</strong>：每个群组一个独立会话，由 (agentId, channelId, groupId) 三元组标识。群组会话与 DM 会话完全隔离——Agent 在群组中看不到同一用户的私聊历史，反之亦然。</p>
<p>会话的激活模式（Activation Mode）控制 Agent 何时响应：mention 模式下，只有 @mention 才触发响应；always 模式下，所有消息都触发响应。DM 会话默认为 always，群组会话默认为 mention。</p>
<p>队列模式（Queue Mode）控制并发消息的处理策略：sequential 模式下，消息严格按接收顺序逐条处理；parallel 模式下，多条消息可并行处理（适用于无状态的工具调用场景）。</p>
<p>Reply-back 路由确保 Agent 的响应被发送到正确的渠道和对话。当 Agent 通过工具调用触发了跨渠道操作时（如在 Telegram 对话中要求 Agent 向 Slack 频道发送消息），reply-back 路由负责将操作结果路由回发起请求的 Telegram 对话。</p>
<div class="blog_h2"><span class="graybg">Session 工具：Agent 间协调</span></div>
<p>三个内置工具使 Agent 具备了 <span style="background-color: #c0c0c0;">跨会话/跨 Agent 协调能力</span>：</p>
<pre class="crayon-plain-tag">// sessions_list: 列举当前所有活跃会话
{
  name: 'sessions_list',
  description: 'List all active sessions with their channel, peer, and status',
  parameters: {
    filter: { type: 'object', properties: {
      channelId: { type: 'string' },
      status: { enum: ['active', 'idle', 'archived'] }
    }}
  }
}

// sessions_history: 读取指定会话的历史消息
{
  name: 'sessions_history',
  description: 'Read message history from a specific session',
  parameters: {
    sessionId: { type: 'string' },
    limit: { type: 'number', default: 50 }
  }
}

// sessions_send: 向指定会话发送消息（实现 Agent-to-Agent 通信）
{
  name: 'sessions_send',
  description: 'Send a message to a specific session (enables agent-to-agent coordination)',
  parameters: {
    sessionId: { type: 'string' },
    content: { type: 'string' }
  }
}</pre>
<p>sessions_send 是多 Agent 协调的关键。Agent A 可以通过 sessions_list 发现 Agent B 的会话，通过 sessions_send 向 Agent B 发送指令或查询，Agent B 的响应会通过 reply-back 路由返回 Agent A 的会话上下文。</p>
<div class="blog_h2"><span class="graybg">多 Agent 路由</span></div>
<p>OpenClaw 支持在同一实例中运行多个 Agent，每个 Agent 有独立的配置和会话空间。路由规则在配置文件中定义，支持按渠道、账号、peer 三个维度将 inbound 消息分发到不同 Agent：</p>
<pre class="crayon-plain-tag"># config.yaml 多 Agent 路由示例
agents:
  - id: general-assistant
    provider: openai
    model: gpt-4o
    routes:
      - channel: telegram
        account: "@mybot"
      - channel: discord
        account: "bot-token-1"

  - id: coding-helper
    provider: anthropic
    model: claude-sonnet-4-20250514
    routes:
      - channel: slack
        account: "workspace-1"
        peers: ["U12345678"]  # 仅特定用户的消息路由到此 Agent</pre>
<p>每个 Agent 拥有独立的 workspace 和 session 存储，实现完全隔离。</p>
<div class="blog_h2"><span class="graybg">Agent Workspace 与注入文件</span></div>
<p>每个 Agent 的运行时上下文由 ~/.openclaw/workspace/ 目录提供。该目录下的三个特殊文件会被自动注入到 Agent 的系统提示词中：</p>
<p>AGENTS.md：定义 Agent 的角色、行为准则和约束。这是 Agent 人格的核心定义文件。</p>
<p>SOUL.md：更细粒度的人格描述——语气、对话风格、知识领域偏好等。</p>
<p>TOOLS.md：工具使用指南，告诉 Agent 每个可用工具的使用场景和最佳实践。</p>
<p>这三个文件均为 Markdown 格式，用户可以自由编辑。修改后无需重启——Gateway 在每次会话消息处理前会检查文件的 mtime，如有变更则重新加载。</p>
<div class="blog_h2"><span class="graybg">Session 持久化、修剪与压缩</span></div>
<p>会话历史以 JSONL（JSON Lines）格式持久化到 ~/.openclaw/agents/<agentId>/sessions/*.jsonl。每个会话一个文件，每行一条消息记录。JSONL 格式的选择是经过考量的：它支持 append-only 写入（崩溃安全），支持按行增量读取（内存效率），且可以直接用标准文本工具检查。</p>
<p>长时间运行的会话会积累大量历史，导致 context window 溢出和延迟增加。OpenClaw 提供了两种应对机制：</p>
<p><strong>Session Pruning（会话修剪）</strong>：自动删除超过配置时间窗口（默认 7 天）的旧消息。修剪操作在会话被激活时触发，是懒惰式的。</p>
<p><strong>Session Compaction（会话压缩）</strong>：通过 /compact 命令手动触发。压缩过程调用 AI 模型将长历史总结为精简的上下文摘要，替换原始的逐条消息记录。压缩后的会话文件体积可缩减 80% 以上，同时保留关键上下文信息。</p>
<div class="blog_h2"><span class="graybg">Thinking Levels 与 Idle-stream Timeout</span></div>
<p>OpenClaw 暴露了对 AI 模型 "思考深度" 的精细控制。thinkingLevel 参数支持六个级别：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>级别</td>
<td>行为</td>
</tr>
</thead>
<tbody>
<tr>
<td>off</td>
<td>禁用扩展思考（Extended Thinking），直接生成响应</td>
</tr>
<tr>
<td>minimal</td>
<td>最小思考预算</td>
</tr>
<tr>
<td>low</td>
<td>低思考预算，适合简单任务</td>
</tr>
<tr>
<td>medium</td>
<td>中等思考预算，默认值</td>
</tr>
<tr>
<td>high</td>
<td>高思考预算，适合复杂推理</td>
</tr>
<tr>
<td>xhigh</td>
<td>极高思考预算，用于需要深度推理的场景</td>
</tr>
</tbody>
</table>
<p>Thinking Level 可以在会话级别通过 sessions.patch 协议方法动态调整，也可以在配置文件中设置全局默认值。支持扩展思考的 Provider（如 Anthropic Claude）会根据级别调整思考 token 的预算上限。</p>
<p>v2026.3.31 引入的 <span style="background-color: #c0c0c0;">idle-stream timeout</span> 解决了一个实际运维问题：当模型流（Model Stream）长时间无新 token 输出时（例如模型服务端卡住或网络中断），Agent 会一直等待而不释放会话锁，导致该会话的后续消息全部堆积。idle-stream timeout 允许配置一个超时时间（默认 120 秒），当流在指定时间内无新数据时，Agent 会主动中断流并返回部分响应或错误消息。此超时时间可在配置文件中按 Provider 调整——使用本地 Ollama 模型时可能需要更长的超时。</p>
<div class="blog_h1"><span class="graybg">内存系统</span></div>
<p>AI 助手的个性化能力取决于记忆系统的深度。OpenClaw 的内存子系统 <span style="background-color: #c0c0c0;">memory-core</span> 是整个项目中模块拆分最细致的部分，由 13 个子模块组成，全部通过 plugin-sdk 导出。设计目标明确：所有记忆数据以本地 Markdown 文件和 SQLite 数据库的形式持久化，用户可直接编辑、可 Git 版本控制、可离线运行。</p>
<div class="blog_h2"><span class="graybg">13 个子模块的职责划分</span></div>
<p>plugin-sdk 中与记忆相关的导出共 13 个路径，每个路径对应一个独立的编译单元：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">导出路径</td>
<td style="text-align: center;">职责</td>
</tr>
</thead>
<tbody>
<tr>
<td>memory-core</td>
<td>根模块，定义 MemoryStore 接口、MemoryEntry 类型、TTL 策略和序列化契约</td>
</tr>
<tr>
<td>memory-core-engine-runtime</td>
<td>引擎运行时，将内存操作绑定到当前 Agent 运行时生命周期</td>
</tr>
<tr>
<td>memory-core-host-engine-embeddings</td>
<td>嵌入引擎宿主：调度 Embedding 模型计算向量，管理批量嵌入队列</td>
</tr>
<tr>
<td>memory-core-host-engine-foundation</td>
<td>基础引擎宿主：提供 tokenizer 绑定、向量维度协商、距离度量选择</td>
</tr>
<tr>
<td>memory-core-host-engine-qmd</td>
<td>QMD（Query-Memory-Document）引擎：将用户查询与记忆文档进行语义匹配</td>
</tr>
<tr>
<td>memory-core-host-engine-storage</td>
<td>存储引擎宿主：抽象底层存储后端（SQLite、LanceDB），提供统一 CRUD</td>
</tr>
<tr>
<td>memory-core-host-multimodal</td>
<td>多模态记忆：处理图片、音频等非文本记忆条目的索引与检索</td>
</tr>
<tr>
<td>memory-core-host-query</td>
<td>查询宿主：构建语义搜索查询，合并关键词过滤与向量相似度</td>
</tr>
<tr>
<td>memory-core-host-runtime-cli</td>
<td>CLI 运行时宿主：暴露 openclaw memory search 等终端命令</td>
</tr>
<tr>
<td>memory-core-host-runtime-core</td>
<td>核心运行时宿主：记忆系统的初始化、迁移和生命周期管理</td>
</tr>
<tr>
<td>memory-core-host-runtime-files</td>
<td>文件运行时宿主：监控 Markdown 记忆文件的变更并触发重新索引</td>
</tr>
<tr>
<td>memory-core-host-secret</td>
<td>密钥宿主：管理记忆存储的加密密钥与 SecretRef 解析</td>
</tr>
<tr>
<td>memory-core-host-status</td>
<td>状态宿主：报告索引进度、向量数量、最近查询延迟等运行指标</td>
</tr>
</tbody>
</table>
<p>这种拆分方式遵循 OpenClaw 的插件架构原则：每个子模块可以被独立替换或禁用，核心系统只依赖 memory-core 根模块定义的接口，而不直接依赖任何具体存储后端。</p>
<div class="blog_h2"><span class="graybg">本地 Markdown 文件持久化</span></div>
<p>OpenClaw 的记忆系统将用户偏好和长期上下文存储为本地 Markdown 文件，默认位于 ~/.openclaw/memory/ 目录。每个记忆文件是标准 Markdown，带有 YAML front-matter 元数据：</p>
<pre class="crayon-plain-tag">---
type: preference
created: 2026-03-15T08:22:00Z
updated: 2026-04-01T14:30:00Z
tags: [coding-style, language]
---

# Coding Preferences

- Preferred language: TypeScript with strict mode
- Tab width: 2 spaces
- Always use explicit return types
- Prefer functional composition over class inheritance</pre>
<p>这种设计的核心优势在于三点：第一，用户可以直接用任何文本编辑器修改记忆内容，无需进入 OpenClaw 的界面；第二，记忆文件可以纳入 Git 版本控制，团队成员可以共享和同步偏好配置；第三，记忆内容完全离线可用，不依赖任何云端服务。memory-core-host-runtime-files 模块通过文件系统监听（fs.watch）检测 Markdown 文件的变更，自动触发重新索引流程——解析 front-matter、提取正文、计算嵌入向量、更新向量存储。</p>
<div class="blog_h2"><span class="graybg">向量存储：sqlite-vec 与 LanceDB 双后端</span></div>
<p>语义搜索依赖向量存储。OpenClaw 提供两个后端选项：</p>
<p><span style="background-color: #c0c0c0;">sqlite-vec</span>（版本 0.1.9）是默认后端。它是 SQLite 的向量搜索扩展，以 npm 依赖 sqlite-vec@0.1.9 的形式声明在 package.json 中。sqlite-vec 将向量存储为 SQLite 表中的 BLOB 列，支持精确最近邻（Exact KNN）和基于量化的近似最近邻（ANN）搜索。对于个人使用场景——通常记忆条目在数百到数千量级——sqlite-vec 的精确 KNN 已经足够高效，查询延迟在亚毫秒级别。sqlite-vec 的优势与 OpenClaw 的本地优先哲学完全一致：单文件数据库，零外部依赖，可直接备份和迁移。</p>
<p><span style="background-color: #c0c0c0;">memory-lancedb</span> 是第二个后端，同样通过 plugin-sdk 导出。LanceDB 是一个嵌入式向量数据库，底层使用 Lance 列式格式，支持 IVF-PQ 索引，适合记忆条目达到十万量级的场景。memory-core-host-engine-storage 模块通过统一的存储抽象层隔离这两个后端，上层代码无需感知底层实现差异：</p>
<pre class="crayon-plain-tag">// memory-core-host-engine-storage 抽象接口
export interface VectorStorageBackend {
  insert(entries: MemoryEntry[]): Promise<void>;
  search(query: Float32Array, topK: number, filter?: MemoryFilter): Promise<ScoredEntry[]>;
  delete(ids: string[]): Promise<void>;
  count(): Promise<number>;
  vacuum(): Promise<void>;
}</pre>
<div class="blog_h2"><span class="graybg">嵌入管道与语义搜索</span></div>
<p>memory-core-host-engine-embeddings 管理嵌入计算的完整管道。当记忆文件被创建或修改时，该模块执行以下流程：</p>
<ol>
<li>解析 Markdown 文件，将正文按段落分块（chunking），每块控制在 512 token 以内</li>
<li>调用当前配置的嵌入模型（Embedding Model）计算向量，默认使用提供者插件中配置的嵌入端点</li>
<li>将向量与元数据（来源文件路径、chunk 偏移、时间戳、标签）一起写入向量存储</li>
<li>维护一个增量索引：仅对变更的块重新计算嵌入，未修改的块保留原有向量</li>
</ol>
<p>memory-core-host-engine-qmd（QMD 引擎）负责查询时的语义匹配。QMD 的全称是 Query-Memory-Document，它实现一个三阶段检索流程：先对用户查询计算嵌入向量，然后在向量存储中执行近似最近邻搜索获取候选集，最后用 BM25 关键词评分对候选集重排序（Re-ranking）。memory-core-host-query 模块负责构建查询对象，将语义相似度阈值、标签过滤、时间范围等条件组合为统一的查询描述符。</p>
<p>记忆系统是 OpenClaw 个性化能力的基石。Agent 运行时在处理每一轮对话时，都会通过 memory-core-engine-runtime 检索相关记忆并注入到系统提示词中。这个过程对用户透明，但直接影响 Agent 回复的个性化程度——它知道用户偏好的编程语言、代码风格、常用工具链，甚至过去对话中建立的项目上下文。</p>
<div class="blog_h1"><span class="graybg">模型提供者</span></div>
<p>OpenClaw 的模型提供者（Model Provider）系统是其多模型支持能力的核心。通过 plugin-sdk 导出的提供者插件超过 25 个，覆盖主流商业 API、开源推理引擎和云平台网关。每个提供者是一个独立插件，遵循统一的注册、认证和模型目录协议。</p>
<div class="blog_h2"><span class="graybg">提供者插件架构</span></div>
<p>每个提供者插件由四个核心文件组成：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">文件</td>
<td style="text-align: center;">职责</td>
</tr>
</thead>
<tbody>
<tr>
<td>provider-entry.ts</td>
<td>插件入口点，注册提供者到插件注册表，声明支持的功能特性（Feature Flags）</td>
</tr>
<tr>
<td>provider-auth.ts</td>
<td>认证逻辑，实现 API Key 或 OAuth 流程</td>
</tr>
<tr>
<td>provider-catalog-shared.ts</td>
<td>模型目录，列出该提供者支持的所有模型及其能力标记（文本/图像/代码等）</td>
</tr>
<tr>
<td>provider-model-shared.ts</td>
<td>模型共享配置，定义 token 限制、定价信息、上下文窗口大小等元数据</td>
</tr>
</tbody>
</table>
<p>提供者插件通过 plugin-sdk 的导出路径注册。以 OpenAI 提供者为例，导出路径为 plugin-sdk/provider-openai，Anthropic 为 plugin-sdk/provider-anthropic，以此类推。</p>
<div class="blog_h2"><span class="graybg">完整提供者清单</span></div>
<p>截至 v2026.4.1，plugin-sdk 导出以下提供者：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">提供者</td>
<td style="text-align: center;">模型举例</td>
<td style="text-align: center;">认证方式</td>
</tr>
</thead>
<tbody>
<tr>
<td>OpenAI</td>
<td>GPT-4o, o3, o4-mini, Codex</td>
<td>API Key / OAuth</td>
</tr>
<tr>
<td>Anthropic (Claude)</td>
<td>Claude Sonnet 4, Opus 4</td>
<td>API Key / OAuth</td>
</tr>
<tr>
<td>Google (Gemini)</td>
<td>Gemini 2.5 Pro, Flash</td>
<td>API Key</td>
</tr>
<tr>
<td>DeepSeek</td>
<td>DeepSeek-V3, DeepSeek-R1</td>
<td>API Key</td>
</tr>
<tr>
<td>xAI (Grok)</td>
<td>Grok-3, Grok-3-mini</td>
<td>API Key</td>
</tr>
<tr>
<td>Ollama</td>
<td>本地部署任意 GGUF 模型</td>
<td>无（本地）</td>
</tr>
<tr>
<td>Mistral</td>
<td>Mistral Large, Codestral</td>
<td>API Key</td>
</tr>
<tr>
<td>MiniMax</td>
<td>MiniMax-Text-01, image-01</td>
<td>API Key</td>
</tr>
<tr>
<td>Moonshot（月之暗面）</td>
<td>Kimi</td>
<td>API Key</td>
</tr>
<tr>
<td>ModelStudio（通义千问）</td>
<td>Qwen-Max, Qwen-Plus</td>
<td>API Key</td>
</tr>
<tr>
<td>Qianfan（百度文心）</td>
<td>ERNIE-4.0, ERNIE-Speed</td>
<td>API Key</td>
</tr>
<tr>
<td>NVIDIA</td>
<td>Nemotron, Llama 3 NVIDIA</td>
<td>API Key</td>
</tr>
<tr>
<td>HuggingFace</td>
<td>Inference API 托管模型</td>
<td>API Token</td>
</tr>
<tr>
<td>Together</td>
<td>Llama, Mixtral 等开源模型</td>
<td>API Key</td>
</tr>
<tr>
<td>Venice</td>
<td>隐私优先推理</td>
<td>API Key</td>
</tr>
<tr>
<td>vLLM</td>
<td>自托管 vLLM 实例</td>
<td>自定义</td>
</tr>
<tr>
<td>SGLang</td>
<td>自托管 SGLang 实例</td>
<td>自定义</td>
</tr>
<tr>
<td>BytePlus（火山引擎）</td>
<td>豆包大模型</td>
<td>API Key</td>
</tr>
<tr>
<td>Cloudflare AI Gateway</td>
<td>Workers AI 代理</td>
<td>API Token</td>
</tr>
<tr>
<td>Amazon Bedrock</td>
<td>Claude on Bedrock, Titan</td>
<td>AWS IAM</td>
</tr>
<tr>
<td>Anthropic Vertex</td>
<td>Claude on Vertex AI</td>
<td>GCP Service Account</td>
</tr>
<tr>
<td>Chutes</td>
<td>GPU 推理市场</td>
<td>API Key</td>
</tr>
<tr>
<td>KiloCode</td>
<td>KiloCode 模型</td>
<td>API Key</td>
</tr>
<tr>
<td>Kimi Coding</td>
<td>Kimi 代码模型</td>
<td>API Key</td>
</tr>
<tr>
<td>OpenCode / OpenCode Go</td>
<td>开源代码推理</td>
<td>API Key</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">认证系统：双模认证与密钥管理</span></div>
<p>认证子系统由四个模块组成：provider-auth-api-key（API Key 认证）、provider-auth-login（OAuth 登录认证）、provider-auth-result（认证结果封装）和 provider-auth-runtime（运行时认证状态管理）。</p>
<p>大多数提供者支持 API Key 单一模式，但 OpenAI 和 Anthropic 等主流提供者同时支持 <span style="background-color: #c0c0c0;">OAuth 登录</span>和 API Key 两种方式。OAuth 模式下，用户通过浏览器完成授权后，OpenClaw 获取 access token 并自动管理刷新流程。这种双模设计（Auth Rotation）允许用户在免费额度用完后无缝切换到自有 API Key，反之亦然。</p>
<p><span style="background-color: #c0c0c0;">合成认证（Synthetic Auth）</span>通过 resolveSyntheticAuth 函数实现。当多个提供者共享同一底层认证（例如 Anthropic Vertex 使用 GCP 凭据，而不是 Anthropic 原生 API Key）时，合成认证将底层凭据转换为提供者期望的格式。实现位于认证运行时模块中：</p>
<pre class="crayon-plain-tag">// provider-auth-runtime 中的合成认证解析
export async function resolveSyntheticAuth(
  provider: ProviderId,
  secretStore: SecretStore
): Promise<AuthResult> {
  const secretRef = getProviderSecretRef(provider);
  const rawCredential = await secretStore.resolve(secretRef);

  // 根据提供者类型转换凭据格式
  switch (provider) {
    case 'anthropic-vertex':
      return synthesizeVertexAuth(rawCredential as GCPServiceAccount);
    case 'amazon-bedrock':
      return synthesizeBedrockAuth(rawCredential as AWSCredentials);
    default:
      return { type: 'api-key', key: rawCredential as string };
  }
}</pre>
<p><span style="background-color: #c0c0c0;">SecretRef</span> 是 OpenClaw 的凭据引用语义。凭据不以明文存储在配置文件中，而是通过 SecretRef 引用操作系统的密钥链（macOS Keychain、Windows Credential Manager、Linux Secret Service）。SecretRef 的格式为 secretref:</p>
<provider>:<key-name>，运行时由 memory-core-host-secret 模块解析为实际凭据值。</p>
<div class="blog_h2"><span class="graybg">模型故障转移</span></div>
<p>模型故障转移（Model Failover）是 OpenClaw 应对 API 速率限制和服务中断的核心机制（详见 <a href="https://docs.openclaw.ai/concepts/model-failover">docs.openclaw.ai/concepts/model-failover</a>）。当主模型返回 429（Rate Limited）或 5xx 错误时，系统自动将请求路由到预配置的备选模型。故障转移配置在用户的 settings 文件中定义：</p>
<pre class="crayon-plain-tag">{
  "models": {
    "primary": "anthropic:claude-sonnet-4-20260514",
    "fallback": [
      "openai:gpt-4o",
      "google:gemini-2.5-pro"
    ],
    "failover": {
      "maxRetries": 2,
      "retryDelayMs": 1000,
      "fallbackOnRateLimit": true,
      "fallbackOnServerError": true
    }
  }
}</pre>
<p>故障转移逻辑在路由层（src/routing/）实现，对上层 Agent 运行时透明。路由层维护每个提供者的健康状态和速率限制窗口，在主模型不可用时按 fallback 列表顺序尝试备选模型。</p>
<div class="blog_h2"><span class="graybg">v2026.3.28 新增特性</span></div>
<p>v2026.3.28 版本对提供者系统引入了三项重要变更：</p>
<p><strong>xAI 迁移至 Responses API</strong>：xAI 提供者从传统的 Chat Completions API 迁移到 Responses API 格式，同时启用 x_search 原生网页搜索功能。Grok 模型可直接在对话中调用 xAI 的搜索基础设施，无需额外的工具调用层。</p>
<p><strong>MiniMax 图像生成</strong>：MiniMax 提供者新增 image-01 模型支持，通过 MiniMax 的图像生成 API 实现文生图能力。该功能作为提供者拥有的工具（Provider-owned Tool）注册，遵循 OpenClaw 的设计原则——提供者特有的工具和设置归属于提供者插件，而非核心系统。</p>
<p><strong>通义千问认证变更</strong>：Qwen 的 portal auth 模式被移除，统一切换为 Model Studio API Key 认证。这是一次破坏性变更，已有的 portal auth 用户需要手动迁移到 API Key 模式。</p>
<div class="blog_h2"><span class="graybg">GitHub Copilot 登录支持</span></div>
<p>OpenClaw 通过 plugin-sdk/github-copilot-login 和 plugin-sdk/github-copilot-token 两个导出模块支持 GitHub Copilot 账户登录。拥有 Copilot 订阅的用户可以直接使用 GitHub 账户认证，通过 Copilot 的基础设施访问底层模型（GPT-4o、Claude 等），无需单独配置每个提供者的 API Key。认证流程复用 GitHub 的 Device Flow OAuth，获取 Copilot token 后由 github-copilot-token 模块管理令牌刷新。</p>
<div class="blog_h1"><span class="graybg">ACP 协议</span></div>
<p><span style="background-color: #c0c0c0;">Agent Client Protocol（ACP）</span>是 OpenClaw 定义的有状态 Agent 会话协议。ACP 的核心思想是将 AI Agent 的交互从特定的聊天界面中解耦出来，使其可以通过任意通信渠道（Discord、iMessage、终端等）启动和管理有状态的 Agent 工作会话。项目依赖 @agentclientprotocol/sdk@0.17.1 提供协议的核心类型和客户端实现。</p>
<div class="blog_h2"><span class="graybg">ACPX：无头 CLI 工具</span></div>
<p><span style="background-color: #c0c0c0;">ACPX</span>（仓库 openclaw/acpx，1,834 stars）是 OpenClaw 的无头（Headless）ACP CLI 客户端。它允许用户从命令行创建、管理和交互 ACP 会话，无需图形界面。ACPX 的典型使用场景包括 CI/CD 管道中的 Agent 自动化、服务器端部署和脚本编排。</p>
<div class="blog_h2"><span class="graybg">ACP 渠道绑定</span></div>
<p>ACP 会话可以绑定到任意聊天渠道。通过 /acp spawn codex --bind here 命令，用户可以在当前渠道上下文中创建一个 ACP 会话。目前支持的绑定包括：</p>
<ul>
<li><strong>Discord</strong>：通过 Discord Bot 渠道绑定，ACP 会话映射到 Discord 线程</li>
<li><strong>BlueBubbles</strong>：macOS 上的 iMessage 桥接，ACP 会话通过 BlueBubbles API 接入 iMessage</li>
<li><strong>iMessage</strong>：直接 iMessage 绑定（仅 macOS/iOS 平台）</li>
</ul>
<p>ACP 的核心分层需要明确区分三个概念：<span style="background-color: #c0c0c0;">聊天表面（Chat Surface）</span>是用户交互的 UI 层，可以是 Discord 频道、终端窗口或 Web 界面；<span style="background-color: #c0c0c0;">ACP 会话（ACP Session）</span>是有状态的 Agent 交互上下文，维护对话历史、工作区状态和工具授权；<span style="background-color: #c0c0c0;">运行时工作区（Runtime Workspace）</span>是 Agent 实际执行操作的文件系统沙箱。一个聊天表面可以关联多个 ACP 会话，而每个 ACP 会话绑定到唯一的运行时工作区。</p>
<div class="blog_h2"><span class="graybg">MCP 集成与工具桥接</span></div>
<p>OpenClaw 集成了 <span style="background-color: #c0c0c0;">Model Context Protocol（MCP）</span>，依赖 @modelcontextprotocol/sdk@1.29.0。MCP 定义了 AI 模型与外部工具之间的标准通信协议，OpenClaw 通过 MCP 桥接层将外部 MCP 工具服务器暴露给 Agent 运行时。</p>
<p>v2026.3.31 版本引入了 <span style="background-color: #c0c0c0;">ACPX plugin-tools MCP 桥接</span>的关键安全变更：MCP 工具默认关闭（explicit default-off），必须在配置中显式启用。这一变更源于信任边界加固（Trust Boundary Hardening）的安全考量——外部 MCP 工具服务器可能执行任意代码，默认启用会扩大攻击面。启用配置示例：</p>
<pre class="crayon-plain-tag">{
  "mcp": {
    "servers": {
      "filesystem": {
        "command": "npx",
        "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"],
        "enabled": true
      }
    },
    "trustPolicy": "prompt-per-tool"
  }
}</pre>
<p>trustPolicy 支持三个级别：prompt-per-tool（每次工具调用需用户确认）、prompt-once（首次确认后自动信任）和 trust-all（完全信任，仅建议在受控环境中使用）。</p>
<div class="blog_h2"><span class="graybg">OpenAI apply_patch 默认启用</span></div>
<p>对于 OpenAI 和 Codex 系列模型，OpenClaw 默认启用 apply_patch 工具。这是 OpenAI Codex 模型原生支持的代码编辑工具，直接通过 API 返回结构化的补丁指令（patch instruction），由 OpenClaw 的运行时执行文件修改。相较于让模型输出完整文件内容再做 diff，apply_patch 减少了输出 token 消耗，降低了大文件编辑时的错误率。apply_patch 的沙箱权限与 write 权限对齐——在非主会话的 Docker 沙箱中，apply_patch 的写入范围受到与普通文件写入相同的约束。</p>
<div class="blog_h2"><span class="graybg">CLI 后端插件：Claude CLI / Codex CLI / Gemini CLI</span></div>
<p>v2026.3.31 将三个主要 CLI 后端——<span style="background-color: #c0c0c0;">Claude CLI</span>、<span style="background-color: #c0c0c0;">Codex CLI</span> 和 <span style="background-color: #c0c0c0;">Gemini CLI</span>——的推理默认行为迁移到各自的 bundled plugin 中。通过 Plugin SDK 的 cli-backend 和 cli-runtime 导出路径，CLI 后端可以注册自定义的推理流、工具暴露和会话管理策略。这一迁移的意义在于解耦——核心不再硬编码 CLI 后端的行为，第三方插件可以通过相同的接口注册自定义 CLI 后端。</p>
<div class="blog_h2"><span class="graybg">ACP 与 Agent-to-Agent 通信</span></div>
<p>ACP 的深层价值体现在 <span style="background-color: #c0c0c0;">Agent-to-Agent（A2A）通信</span>能力上。OpenClaw 的 Session 工具集——sessions_list、sessions_history、sessions_send——允许一个 Agent 会话发现、查询和向另一个 Agent 会话发送消息。sessions_send 支持可选的 reply-back 模式（ping-pong 通信）和 announce 步骤，允许 Agent 之间进行结构化的协调对话。</p>
<p>在多 Agent 部署场景下（例如一个 Agent 负责客户对话，另一个 Agent 负责后端任务执行），A2A 通信避免了传统架构中需要外部消息队列的复杂性。所有通信都通过 Gateway 的 WebSocket 控制平面路由，Agent 之间共享同一个运行时基础设施但拥有隔离的会话上下文和工作区。</p>
<p>v2026.3.31 新增的 ACP 渠道绑定进一步扩展了这一能力：/acp spawn codex --bind here 可以将当前聊天表面直接绑定为 Codex 驱动的工作区，无需创建子线程。通过这种方式，用户可以在 Discord 频道中直接启动一个代码编写 Agent，Agent 的输出直接出现在对话流中。</p>
<div class="blog_h1"><span class="graybg">媒体管道</span></div>
<p>OpenClaw 的媒体处理管道位于 src/media/ 目录，负责所有非文本内容的预处理、理解和生命周期管理。通过 plugin-sdk 导出三个核心模块：media-runtime（运行时管道调度）、media-understanding（媒体内容理解接口）和 media-understanding-runtime（理解模块的运行时绑定）。另有 web-media 导出处理 Web 渠道的媒体特化逻辑。</p>
<div class="blog_h2"><span class="graybg">图像处理：sharp 管道</span></div>
<p>图像处理依赖 sharp@0.34.5——Node.js 生态中性能最高的图像处理库，底层使用 libvips。OpenClaw 使用 sharp 执行以下处理：</p>
<ul>
<li>缩放（Resize）：将用户上传的图像缩放到模型支持的最大分辨率，避免浪费 token 或超出 API 限制</li>
<li>格式转换：将 BMP、TIFF、WebP 等格式统一转换为 JPEG 或 PNG，确保所有提供者都能接收</li>
<li>元数据剥离：移除 EXIF 信息中的地理位置、设备信息等隐私数据</li>
<li>缩略图生成：为 UI 显示生成低分辨率预览</li>
</ul>
<p>文件类型检测使用 file-type@22.0.0，通过魔数（Magic Number）而非文件扩展名判断文件类型，防止恶意文件伪装。</p>
<div class="blog_h2"><span class="graybg">PDF 处理</span></div>
<p>PDF 处理依赖 pdfjs-dist@5.6.205（Mozilla PDF.js 的 npm 发行版）。处理流程包括文本提取、页面渲染为图像（用于多模态模型的视觉理解）和结构化内容解析。对于大型 PDF，OpenClaw 实现分页处理策略——仅提取与当前对话上下文相关的页面范围，而非一次性加载整个文档。</p>
<div class="blog_h2"><span class="graybg">音频与视频处理</span></div>
<p>音频和视频处理管道处理用户上传的多媒体文件或通过语音输入采集的音频流。音频处理包括格式转换（统一为 WAV/MP3）、采样率标准化和静音检测。转录（Transcription）钩子将音频输入转换为文本，集成到 Agent 的对话流程中——语音消息被自动转录后作为文本消息处理，Agent 可以选择性地以语音或文本方式回复。</p>
<p>视频处理采用关键帧提取策略：从视频中按固定间隔或场景变化检测提取关键帧，将其作为图像序列发送给多模态模型进行理解，避免处理完整视频流的高昂计算成本。</p>
<div class="blog_h2"><span class="graybg">大小限制与临时文件生命周期</span></div>
<p>每个渠道（Channel）可独立配置媒体文件的大小上限。例如 Discord 渠道的配置：</p>
<pre class="crayon-plain-tag">{
  "channels": {
    "discord": {
      "mediaMaxMb": 25
    },
    "web": {
      "mediaMaxMb": 100
    },
    "cli": {
      "mediaMaxMb": 500
    }
  }
}</pre>
<p>超过限制的文件在预处理阶段即被拒绝，不进入管道后续环节。临时文件（处理过程中的中间产物）遵循严格的生命周期管理：每个媒体处理任务创建独立的临时目录，处理完成后无论成功还是失败都会清理。media-runtime 模块维护一个临时文件注册表，进程退出时执行兜底清理（cleanup-on-exit），防止磁盘泄漏。</p>
<div class="blog_h2"><span class="graybg">媒体理解与多模态接入</span></div>
<p>media-understanding 和 media-understanding-runtime 两个 SDK 导出路径定义了媒体内容理解的接口和运行时实现。媒体理解不仅仅是格式转换——它将图像、文档和音频转化为模型可以消费的结构化输入。对于图像，理解管道提取图中的文字（OCR）、识别对象和场景；对于 PDF，它生成页面摘要和结构化的段落索引；对于音频，它输出时间戳标注的转录文本。</p>
<p>多模态理解的输出格式遵循各模型提供者的要求。OpenAI 的 GPT-4o 和 Anthropic 的 Claude Sonnet 4 接受 base64 编码的图像嵌入消息体；Google Gemini 支持更大的媒体文件通过 File API 上传后引用。media-understanding-runtime 的职责是根据当前活跃的模型提供者，选择最优的编码和传输策略。</p>
<div class="blog_h2"><span class="graybg">媒体管道的可读性阅读器</span></div>
<p>OpenClaw 集成了 @mozilla/readability@0.6.0（Mozilla 的可读性提取库）和 linkedom@0.18.12（轻量 DOM 实现），用于从网页内容中提取正文。当 Agent 使用浏览器工具访问网页时，原始 HTML 经 linkedom 解析后，Readability 算法提取核心正文内容，剥除导航栏、广告、侧边栏等噪声元素。提取后的纯文本进入 Agent 的上下文窗口，相比注入原始 HTML 大幅减少 token 消耗。</p>
<p>Markdown 渲染由 markdown-it@14.1.1 处理。Agent 输出的 Markdown 格式回复在发送到各渠道前，根据目标渠道的能力进行格式适配：Discord 原生支持 Markdown，Telegram 支持部分 Markdown 子集，WhatsApp 使用 WhatsApp 风格的文本格式化，而 SMS/iMessage 则退化为纯文本。</p>
<div class="blog_h1"><span class="graybg">语音系统</span></div>
<p>OpenClaw 的语音系统覆盖了从语音唤醒到语音合成的完整链路。plugin-sdk 导出三个语音模块：speech（公共接口）、speech-core（核心实现）和 speech-runtime（运行时绑定）。语音功能根据平台和交互模式分为四种形态。</p>
<div class="blog_h2"><span class="graybg">Voice Wake：macOS/iOS 唤醒词</span></div>
<p><span style="background-color: #c0c0c0;">Voice Wake</span>（详见 <a href="https://docs.openclaw.ai/nodes/voicewake">docs.openclaw.ai/nodes/voicewake</a>）是 macOS 和 iOS 平台的唤醒词功能。设备持续监听环境音频，检测到预设唤醒词后激活 Agent 会话。唤醒词检测在设备端本地运行，不向云端发送音频流——这与 OpenClaw 的本地优先原则一致。</p>
<p>唤醒后的消息转发通过 <span style="background-color: #c0c0c0;">VoiceWakeForwarder</span> 实现。用户语音经本地语音识别转为文本后，VoiceWakeForwarder 调用 OpenClaw 的 CLI 接口将文本传递给 Agent：</p>
<pre class="crayon-plain-tag">openclaw-mac agent --message "${text}" --thinking low</pre>
<p>VoiceWakeForwarder 的实现中需要特别处理 Shell 转义（Shell Escaping）：用户语音转录文本可能包含引号、美元符号、反引号等 Shell 特殊字符，直接拼接到命令行会导致注入风险或解析错误。转发器对文本进行严格的 Shell 转义后再传递。--thinking low 参数指示 Agent 使用低延迟思考模式，优先响应速度而非推理深度，适配语音交互对实时性的要求。</p>
<div class="blog_h2"><span class="graybg">Talk Mode：Android 持续语音</span></div>
<p><span style="background-color: #c0c0c0;">Talk Mode</span>（详见 <a href="https://docs.openclaw.ai/nodes/talk">docs.openclaw.ai/nodes/talk</a>）是 Android 平台的持续语音对话模式。与 Voice Wake 的"唤醒→单次交互"模式不同，Talk Mode 维持一个持续开放的语音通道——用户和 Agent 可以进行多轮语音对话，无需每轮重新唤醒。Talk Mode 使用 VAD（Voice Activity Detection，语音活动检测）自动判断用户发言的起止，实现自然的对话节奏。</p>
<div class="blog_h2"><span class="graybg">Push-to-Talk：macOS 覆盖层</span></div>
<p>macOS 平台还提供 <span style="background-color: #c0c0c0;">Push-to-Talk</span> 模式，以系统级覆盖层（Overlay）的形式运行。用户通过长按快捷键激活麦克风输入，松开后结束录音并发送。这种模式适合在桌面工作流中快速提问，无需切换到 OpenClaw 的窗口。覆盖层使用 AppKit 的 NSPanel 实现，设置为浮动在所有窗口之上。</p>
<div class="blog_h2"><span class="graybg">TTS：ElevenLabs 与系统回退</span></div>
<p>语音合成（TTS，Text-to-Speech）采用双层策略。首选方案是 <span style="background-color: #c0c0c0;">ElevenLabs</span> 的 API，提供高质量、低延迟、多语言的语音合成。当 ElevenLabs 不可用（网络离线或未配置 API Key）时，系统自动回退到平台原生 TTS：macOS 使用 AVSpeechSynthesizer，iOS 使用 AVSpeechSynthesizer（同一框架），Android 使用 android.speech.tts.TextToSpeech。</p>
<p>此外，OpenClaw 还集成了 node-edge-tts@1.2.10 作为第三层 TTS 后端。Edge TTS 调用 Microsoft Edge 浏览器的在线 TTS 服务，免费且支持多语言多音色，在无 ElevenLabs 订阅但有网络连接的场景下是实用的中间选项。</p>
<div class="blog_h2"><span class="graybg">Voice Call 插件与闭环测试</span></div>
<p>Voice Call 插件打包在 extensions/ 目录中，作为内置扩展随 OpenClaw 分发。它实现了完整的语音通话功能——用户可以像打电话一样与 Agent 进行实时语音对话，双向音频流通过 WebRTC 或平台原生音频框架传输。</p>
<p>语音通话的质量保证依赖 <span style="background-color: #c0c0c0;">闭环测试（Closed-Loop Testing）</span>。测试脚本通过 test:voicecall:closedloop npm script 执行，流程如下：自动生成测试文本 → TTS 合成为音频 → 音频作为输入馈送给语音通话管道 → Agent 处理并生成回复 → TTS 合成回复音频 → 转录回复音频为文本 → 对比原始文本与回复内容的语义一致性。这种端到端闭环消除了人工测试的不确定性，确保语音管道中每个环节（ASR → 推理 → TTS）都正常工作。</p>
<pre class="crayon-plain-tag"># 执行语音通话闭环测试
pnpm test:voicecall:closedloop

# 测试流程：
# 1. 生成测试 prompt
# 2. TTS 合成输入音频
# 3. 注入音频到 voice call 管道
# 4. 等待 Agent 响应
# 5. 捕获 TTS 输出音频
# 6. ASR 转录输出
# 7. 断言：输出文本与预期语义匹配</pre>
<p>整个语音系统体现了 OpenClaw 的多端一致性追求：同一个 Agent 可以通过唤醒词、持续语音、按键说话或语音通话四种方式接收语音输入，通过 ElevenLabs、Edge TTS 或系统原生 TTS 三种方式输出语音回复，所有组合在各个平台上的行为保持一致。语音能力不是一个附加功能，而是与文本渠道同等地位的一等交互模式。</p>
<p><!-- Chapter 13-17: OpenClaw 深度技术解析 --></p>
<div class="blog_h1"><span class="graybg">原生多端应用</span></div>
<p>OpenClaw 的多端战略并非简单的 WebView 包装。macOS、iOS、Android 三个原生客户端各自承担着差异化的职责：macOS 应用是开发者的本地控制台与调试中心，iOS 应用是移动端的轻量节点（Node），Android 应用则面向最广泛的设备指令族群。三者通过 Gateway WebSocket 协议统一通信，实现跨平台的节点注册、指令派发与画布同步。</p>
<div class="blog_h2"><span class="graybg">13.1 macOS 应用：菜单栏中枢</span></div>
<p>macOS 应用的源码位于 apps/macos/，采用 SwiftUI + AppKit 混合架构，以菜单栏常驻图标为交互入口。在 OpenClaw 内部词汇表中，macOS 应用的代号是 <span style="background-color: #c0c0c0;">makeup</span>（即 "mac app" 的谐音缩写）。</p>
<p>应用的核心功能涵盖以下几个层面：</p>
<p><span style="background-color: #c0c0c0;">Gateway 健康监控</span>：菜单栏图标实时反映 Gateway 进程状态，包括连接数、内存占用与心跳延迟。点击图标弹出的面板提供一键重启入口。Gateway 的重启必须通过 OpenClaw Mac 应用本身或 scripts/restart-mac.sh 脚本执行，而非手动在 tmux 中操作——后者会绕过进程监控链，导致状态不一致。</p>
<p><span style="background-color: #c0c0c0;">语音唤醒与 Push-to-Talk 悬浮层</span>：Voice Wake 持续监听唤醒词，PTT（Push-to-Talk）覆盖层以半透明悬浮窗的形式驻留桌面。两者共同构成语音交互的 macOS 原生入口。</p>
<p><span style="background-color: #c0c0c0;">WebChat 嵌入与调试工具</span>：内嵌 WebChat 视图支持与 Gateway 的实时对话，同时暴露调试面板用于查看消息流、工具调用日志与 token 消耗。</p>
<p><span style="background-color: #c0c0c0;">SSH 隧道远程控制</span>：macOS 应用可通过 SSH 隧道连接远程部署的 Gateway 实例，在本地菜单栏操控云端服务。</p>
<div class="blog_h3"><span class="graybg">13.1.1 SwiftUI 状态管理：Observation 框架</span></div>
<p>macOS 应用的状态管理已全面迁移至 Swift 5.9 引入的 <span style="background-color: #c0c0c0;">Observation 框架</span>，使用 @Observable 宏标记可观察类型，以 @Bindable 实现属性级的双向绑定。旧版 ObservableObject / @StateObject / @Published 模式已被显式弃用 — 任何残留的 legacy 用法都应迁移至新框架。Observation 框架的优势在于更细粒度的依赖追踪：SwiftUI 仅在被实际读取的属性发生变化时重新渲染视图，而非 ObservableObject 的整体通知模式。</p>
<pre class="crayon-plain-tag">// 正确：Observation 框架
@Observable
final class GatewayMonitor {
    var isConnected = false
    var latencyMs: Int = 0
    var sessionCount: Int = 0
}

// 错误：已弃用的旧模式，不要使用
// class GatewayMonitor: ObservableObject {
//     @Published var isConnected = false
// }</pre>
<div class="blog_h3"><span class="graybg">13.1.2 签名构建与 TCC 权限</span></div>
<p>macOS 应用需要签名构建才能使系统权限在重新编译后持久化。未签名的开发构建在每次 rebuild 后都会触发 TCC（Transparency, Consent, and Control）权限重置弹窗。打包脚本位于 scripts/package-mac-app.sh，负责代码签名、公证（Notarization）与 DMG 封装。</p>
<p>macOS 节点模式（Node Mode）暴露的系统能力通过 TCC 权限映射管控：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">节点命令</td>
<td style="text-align: center;">功能</td>
<td style="text-align: center;">TCC 权限</td>
</tr>
</thead>
<tbody>
<tr>
<td>system.run</td>
<td>执行本地命令，返回 stdout/stderr/exit code</td>
<td>needsScreenRecording 标志位</td>
</tr>
<tr>
<td>system.notify</td>
<td>发送用户通知</td>
<td>notifications</td>
</tr>
<tr>
<td>canvas.*</td>
<td>画布操作路由</td>
<td>screen-recording</td>
</tr>
<tr>
<td>camera.*</td>
<td>摄像头抓取</td>
<td>camera</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">13.1.3 统一日志系统</span></div>
<p>macOS 应用的日志通过 scripts/clawlog.sh 脚本统一查询，底层使用 macOS 的 Unified Logging 系统，支持按子系统（subsystem）过滤。常见操作：</p>
<pre class="crayon-plain-tag"># 实时跟踪所有 OpenClaw 子系统日志
./scripts/clawlog.sh --follow

# 按类别过滤
./scripts/clawlog.sh --category networking --tail 100

# 查看特定子系统
./scripts/clawlog.sh --subsystem ai.openclaw.gateway</pre>
<div class="blog_h2"><span class="graybg">13.2 iOS 应用：移动节点</span></div>
<p>iOS 应用源码位于 apps/ios/，是一个标准 Xcode 项目 + SwiftUI 工程。与 macOS 应用不同，iOS 应用的定位是作为 Gateway 的远程节点运行，通过 <span style="background-color: #c0c0c0;">Bonjour 设备发现</span>（Device Discovery）机制自动配对局域网内的 Gateway 实例，并通过 Gateway WebSocket 建立持久连接。</p>
<p>iOS 节点提供的核心能力：</p>
<p><span style="background-color: #c0c0c0;">Canvas Surface</span>：在 iOS 设备上渲染 Agent 驱动的画布内容，支持触摸交互。</p>
<p><span style="background-color: #c0c0c0;">Voice Wake 转发</span>：iOS 端的语音唤醒检测结果通过 WebSocket 转发至 Gateway，实现移动端的免触语音激活。</p>
<p><span style="background-color: #c0c0c0;">Talk Mode</span>：长按说话的语音交互模式，音频流直接传输至 Gateway 进行识别与处理。</p>
<p><span style="background-color: #c0c0c0;">Camera Snap/Clip</span>：支持拍照快照和短视频片段采集，供 Agent 的视觉能力使用。</p>
<p><span style="background-color: #c0c0c0;">Screen Recording</span>：通过 ReplayKit 进行屏幕录制，将录制内容作为上下文发送给 Agent。</p>
<p>iOS 应用的版本号维护在两个位置：apps/ios/Sources/Info.plist 和 apps/ios/Tests/Info.plist，关键字段为 CFBundleShortVersionString（展示版本号）和 CFBundleVersion（构建号）。发版时两个文件必须同步更新。</p>
<div class="blog_h2"><span class="graybg">13.3 Android 应用：全能设备节点</span></div>
<p>Android 应用位于 apps/android/，使用 Kotlin + Gradle 构建。与 iOS 应用相比，Android 节点暴露了更丰富的设备指令族群（Device Command Families），充分利用 Android 平台的开放性。</p>
<p>应用 UI 组织为三个主要标签页：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">标签页</td>
<td style="text-align: center;">功能</td>
</tr>
</thead>
<tbody>
<tr>
<td>Connect</td>
<td>设备配对入口，支持设置码（Setup Code）和手动输入两种方式</td>
</tr>
<tr>
<td>Chat Sessions</td>
<td>会话列表与聊天界面</td>
</tr>
<tr>
<td>Voice</td>
<td>语音交互控制面板</td>
</tr>
</tbody>
</table>
<p>Android 节点支持的设备指令族群是三端中最丰富的：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">指令族</td>
<td style="text-align: center;">能力</td>
</tr>
</thead>
<tbody>
<tr>
<td>notifications</td>
<td>读取/发送系统通知</td>
</tr>
<tr>
<td>location</td>
<td>GPS 定位与地理围栏</td>
</tr>
<tr>
<td>SMS</td>
<td>短信读取与发送</td>
</tr>
<tr>
<td>photos</td>
<td>相册访问与照片上传</td>
</tr>
<tr>
<td>contacts</td>
<td>通讯录读写</td>
</tr>
<tr>
<td>calendar</td>
<td>日历事件管理</td>
</tr>
<tr>
<td>motion</td>
<td>加速度计、陀螺仪等传感器数据</td>
</tr>
<tr>
<td>app update</td>
<td>应用自更新管理</td>
</tr>
</tbody>
</table>
<p>此外，Android 端同样支持 Canvas 渲染、摄像头采集与屏幕录制能力。</p>
<div class="blog_h3"><span class="graybg">13.3.1 Android 构建与测试命令</span></div>
<pre class="crayon-plain-tag"># 单元测试（Play Debug 变体）
./gradlew :app:testPlayDebugUnitTest

# 第三方集成测试
./gradlew :app:testThirdPartyDebugUnitTest

# Kotlin 代码风格检查
./gradlew :app:ktlintCheck :benchmark:ktlintCheck

# 发布 AAB 构建
bun apps/android/scripts/build-release-aab.ts</pre>
<p>版本信息定义在 apps/android/app/build.gradle.kts 中的 versionName（展示版本）和 versionCode（数字递增版本）。</p>
<div class="blog_h2"><span class="graybg">13.4 跨平台节点协议</span></div>
<p>三个原生应用通过统一的 <span style="background-color: #c0c0c0;">Gateway WebSocket 协议</span>与 Gateway 通信。节点相关的核心命令包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">命令</td>
<td style="text-align: center;">方向</td>
<td style="text-align: center;">功能</td>
</tr>
</thead>
<tbody>
<tr>
<td>node.list</td>
<td>Gateway → 客户端</td>
<td>枚举已连接的所有节点及其能力声明</td>
</tr>
<tr>
<td>node.describe</td>
<td>Gateway → 节点</td>
<td>查询指定节点的详细能力描述与参数 schema</td>
</tr>
<tr>
<td>node.invoke</td>
<td>Gateway → 节点</td>
<td>在指定节点上执行命令并返回结果</td>
</tr>
</tbody>
</table>
<p>macOS 平台的系统权限通过 TCC 框架管控，涵盖 screen-recording、notifications、camera、location 四项。每项权限与特定的节点命令能力绑定，应用会在首次调用时请求授权。</p>
<p>会话级别的权限提升通过 /elevated on|off 命令控制。开启后，当前会话获得完整的 bash 访问权限；关闭时回退到受限执行面。该命令是每个会话独立的，不影响其他并发会话。</p>
<div class="blog_h3"><span class="graybg">13.4.1 v2026.3.31 安全强化</span></div>
<p><span style="background-color: #c0c0c0;">v2026.3.31</span> 引入了两项与节点安全相关的破坏性变更。第一，节点命令在设备配对（Device Pairing）完成后不再自动启用 — 必须经过显式的<span style="background-color: #c0c0c0;">节点配对审批</span>（Node Pairing Approval）后，节点命令才会暴露给 Agent。设备配对仅建立了 WebSocket 连接通道，而节点配对审批确认了用户对该设备能力暴露的授权意图。</p>
<p>第二，由节点端发起的运行（Node-originated Runs）被限制在缩减的可信执行面（Reduced Trusted Surface）上。即使节点本身拥有完整能力，从节点侧主动触发的执行流只能使用预定义的安全工具子集。</p>
<p><!-- Chapter 14 --></p>
<div class="blog_h1"><span class="graybg">Live Canvas</span></div>
<p><span style="background-color: #c0c0c0;">Live Canvas</span> 是 OpenClaw Gateway 托管的 Agent 驱动可视化工作区。与传统的静态输出不同，Live Canvas 是持久性的交互画面 — Agent 可以在其上推送内容、重置状态、执行脚本、捕获快照。Canvas 的跨端渲染由原生应用（macOS、iOS SwiftUI、Android）各自实现，而画布操控逻辑通过 <span style="background-color: #c0c0c0;">A2UI</span>（Agent to UI）协议统一抽象。</p>
<div class="blog_h2"><span class="graybg">14.1 A2UI 协议与构建</span></div>
<p>A2UI 定义了 Agent 向 UI 层发送控制指令的协议规范。Canvas 宿主的 A2UI 实现位于 src/canvas-host/a2ui/ 目录。该实现会被打包为一个独立的 bundle，由 Gateway 在运行时加载并注入到 Canvas 宿主容器中。</p>
<p>bundle 的构建产物通过哈希文件 src/canvas-host/a2ui/.bundle.hash 进行版本追踪（自动生成，不应手动编辑）。构建命令有两种等价形式：</p>
<pre class="crayon-plain-tag"># 通过 pnpm script
pnpm canvas:a2ui:bundle

# 通过 shell 脚本
scripts/bundle-a2ui.sh</pre>
<p>A2UI bundle 的构建是整体 pnpm build 流水线的第一步。完整的构建管线为：</p>
<pre class="crayon-plain-tag">pnpm build
# 等价于：
# 1. pnpm canvas:a2ui:bundle
# 2. tsdown-build.mjs
# 3. runtime-postbuild.mjs</pre>
<p>A2UI 的 vendor 源码维护在 vendor/a2ui 目录，原生端的共享封装层位于 apps/shared/OpenClawKit/Tools/CanvasA2UI。</p>
<div class="blog_h3"><span class="graybg">14.1.1 跨编译注意事项</span></div>
<p>A2UI bundle 的构建在交叉编译环境下可能失败。典型场景是在 Apple Silicon 上通过 QEMU 构建 amd64 目标 — 这种情况下 A2UI 的构建步骤可能因 QEMU 对某些指令集的模拟不完整而崩溃。Dockerfile 中已对此做了防护处理：当 A2UI bundle 构建失败时，会创建一个存根（stub）文件代替，确保 Docker 镜像的整体构建不会中断。这意味着 QEMU 交叉编译产出的镜像可能不包含完整的 Canvas 功能。</p>
<div class="blog_h2"><span class="graybg">14.2 Canvas 操作原语</span></div>
<p>Canvas 的操作模型由四个核心原语组成：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">操作</td>
<td style="text-align: center;">语义</td>
<td style="text-align: center;">典型用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>canvas.push</td>
<td>向 Canvas 追加内容（HTML/JS/CSS 片段）</td>
<td>增量构建 UI 界面</td>
</tr>
<tr>
<td>canvas.reset</td>
<td>清空 Canvas 并重新初始化</td>
<td>切换上下文或重置状态</td>
</tr>
<tr>
<td>canvas.eval</td>
<td>在 Canvas 上下文中执行任意 JavaScript</td>
<td>动态交互逻辑、数据可视化</td>
</tr>
<tr>
<td>canvas.snapshot</td>
<td>捕获当前 Canvas 的视觉快照</td>
<td>记录状态、生成截图反馈</td>
</tr>
</tbody>
</table>
<p>其中 canvas.eval 的安全定位需要特别说明。它被归类为 <span style="background-color: #c0c0c0;">Operator 控制面</span>（Operator Control Surface）— 意味着其安全性由部署运营方（Operator）负责，而非 OpenClaw 平台本身。Agent 通过 canvas.eval 可以执行任意 JavaScript 代码，这赋予了极大的灵活性，但也意味着 Operator 必须在自己的部署环境中建立相应的安全防线。</p>
<div class="blog_h2"><span class="graybg">14.3 Canvas 与节点模式</span></div>
<p>在多端架构中，Canvas 操作通过节点模式暴露。所有 canvas.* 调用都被路由为 node.invoke 指令发送到对应的端侧节点执行。这意味着 Agent 可以指定在特定设备（例如用户的 iPad 或 Android 手机）上渲染画布内容，实现跨设备的视觉工作流编排。</p>
<p>三个平台对 Canvas 的渲染实现各有差异：macOS 使用 WebKit 视图，iOS 使用 SwiftUI 原生视图层结合 WebKit 渲染，Android 使用 Android WebView。但上层的 A2UI 协议保证了 Agent 无需关心底层渲染差异。</p>
<div class="blog_h2"><span class="graybg">A2UI 构建管线与交叉编译</span></div>
<p>A2UI 的 bundle 文件位于 src/canvas-host/a2ui/a2ui.bundle.js，其哈希值记录在 src/canvas-host/a2ui/.bundle.hash（自动生成，不应手动编辑）。构建命令为 pnpm canvas:a2ui:bundle 或 scripts/bundle-a2ui.sh。在 pnpm build 的完整构建管线中，A2UI bundle 作为第一步执行。</p>
<p>交叉编译是一个已知痛点。在 Apple Silicon 上构建 amd64 镜像时，A2UI bundle 可能因 QEMU 模拟环境的限制而失败。Dockerfile 对此做了优雅降级处理：bundle 失败时创建一个 stub 文件（包含注释 /* A2UI bundle unavailable in this build */），并同时清理 vendor/a2ui 和 apps/shared/OpenClawKit/Tools/CanvasA2UI 目录，确保构建不会因 A2UI 不可用而中断。CI 构建在原生架构上执行，因此不受此影响。</p>
<div class="blog_h2"><span class="graybg">Canvas 的安全定位</span></div>
<p>Canvas 的安全定位是理解其设计边界的关键。canvas.eval 允许 Agent 在 Canvas 中执行任意 JavaScript 代码，这在功能上等同于一个浏览器端的 eval()。OpenClaw 将此明确归类为 <span style="background-color: #c0c0c0;">Operator 控制面</span>——与浏览器自动化工具中的脚本执行类似，安全责任在于部署者而非平台。这种定位与 OpenClaw 的单用户、本地优先架构一致：在用户自己的设备上，Agent 本就拥有与用户相当的权限。但在多租户或公开部署场景中，Operator 必须评估 Canvas eval 带来的风险并做出适当限制。</p>
<p><!-- Chapter 15 --></p>
<div class="blog_h1"><span class="graybg">Lobster</span></div>
<p><span style="background-color: #c0c0c0;">Lobster</span> 是 OpenClaw 生态中的工作流编排 Shell，独立仓库位于 openclaw/lobster（992 stars）。其定位口号是 "OpenClaw native workflow Shell" — 一个为 OpenClaw 原生设计的工作流执行环境。</p>
<div class="blog_h2"><span class="graybg">15.1 类型化 JSON 管线</span></div>
<p>Lobster 的核心抽象是<span style="background-color: #c0c0c0;">类型化 JSON 管线</span>（Typed JSON Pipelines）。与 Unix shell 的文本管道不同，Lobster 管线中流动的数据是带有类型约束的 JSON 结构。每个管线步骤声明其输入 schema 和输出 schema，Lobster 在管线组装阶段即可进行类型检查，而非等到运行时才暴露类型不匹配的问题。</p>
<p>管线架构是可组合的（Composable Pipeline Architecture）：开发者可以将 OpenClaw 的 Skills 和 Tools 链接为多步骤工作流。每个步骤可以是一个 Skill 调用、一个 Tool 执行、一段自定义逻辑，或者一个嵌套的子管线。</p>
<p>关键的流控机制是<span style="background-color: #c0c0c0;">审批门</span>（Approval Gates）。管线中可以在任意步骤之间插入审批点，执行会暂停并等待指定审批人（人类或其他 Agent）的确认后才继续推进。这对于涉及敏感操作的自动化流程至关重要 — 例如在部署管线中，代码编译步骤自动执行，但推送至生产环境之前需要人工审批。</p>
<div class="blog_h2"><span class="graybg">15.2 终端色彩调色板</span></div>
<p>Lobster 对终端输出的视觉一致性有严格要求。色彩定义集中在 src/terminal/palette.ts 模块，该模块导出一套共享的终端颜色调色板（Terminal Color Palette）。所有面向终端的输出 — 包括 onboarding 引导流程、配置提示（config prompts）与 TTY UI 输出 — 都必须引用调色板中定义的颜色常量，严禁在代码中硬编码颜色值。</p>
<pre class="crayon-plain-tag">// src/terminal/palette.ts
export const palette = {
  primary: chalk.hex('#5B8DEF'),
  success: chalk.hex('#6BCB77'),
  warning: chalk.hex('#FFD93D'),
  error:   chalk.hex('#FF6B6B'),
  muted:   chalk.gray,
  highlight: chalk.bold.white,
  // ... 更多颜色定义
} as const;</pre>
<p>这一设计确保了 Lobster 在不同终端模拟器和配色方案下的视觉一致性，同时简化了主题定制。</p>
<div class="blog_h2"><span class="graybg">15.3 Caclawphony：自主执行编排</span></div>
<p><span style="background-color: #c0c0c0;">Caclawphony</span>（仓库 openclaw/caclawphony，34 stars）是构建在 Lobster 之上的 Symphony 系统。它的核心能力是将项目级任务分解为相互隔离的自主执行单元（Isolated Autonomous Execution Runs）。每个执行单元拥有独立的上下文、工具集与沙箱环境，多个单元可以并行运行。</p>
<p>Caclawphony 适用于大规模项目重构、批量代码迁移等需要将工作分而治之的场景。项目经理（人类或 Agent）在顶层定义任务分解策略，Caclawphony 负责将其转化为可并行执行的 Lobster 管线集合。</p>
<p>Caclawphony 与 OpenClaw 主仓库的 Session 工具形成互补：sessions_send 提供 Agent 间的点对点通信，而 Caclawphony 提供的是任务级的编排框架——它关心的是"哪些工作单元需要并行/串行执行"，而非"Agent A 如何向 Agent B 发消息"。两者结合，构成了从单次对话到复杂项目执行的完整 Agent 协作栈。</p>
<div class="blog_h2"><span class="graybg">Lobster 的设计哲学</span></div>
<p>Lobster 的名称选择（龙虾）并非随意。在 OpenClaw 的概念体系中，龙虾象征着两个工程理念：其一，龙虾的螯足（claw）代表工具——Lobster 管线中的每个步骤都是一个可独立运行的工具调用；其二，龙虾的蜕壳（molt）代表版本演进——管线可以在保持外部接口不变的情况下替换内部实现。</p>
<p>Lobster 管线与 Unix 管道的另一个关键区别在于<strong>错误处理语义</strong>。Unix 管道中，上游命令的非零退出码可以被下游忽略（除非设置了 set -o pipefail）。Lobster 管线的每个步骤都必须显式声明其错误处理策略：fail-fast（任何错误立即终止整条管线）、retry（按指数退避重试）、skip（记录错误但继续执行）或 fallback（切换到备选步骤）。这种显式的错误处理语义使得 Lobster 管线在可靠性上远超 Shell 脚本。</p>
<div class="blog_h2"><span class="graybg">15.4 Lobster 与 Cron 的关系</span></div>
<p>Lobster 管线可以与 OpenClaw 的 Cron 调度系统结合，实现定时自动化。Cron 负责触发时机，Lobster 负责执行逻辑。典型应用包括：每日凌晨执行代码质量扫描管线、每周生成项目状态报告管线、在特定事件触发后延迟执行清理管线等。Cron 触发器将 Lobster 管线 ID 作为执行载荷传递，Gateway 的调度器负责在指定时间实例化管线并开始执行。</p>
<div class="blog_h1"><span class="graybg">Web UI 与浏览器控制</span></div>
<div class="blog_h2"><span class="graybg">Control UI：Lit 3 + Vite</span></div>
<p>OpenClaw 的 Web 管理界面——<span style="background-color: #c0c0c0;">Control UI</span>——直接由 Gateway 进程托管和分发，不需要独立的前端服务器。UI 源码位于 ui/ 目录，使用 <span style="background-color: #c0c0c0;">Lit 3</span>（Google 的 Web Components 库）构建，以 <span style="background-color: #c0c0c0;">Vite</span> 作为开发服务器和构建工具。构建命令为 pnpm ui:build，产出物嵌入到 Gateway 的静态资源路径中。</p>
<p>选择 Lit 而非 React/Vue/Svelte 体现了 OpenClaw 的工程偏好：Lit 基于 Web Components 标准，无需虚拟 DOM 运行时，产出的 bundle 体积极小，且与 Gateway 的原生 HTTP 服务天然兼容。Control UI 的功能覆盖会话管理、渠道状态监控、配置编辑、Skills 管理和 Agent 交互。UI 构建系统支持信号（Signals）响应式模式，通过 @lit-labs/signals@0.2.0 和 signal-utils@0.21.1 实现细粒度的 UI 更新。</p>
<p>UI 还有独立的测试流水线 pnpm test:ui，以及专门的 lint 规则 lint:ui:no-raw-window-open 防止在 UI 代码中使用原始的 window.open()（应使用框架提供的安全包装）。</p>
<div class="blog_h2"><span class="graybg">WebChat：基于 Gateway WebSocket 的对话界面</span></div>
<p><span style="background-color: #c0c0c0;">WebChat</span>（详见 <a href="https://docs.openclaw.ai/web/webchat">docs.openclaw.ai/web/webchat</a>）是 Control UI 中内嵌的对话界面，直接使用 Gateway 的 WebSocket 连接——无需独立的 WebChat 端口或额外配置。安装完 Gateway 后，用户在浏览器中访问 http://localhost:18789 即可开始与 Agent 对话。</p>
<p>WebChat 同时也是 macOS App 的内嵌 Web 视图，通过 macOS 的 WebKit 视图直接加载。这种架构复用确保了 Web 端和 macOS 端的对话体验一致。</p>
<div class="blog_h2"><span class="graybg">浏览器控制工具：Playwright + 专属 Chromium</span></div>
<p>OpenClaw 的浏览器控制工具（详见 <a href="https://docs.openclaw.ai/tools/browser">docs.openclaw.ai/tools/browser</a>）是核心工具体系中最复杂的模块之一。它使用 playwright-core@1.58.2 通过 CDP（Chrome DevTools Protocol）控制一个专属的 Chromium 实例——不是用户的日常浏览器，而是 OpenClaw 管理的独立实例，拥有独立的浏览器配置文件（Profile）。</p>
<p>浏览器控制的核心能力包括：</p>
<ul>
<li><strong>页面快照（Snapshots）</strong>：捕获页面的 DOM 状态和视觉渲染，供 Agent 分析页面内容</li>
<li><strong>结构化操作（Actions）</strong>：点击、填写表单、滚动、导航——Agent 通过结构化指令驱动浏览器，而非注入自由 JavaScript</li>
<li><strong>文件上传</strong>：Agent 可以指示浏览器在文件选择器中上传指定文件</li>
<li><strong>多 Profile 隔离</strong>：不同任务可以使用不同的浏览器配置文件，保持 Cookie 和登录状态的隔离</li>
</ul>
<p>浏览器工具的配置通过 JSON 声明：</p>
<pre class="crayon-plain-tag">{
  "browser": {
    "enabled": true,
    "color": "#FF4500"
  }
}</pre>
<p>color 参数控制浏览器窗口的标题栏颜色——这是一个细节设计，当 Agent 控制的浏览器窗口出现在屏幕上时，用户可以通过颜色快速区分它与自己的日常浏览器。</p>
<p>Docker 镜像构建时，可以通过 --build-arg OPENCLAW_INSTALL_BROWSER=1 预安装 Chromium 和 Xvfb（X Virtual Frame Buffer），增加约 300MB 镜像体积，但省去了每次容器启动时 60-90 秒的 Playwright 安装时间。这对 CI/CD 场景尤其重要。</p>
<div class="blog_h2"><span class="graybg">工具体系概览</span></div>
<p>OpenClaw 的<span style="background-color: #c0c0c0;">一等工具（First-class Tools）</span>是平台核心能力的直接延伸，区别于通过 Skills 或 MCP 接入的第三方工具。一等工具直接集成在 Gateway 和 Agent 运行时中，享有完整的安全策略和沙箱支持：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">工具</td>
<td style="text-align: center;">能力</td>
<td style="text-align: center;">文档</td>
</tr>
</thead>
<tbody>
<tr>
<td><span style="background-color: #c0c0c0;">Browser</span></td>
<td>专属 Chromium 控制、CDP 快照、结构化操作、文件上传</td>
<td><a href="https://docs.openclaw.ai/tools/browser">docs.openclaw.ai/tools/browser</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Canvas</span></td>
<td>A2UI 驱动的可视化工作区（push/reset/eval/snapshot）</td>
<td><a href="https://docs.openclaw.ai/platforms/mac/canvas">docs.openclaw.ai/platforms/mac/canvas</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Nodes</span></td>
<td>设备端操作：camera snap/clip、screen record、location.get、notifications</td>
<td><a href="https://docs.openclaw.ai/nodes">docs.openclaw.ai/nodes</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Cron</span></td>
<td>定时任务调度与自动化触发</td>
<td><a href="https://docs.openclaw.ai/automation/cron-jobs">docs.openclaw.ai/automation/cron-jobs</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Sessions</span></td>
<td>sessions_list / sessions_history / sessions_send（Agent 间通信）</td>
<td><a href="https://docs.openclaw.ai/concepts/session-tool">docs.openclaw.ai/concepts/session-tool</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Webhooks</span></td>
<td>接收外部 HTTP 回调并触发 Agent 处理</td>
<td><a href="https://docs.openclaw.ai/automation/webhook">docs.openclaw.ai/automation/webhook</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Gmail Pub/Sub</span></td>
<td>Gmail 邮件到达事件驱动</td>
<td><a href="https://docs.openclaw.ai/automation/gmail-pubsub">docs.openclaw.ai/automation/gmail-pubsub</a></td>
</tr>
<tr>
<td><span style="background-color: #c0c0c0;">Discord/Slack Actions</span></td>
<td>平台原生交互（斜杠命令、按钮、下拉菜单）</td>
<td>渠道文档内嵌</td>
</tr>
</tbody>
</table>
<p>在沙箱模式下，工具的可用性受到严格约束。非主会话（non-main sessions）的 Docker 沙箱中，默认<strong>允许</strong>的工具包括 bash、process、read、write、edit 以及 sessions 系列；默认<strong>禁止</strong>的工具包括 browser、canvas、nodes、cron、discord、gateway。这种白名单+黑名单的双层控制确保了多租户场景下的安全隔离。</p>
<p><!-- Chapter 16 --></p>
<div class="blog_h1"><span class="graybg">安全模型</span></div>
<p>OpenClaw 的安全模型覆盖了从消息入口到执行环境的完整链路。本章从 DM（Direct Message）配对的接入控制讲起，经过沙箱隔离的执行边界，到安全基础设施与凭证管理，系统性地拆解 OpenClaw 的安全架构。</p>
<div class="blog_h2"><span class="graybg">16.1 DM 配对与接入控制</span></div>
<p>OpenClaw 的默认 DM 安全策略（dmPolicy）设置为 <span style="background-color: #c0c0c0;">"pairing"</span>。在此模式下，任何未知发送者发起的 DM 会话都会收到一个配对码（Pairing Code），用户需要在服务器端通过 CLI 确认才能建立信任关系。</p>
<p>三种 DM 策略模式的对比：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">模式</td>
<td style="text-align: center;">行为</td>
<td style="text-align: center;">安全级别</td>
</tr>
</thead>
<tbody>
<tr>
<td>pairing（默认）</td>
<td>未知发送者收到配对码，需管理员批准</td>
<td>高</td>
</tr>
<tr>
<td>allowlist</td>
<td>仅白名单中的用户可发起 DM</td>
<td>高</td>
</tr>
<tr>
<td>open</td>
<td>接受所有 DM（需同时配置 allowFrom: "*"）</td>
<td>低</td>
</tr>
</tbody>
</table>
<p>配对审批通过 CLI 命令完成：</p>
<pre class="crayon-plain-tag">openclaw pairing approve <channel> <code></pre>
<p>每个渠道的允许列表通过 allowFrom 字段独立配置。例如 channels.telegram.allowFrom、channels.discord.allowFrom 分别控制 Telegram 和 Discord 渠道的准入名单。</p>
<p>公开 DM 访问需要双重显式授权：dmPolicy="open" 和 allowFrom 数组中包含 "*" 通配符。仅设置其中一项不会开放公开访问 — 这是一项有意为之的双重门控设计，防止配置笔误导致意外暴露。</p>
<p>openclaw doctor 会主动检测并告警风险或错误的 DM 策略配置，包括但不限于：open 模式下缺少 allowFrom 通配符、allowlist 模式下白名单为空等异常情况。</p>
<p>历史遗留的配置键名 channels.discord.dm.policy 已迁移为 channels.discord.dmPolicy。旧格式在当前版本仍可被识别，但会触发弃用警告。</p>
<div class="blog_h2"><span class="graybg">16.2 沙箱隔离</span></div>
<p>OpenClaw 的沙箱策略通过 agents.defaults.sandbox.mode 配置。推荐的默认值为 <span style="background-color: #c0c0c0;">"non-main"</span>，意味着非主会话（群组会话、频道会话等）自动进入沙箱隔离环境。</p>
<p>这一设计基于 OpenClaw 的<span style="background-color: #c0c0c0;">单用户设计假设</span>：主会话（Main Session）的操作者是服务的所有者，拥有完整的宿主机访问权限，工具在宿主机上直接执行。而非主会话来自外部用户，每个会话在独立的 Docker 沙箱容器中执行，彼此以及与宿主机之间完全隔离。</p>
<p>沙箱内的工具可用性由白名单和黑名单双重控制：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">类别</td>
<td style="text-align: center;">工具列表</td>
</tr>
</thead>
<tbody>
<tr>
<td>沙箱白名单（允许使用）</td>
<td>bash, process, read, write, edit, sessions_list, sessions_history, sessions_send, sessions_spawn</td>
</tr>
<tr>
<td>沙箱黑名单（禁止使用）</td>
<td>browser, canvas, nodes, cron, discord, gateway</td>
</tr>
</tbody>
</table>
<p>黑名单中的工具若在沙箱会话中被调用，将返回明确的权限拒绝错误而非静默忽略。</p>
<div class="blog_h2"><span class="graybg">16.3 安全基础设施</span></div>
<p>OpenClaw 的安全策略文档维护在独立仓库 openclaw/trust（35 stars），对外发布于 trust.openclaw.ai。该仓库包含完整的威胁模型（Threat Model）文档。安全漏洞报告通过 security@openclaw.ai 接收。</p>
<div class="blog_h3"><span class="graybg">16.3.1 SSRF 防护</span></div>
<p>OpenClaw Plugin SDK 导出 ssrf-runtime 模块，供插件在发起网络请求时使用。该模块会校验目标地址，阻止对内网地址（RFC 1918）、环回地址、链路本地地址以及云元数据端点的访问，从而防范 SSRF（Server-Side Request Forgery，服务端请求伪造）攻击。所有插件的网络调用都应通过此模块路由，而非直接使用 fetch 或 http 模块。</p>
<div class="blog_h3"><span class="graybg">16.3.2 Prompt Injection 的立场</span></div>
<p>OpenClaw 将 Prompt Injection（提示注入）正式声明为<span style="background-color: #c0c0c0;">范围外</span>（Out of Scope）— 不将其视为安全漏洞。这一立场基于现实考量：当前 LLM 架构下不存在可靠的 prompt injection 防御手段，将其纳入漏洞范围只会制造虚假的安全承诺。相应地，canvas.eval 和浏览器脚本执行均被归类为 Operator 控制面，安全边界由部署方自行划定。</p>
<div class="blog_h2"><span class="graybg">16.4 插件安装安全</span></div>
<p>插件安装流程的安全管控在 v2026.3.28 至 v2026.3.31 之间经历了显著强化。</p>
<p>安装流程中的 before_install 钩子为安全扫描器提供了集成点。任何外部安全扫描工具都可以注册为 before_install 处理器，在插件代码落盘前进行检查。</p>
<p><span style="background-color: #c0c0c0;">v2026.3.31 破坏性变更</span>：内置的危险代码检测器现在对 "critical" 级别的发现默认执行<span style="background-color: #c0c0c0;">关闭失败</span>（Fail Closed）策略。此前，critical 级别的发现仅生成警告，管理员可以选择忽略。新策略下，标记为 critical 的发现会直接阻止安装。如需强制安装已标记的插件，必须使用显式的覆盖参数：</p>
<pre class="crayon-plain-tag">openclaw plugin install <name> --dangerously-force-unsafe-install</pre>
<p>该参数名称的冗长是有意为之 — 让每次使用都足够刻意，避免误操作。Skills 安装和 Plugins 安装都受到相同的扫描门控约束。</p>
<div class="blog_h2"><span class="graybg">16.5 Gateway 认证强化（v2026.3.31）</span></div>
<p>v2026.3.31 对 Gateway 的认证机制进行了多项收紧：</p>
<p><span style="background-color: #c0c0c0;">trusted-proxy 模式</span>拒绝混合共享令牌配置（Mixed Shared-token Configs）。如果检测到多个服务共用同一认证令牌，Gateway 将拒绝启动并报告配置冲突。</p>
<p><span style="background-color: #c0c0c0;">local-direct 回退模式</span>现在要求显式配置令牌。此前，同一主机上的连接可以隐式获得认证（Implicit Same-host Auth），这在多租户部署场景下存在风险。新版本移除了这一隐式信任，所有连接都必须提供有效令牌。</p>
<p>节点配对审批（Node Pairing Approval）成为强制前提 — 节点命令直到配对审批完成后才暴露。节点发起的运行（Node-originated Runs）被限制在缩减的可信执行面上。</p>
<div class="blog_h2"><span class="graybg">16.6 凭证管理</span></div>
<p>OpenClaw 的凭证统一存储于 ~/.openclaw/credentials/ 目录。Web 服务提供商的凭证刷新通过 openclaw login 命令重新执行 OAuth 流程。</p>
<p>Provider 插件中的密钥引用使用 <span style="background-color: #c0c0c0;">SecretRef</span> 语义 — 配置文件中仅存储密钥的引用标识符而非明文值，运行时由凭证管理器解析为实际密钥。这一设计确保配置文件可以安全地纳入版本控制。</p>
<p>关于内容安全的基本原则：永远不要提交真实的电话号码、视频文件或生产环境配置值到代码仓库中。</p>
<p><!-- Chapter 17 --></p>
<div class="blog_h1"><span class="graybg">构建与测试</span></div>
<p>OpenClaw 的构建与测试基础设施是其工程纪律的集中体现。本章逐一拆解构建工具链的选型、198 个 npm 脚本的分类、测试基础设施的架构设计以及代码质量门控的实施细节。</p>
<div class="blog_h2"><span class="graybg">17.1 构建工具链</span></div>
<p>OpenClaw 的构建工具选型刻意回避了主流的 webpack/rollup/esbuild 全家桶模式，转而采用一套更专注的工具组合：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">工具</td>
<td style="text-align: center;">版本</td>
<td style="text-align: center;">职责</td>
</tr>
</thead>
<tbody>
<tr>
<td>tsdown</td>
<td>0.21.7</td>
<td>打包器（bundler），通过 scripts/tsdown-build.mjs 驱动</td>
</tr>
<tr>
<td>TypeScript</td>
<td>6.0.2</td>
<td>类型检查</td>
</tr>
<tr>
<td>@typescript/native-preview</td>
<td>7.0.0-dev.20260331.1</td>
<td>Go 实现的 TypeScript 编译器预览版（pnpm tsgo）</td>
</tr>
<tr>
<td>oxfmt</td>
<td>0.43.0</td>
<td>代码格式化（取代 Prettier）</td>
</tr>
<tr>
<td>oxlint + oxlint-tsgolint</td>
<td>1.58.0 / 0.18.1</td>
<td>代码检查（取代 ESLint）</td>
</tr>
<tr>
<td>Bun</td>
<td>-</td>
<td>开发/测试阶段的 TypeScript 执行器</td>
</tr>
<tr>
<td>Node 22+</td>
<td>-</td>
<td>生产运行时（保持 Node + Bun 双路径兼容）</td>
</tr>
<tr>
<td>tsx</td>
<td>4.21.0</td>
<td>基于 Node 的 TypeScript 执行</td>
</tr>
<tr>
<td>jiti</td>
<td>2.6.1</td>
<td>运行时 ESM 解析（plugin-sdk 别名解析）</td>
</tr>
</tbody>
</table>
<p>几点选型要点值得展开：</p>
<p><span style="background-color: #c0c0c0;">tsdown 而非直接使用 esbuild</span>：tsdown 在 esbuild 之上提供了更高层的打包抽象，其配置文件比直接编写 esbuild 插件更简洁。构建入口是 scripts/tsdown-build.mjs。</p>
<p><span style="background-color: #c0c0c0;">@typescript/native-preview</span>：这是 TypeScript 官方的 Go 语言重写实验版本，通过 pnpm tsgo 调用。其类型检查速度比标准 TypeScript 编译器快一个数量级，OpenClaw 将其用于 CI 中的快速类型检查路径。</p>
<p><span style="background-color: #c0c0c0;">oxfmt / oxlint</span>：基于 Rust 的格式化和检查工具链，替代了传统的 Prettier + ESLint 组合。格式化命令为 pnpm format（检查）和 pnpm format:fix（自动修复），检查命令为 pnpm lint。</p>
<p><span style="background-color: #c0c0c0;">Bun + Node 双运行时</span>：开发和测试阶段使用 Bun 获得更快的启动速度（bun <file.ts>、bunx <tool>），生产部署使用 Node 22+ 确保兼容性。两条路径必须同时保持可用。</p>
<div class="blog_h3"><span class="graybg">17.1.1 构建管线与变体</span></div>
<p>完整构建管线：</p>
<pre class="crayon-plain-tag">pnpm build
# 展开为：
# 1. pnpm canvas:a2ui:bundle    → A2UI bundle 构建
# 2. scripts/tsdown-build.mjs   → 主体打包
# 3. runtime-postbuild.mjs      → 运行时后处理</pre>
<p>三种构建变体服务于不同场景：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">变体</td>
<td style="text-align: center;">命令</td>
<td style="text-align: center;">用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>完整构建</td>
<td>pnpm build</td>
<td>包含 A2UI bundle + 主体 + 后处理</td>
</tr>
<tr>
<td>Docker 构建</td>
<td>pnpm build:docker</td>
<td>跳过 A2UI bundle（可能在 QEMU 下失败）</td>
</tr>
<tr>
<td>严格冒烟测试</td>
<td>pnpm build:strict-smoke</td>
<td>快速验证构建产物的基本可用性</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">17.2 198 个 npm 脚本分类</span></div>
<p>OpenClaw 的 package.json 包含 <span style="background-color: #c0c0c0;">198 个 npm 脚本</span>。这个数字并非膨胀 — 它反映了一个覆盖多平台、多渠道、多插件的工程体系所需的自动化指令密度。以下按职责分类：</p>
<div class="blog_h3"><span class="graybg">17.2.1 构建类</span></div>
<p>build、build:docker、build:plugin-sdk:dts、build:strict-smoke — 核心构建管线及其变体，加上 Plugin SDK 的类型声明生成。</p>
<div class="blog_h3"><span class="graybg">17.2.2 检查与 Lint 类（30+）</span></div>
<p>以 pnpm check 作为元检查入口，编排 tsgo、lint、format、format:check 及约 20 个特定规则检查脚本。详见 17.6 节的 CI 架构分析。</p>
<div class="blog_h3"><span class="graybg">17.2.3 测试类（40+）</span></div>
<p>测试脚本是数量最多的一组：test、test:fast、test:watch、test:coverage、test:e2e、test:live、test:gateway、test:channels、test:extensions、test:contracts，以及一系列 test:docker:* 和 test:parallels:* 脚本。</p>
<div class="blog_h3"><span class="graybg">17.2.4 发布类</span></div>
<p>release:check、release:openclaw:npm:check、release:plugins:npm:check — 发布前的版本号、changelog、npm registry 一致性校验。</p>
<div class="blog_h3"><span class="graybg">17.2.5 平台类</span></div>
<p>android:*、ios:*、ui:* — 各平台的构建、测试、lint 快捷入口。</p>
<div class="blog_h3"><span class="graybg">17.2.6 文档类</span></div>
<p>docs:check-links（死链检测）、docs:spellcheck（拼写检查）、docs:check-i18n-glossary（国际化术语表一致性）。</p>
<div class="blog_h3"><span class="graybg">17.2.7 协议类</span></div>
<p>protocol:check（协议定义一致性检查）、protocol:gen（生成 TypeScript 类型）、protocol:gen:swift（生成 Swift 类型）。</p>
<div class="blog_h2"><span class="graybg">17.3 测试基础设施</span></div>
<p>OpenClaw 使用 <span style="background-color: #c0c0c0;">Vitest 4.1.2</span> 作为测试框架，搭配 @vitest/coverage-v8 进行 V8 引擎级别的代码覆盖率采集。覆盖率门槛统一设置为 <span style="background-color: #c0c0c0;">70%</span>，覆盖 lines、branches、functions、statements 四个维度。</p>
<p>一条关键的强制规则：Vitest 的并发模式<span style="background-color: #c0c0c0;">只允许使用 forks 池</span>。threads、vmThreads、vmForks 三种模式被显式禁用。这一限制源于 OpenClaw 测试中大量涉及进程级副作用（子进程创建、文件系统操作、网络端口占用等），线程级隔离无法提供足够的隔离保障。</p>
<p>并行测试编排由 test-parallel.mjs 脚本驱动，提供三种执行配置：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">配置</td>
<td style="text-align: center;">并行度</td>
<td style="text-align: center;">用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>default</td>
<td>CPU 核心数的 50%</td>
<td>日常开发，平衡速度与系统响应性</td>
</tr>
<tr>
<td>serial</td>
<td>1</td>
<td>调试失败用例，排除并发干扰</td>
</tr>
<tr>
<td>max</td>
<td>CPU 核心数的 100%</td>
<td>CI 环境，最大化吞吐</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">17.3.1 测试层级</span></div>
<p>测试体系覆盖多个层级，每个层级关注不同粒度的验证需求：</p>
<p><span style="background-color: #c0c0c0;">单元测试与集成测试</span>：pnpm test（全量运行）、pnpm test:fast（排除慢速用例）、pnpm test:watch（文件监听模式）、pnpm test:coverage（带覆盖率报告）。</p>
<p><span style="background-color: #c0c0c0;">领域测试</span>：test:channels（渠道集成）、test:extensions（扩展接口）、test:gateway（Gateway 协议）、test:e2e（端到端流程）、test:live（真实 API 对接）。</p>
<p><span style="background-color: #c0c0c0;">契约测试</span>（Contract Tests）：test:contracts:channels 和 test:contracts:plugins 分别强制渠道和插件的接口契约一致性。契约测试确保 Channel 适配器和 Plugin 遵循其声明的接口协议，防止实现漂移。</p>
<p><span style="background-color: #c0c0c0;">Docker E2E 测试</span>（8+ 场景）：在完整的 Docker 容器化环境中执行端到端验证。覆盖场景包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">场景</td>
<td style="text-align: center;">验证范围</td>
</tr>
</thead>
<tbody>
<tr>
<td>onboard</td>
<td>首次启动引导流程</td>
</tr>
<tr>
<td>plugins</td>
<td>插件安装、加载与执行</td>
</tr>
<tr>
<td>MCP channels</td>
<td>MCP 协议渠道连通性</td>
</tr>
<tr>
<td>gateway network</td>
<td>Gateway 网络拓扑与路由</td>
</tr>
<tr>
<td>OpenWebUI</td>
<td>OpenWebUI 集成</td>
</tr>
<tr>
<td>doctor-switch</td>
<td>doctor 诊断与配置切换</td>
</tr>
<tr>
<td>qr-import</td>
<td>QR 码配置导入</td>
</tr>
<tr>
<td>live models</td>
<td>真实模型端点对接</td>
</tr>
</tbody>
</table>
<p><span style="background-color: #c0c0c0;">Parallels 冒烟测试</span>：在 macOS、Windows、Linux 三个虚拟机客户端上执行冒烟测试，验证跨操作系统的基本功能可用性。</p>
<p><span style="background-color: #c0c0c0;">性能测试套件</span>：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">脚本</td>
<td style="text-align: center;">度量维度</td>
</tr>
</thead>
<tbody>
<tr>
<td>test:perf:budget</td>
<td>性能预算检查（运行时间/内存上限）</td>
</tr>
<tr>
<td>test:perf:hotspots</td>
<td>热点函数 profiling</td>
</tr>
<tr>
<td>test:perf:imports</td>
<td>模块导入耗时分析</td>
</tr>
<tr>
<td>test:startup:bench</td>
<td>启动时间基准</td>
</tr>
<tr>
<td>test:startup:memory</td>
<td>启动内存占用</td>
</tr>
</tbody>
</table>
<p><span style="background-color: #c0c0c0;">Live 测试</span>：通过设置环境变量 OPENCLAW_LIVE_TEST=1 启用，执行 pnpm test:live。这些测试使用真实的 API 密钥调用外部服务，因此不在常规 CI 中运行，而是在专用的 live 测试环境中定期执行。</p>
<div class="blog_h2"><span class="graybg">17.4 代码质量门控</span></div>
<p>OpenClaw 对代码质量的门控措施密度极高，以下逐一展开：</p>
<p><span style="background-color: #c0c0c0;">文件行数限制</span>：check:loc 脚本强制执行约 500-700 行的文件行数上限。超过上限的文件会被标记为需要拆分。这是一项软性但有强制检查的编码指南，目标是防止出现难以维护的巨型文件。</p>
<p><span style="background-color: #c0c0c0;">严格类型纪律</span>：禁止使用 @ts-nocheck，避免使用 any 类型，优先使用 unknown。在对外边界（配置文件、Webhook 载荷、CLI 输出、API 响应）处优先使用 zod@4.3.6 进行运行时 schema 校验。</p>
<p><span style="background-color: #c0c0c0;">动态导入防护</span>：构建系统会检测同一模块同时存在静态导入和动态导入的情况，并发出 INEFFECTIVE_DYNAMIC_IMPORT 警告。这种混用模式会导致 tree-shaking 失效 — 模块已经通过静态导入被打包，动态导入不会带来额外的按需加载收益，反而增加了代码理解的复杂度。</p>
<p><span style="background-color: #c0c0c0;">重复代码检测</span>：使用 jscpd@4.0.8 扫描 src/、extensions/、test/、scripts/ 目录下的代码重复。超过阈值的重复块会触发 CI 失败。</p>
<p><span style="background-color: #c0c0c0;">漂移检测</span>：一系列 check 脚本监控各类定义与实现之间的一致性：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">检查</td>
<td style="text-align: center;">检测内容</td>
</tr>
</thead>
<tbody>
<tr>
<td>canon:check</td>
<td>规范代码样式一致性</td>
</tr>
<tr>
<td>plugin-sdk:api:check</td>
<td>Plugin SDK 公开 API 漂移检测</td>
</tr>
<tr>
<td>config:docs:check</td>
<td>配置 schema 与文档的一致性</td>
</tr>
<tr>
<td>lint:plugins:plugin-sdk-subpaths-exported</td>
<td>Plugin SDK 子路径导出完整性</td>
</tr>
</tbody>
</table>
<p>此外还有 8 个以上针对特定扩展（Extension）的边界 lint 规则，确保各扩展模块不会越界访问其他扩展的内部 API。</p>
<div class="blog_h2"><span class="graybg">17.5 CI 架构</span></div>
<p>OpenClaw 的 CI 采用<span style="background-color: #c0c0c0;">两层检查体系</span>（Two-tier Check System），将本地开发门控与 CI 门控分离：</p>
<div class="blog_h3"><span class="graybg">17.5.1 第一层：pnpm check（本地开发门控）</span></div>
<p>pnpm check 是每次提交前必须通过的本地检查。其执行顺序为：</p>
<pre class="crayon-plain-tag"># pnpm check 的执行序列：
# 1. no-conflict-markers     → 检测未解决的合并冲突标记
# 2. host-env-policy:swift   → 验证 Swift 宿主环境策略
# 3. tsgo                    → Go 版 TypeScript 类型检查
# 4. lint                    → oxlint 代码检查
# 5. format                  → oxfmt 格式校验</pre>
<p>该管线为串行执行，任何步骤失败都会终止后续步骤并报告错误位置。</p>
<div class="blog_h3"><span class="graybg">17.5.2 第二层：check-additional（CI 专属门控）</span></div>
<p>check-additional 在 CI 环境中额外执行，包含架构策略和边界策略守卫。这些检查被有意排除在本地开发循环之外 — 它们通常运行较慢且依赖 CI 专有环境（如完整的 git 历史、所有分支的 diff 信息等），放入本地循环会严重拖慢开发节奏。</p>
<div class="blog_h3"><span class="graybg">17.5.3 Pre-commit 钩子与快速通道</span></div>
<p>Pre-commit 钩子由 <span style="background-color: #c0c0c0;">prek</span> 工具管理，钩子的默认行为是执行完整的 pnpm check 管线。</p>
<p>对于需要快速迭代的场景，环境变量 FAST_COMMIT=1 可以跳过 format 和 check 步骤：</p>
<pre class="crayon-plain-tag"># 跳过格式和检查（手动保证代码质量时使用）
FAST_COMMIT=1 git commit -m "wip: experimental changes"</pre>
<p>使用 FAST_COMMIT 意味着开发者自行承担代码质量责任 — CI 仍会执行完整检查，不符合要求的提交将在 CI 阶段被拦截。</p>
<div class="blog_h3"><span class="graybg">17.5.4 主分支准入标准</span></div>
<p>代码合入主分支（main）的<span style="background-color: #c0c0c0;">准入门槛</span>（Landing Bar）为：</p>
<pre class="crayon-plain-tag"># 主分支准入三件套：
pnpm check        # 类型 + lint + 格式
pnpm test          # 全量测试
pnpm build         # 构建验证（当变更涉及构建影响面时）</pre>
<p>第三项 pnpm build 是条件性的：仅在变更涉及构建影响面（build-affecting surfaces）时才要求。构建影响面的定义包括但不限于：tsdown-build.mjs 配置变更、package.json 依赖变更、tsconfig.json 变更、新增或删除模块导出等。纯粹的逻辑变更（函数实现调整、bug 修复等）不触发构建要求，以此平衡 CI 速度与安全性。</p>
<div class="blog_h1"><span class="graybg">部署方案</span></div>
<p>OpenClaw 的部署策略覆盖了从单人开发者到企业团队的全部场景。部署方式按复杂度递增排列：npm 全局安装、Docker 容器化、Ansible 编排、Nix 声明式配置、Windows 系统托盘。每种方式对应不同的运维哲学和安全模型。</p>
<div class="blog_h2"><span class="graybg">npm 全局安装（推荐方式）</span></div>
<p>对于大多数开发者而言，npm 全局安装是进入 OpenClaw 最快的路径：</p>
<pre class="crayon-plain-tag">npm install -g openclaw@latest
openclaw onboard --install-daemon</pre>
<p>第一条命令将 OpenClaw CLI 及其全部依赖安装到 Node.js 的全局 node_modules 目录。第二条命令启动交互式引导向导（Onboarding Wizard），--install-daemon 标志指示向导在流程结束后自动注册系统守护进程（Daemon）。</p>
<p>守护进程的注册方式因操作系统而异。在 macOS 上，OpenClaw 生成一个 <span style="background-color: #c0c0c0;">launchd</span> plist 文件并通过 launchctl load 注册为用户级别的 Launch Agent。plist 的关键配置如下：</p>
<pre class="crayon-plain-tag"><key>Label</key>
<string>ai.openclaw.gateway</string>
<key>ProgramArguments</key>
<array>
  <string>/usr/local/bin/node</string>
  <string>/usr/local/lib/node_modules/openclaw/dist/gateway.js</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/></pre>
<p>在 Linux 上，OpenClaw 采用 <span style="background-color: #c0c0c0;">systemd 用户服务（user service）</span>，将单元文件写入 ~/.config/systemd/user/openclaw-gateway.service，并通过 systemctl --user enable --now openclaw-gateway 启动。这一选择的关键在于"用户级别"——无需 root 权限，服务生命周期与用户会话绑定，遵循最小权限原则。配合 loginctl enable-linger 可以实现即使用户未登录也保持运行。</p>
<div class="blog_h2"><span class="graybg">Docker 部署：四阶段构建深度剖析</span></div>
<p>Docker 部署是 OpenClaw 为生产环境和隔离场景提供的首选方案。其 Dockerfile 采用<span style="background-color: #c0c0c0;">多阶段构建（Multi-Stage Build）</span>模式，共分四个阶段，每个阶段都经过精心设计以最小化最终镜像体积。</p>
<div class="blog_h3"><span class="graybg">阶段一：ext-deps — 扩展依赖提取</span></div>
<p>第一阶段的唯一职责是从 extensions/ 目录树中提取所有 package.json 文件，同时保留其目录结构。这是一个纯文件复制阶段，不执行任何安装操作：</p>
<pre class="crayon-plain-tag">FROM node:24-bookworm AS ext-deps
WORKDIR /app
COPY extensions/ extensions/
RUN find extensions -name "package.json" -exec sh -c \
  'mkdir -p /out/$(dirname {}) && cp {} /out/{}' \;</pre>
<p>这种分离的目的是利用 Docker 的层缓存机制——只有当扩展的 package.json 发生变化时，后续的依赖安装层才会失效。</p>
<div class="blog_h3"><span class="graybg">阶段二：build — 编译构建</span></div>
<p>构建阶段使用 <span style="background-color: #c0c0c0;">Bun</span> 作为 JavaScript 运行时加速依赖安装，同时以 <span style="background-color: #c0c0c0;">pnpm</span> 作为包管理器，<span style="background-color: #c0c0c0;">tsdown</span> 作为 TypeScript 编译工具：</p>
<pre class="crayon-plain-tag">FROM oven/bun:1 AS build
WORKDIR /app
COPY --from=ext-deps /out/extensions ./extensions
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN cd ui && pnpm run build</pre>
<p>构建产物包括两部分：dist/ 目录下的 TypeScript 编译输出（通过 tsdown 生成），以及 ui/dist/ 目录下的 Web Control UI 静态资源（通过 Vite 构建）。</p>
<div class="blog_h3"><span class="graybg">阶段三：runtime-assets — 运行时资产裁剪</span></div>
<p>第三阶段是整个构建流水线中最关键的体积优化环节：</p>
<pre class="crayon-plain-tag">FROM build AS runtime-assets
RUN pnpm prune --prod
RUN find . -name "*.d.ts" -delete \
 && find . -name "*.map" -delete \
 && find . -name "*.ts" ! -name "*.d.ts" -path "*/src/*" -delete</pre>
<p>首先通过 pnpm prune --prod 移除所有 devDependencies，然后逐一清除 TypeScript 声明文件（.d.ts）、Source Map 文件（.map）和源码文件。最终只保留 JavaScript 运行时代码和生产级依赖。</p>
<div class="blog_h3"><span class="graybg">阶段四：runtime — 最终运行镜像</span></div>
<p>最终阶段基于精简的 Node 24 镜像，且所有基础镜像均采用<span style="background-color: #c0c0c0;">固定 SHA256 摘要（Pinned SHA256 Digest）</span>以确保构建可复现：</p>
<pre class="crayon-plain-tag"># 默认变体
FROM node:24-bookworm@sha256:abc123... AS runtime
# 精简变体
# FROM node:24-bookworm-slim@sha256:def456... AS runtime

USER node
WORKDIR /home/node/app
COPY --from=runtime-assets --chown=node:node /app ./

HEALTHCHECK --interval=3m --timeout=10s --start-period=30s \
  CMD curl -f http://localhost:18789/healthz || exit 1

EXPOSE 18789 18790
CMD ["node", "dist/gateway.js"]</pre>
<p>OpenClaw 提供两个镜像变体（Variant），通过构建参数 OPENCLAW_VARIANT 选择：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>变体</td>
<td>基础镜像</td>
<td>特点</td>
<td>适用场景</td>
</tr>
</thead>
<tbody>
<tr>
<td>default</td>
<td>node:24-bookworm</td>
<td>包含完整 Debian 工具链，支持浏览器安装</td>
<td>需要 Playwright/浏览器渠道</td>
</tr>
<tr>
<td>slim</td>
<td>node:24-bookworm-slim</td>
<td>最小化系统库，镜像体积减小约 40%</td>
<td>纯 CLI/API 场景</td>
</tr>
</tbody>
</table>
<p>其余构建参数（Build Args）包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>参数名</td>
<td>默认值</td>
<td>说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>OPENCLAW_EXTENSIONS</td>
<td>all</td>
<td>控制构建哪些扩展，逗号分隔或 all</td>
</tr>
<tr>
<td>OPENCLAW_INSTALL_BROWSER</td>
<td>false</td>
<td>是否在镜像中预装 Chromium（Playwright）</td>
</tr>
<tr>
<td>OPENCLAW_INSTALL_DOCKER_CLI</td>
<td>false</td>
<td>是否安装 Docker CLI（用于沙箱功能）</td>
</tr>
</tbody>
</table>
<p>安全方面，最终镜像以 USER node（uid 1000）运行，杜绝 root 权限。健康检查（Health Check）配置了两个端点：/healthz 用于存活探测（Liveness Probe），/readyz 用于就绪探测（Readiness Probe），检测间隔为 3 分钟，超时 10 秒，启动宽限期 30 秒。</p>
<div class="blog_h3"><span class="graybg">docker-compose.yml：双服务架构</span></div>
<p>官方 docker-compose.yml 定义了两个服务，体现了 OpenClaw 的网关-客户端分离架构：</p>
<pre class="crayon-plain-tag">version: "3.9"
services:
  openclaw-gateway:
    image: ghcr.io/openclaw/openclaw:latest
    ports:
      - "18789:18789"
      - "18790:18790"
    volumes:
      - openclaw-data:/home/node/.openclaw
      - /var/run/docker.sock:/var/run/docker.sock
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
      - NET_ADMIN
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:18789/healthz"]
      interval: 3m
      timeout: 10s
      start_period: 30s

  openclaw-cli:
    image: ghcr.io/openclaw/openclaw:latest
    command: ["node", "dist/cli.js"]
    depends_on:
      openclaw-gateway:
        condition: service_healthy
    environment:
      - OPENCLAW_GATEWAY_URL=http://openclaw-gateway:18789

volumes:
  openclaw-data:</pre>
<p>安全加固（Security Hardening）体现在三处：no-new-privileges 阻止进程通过 setuid/setgid 提权；cap_drop 丢弃 NET_RAW 和 NET_ADMIN 能力（Capability），防止原始套接字操作和网络配置篡改；Docker Socket 挂载（/var/run/docker.sock）为沙箱集成提供<span style="background-color: #c0c0c0;">Docker-in-Docker（DinD）</span>能力，允许 Agent 在隔离容器中执行命令。</p>
<p>端口映射方面，18789 是 Gateway 的 HTTP 主端口，承载 REST API、WebSocket 连接和 Web Control UI；18790 是 Bridge 端口，供外部渠道插件通过 gRPC 或 WebSocket 桥接到 Gateway。</p>
<div class="blog_h2"><span class="graybg">Ansible 部署：openclaw/openclaw-ansible</span></div>
<p>openclaw/openclaw-ansible（545 stars）提供了一套完整的 Ansible Playbook，将 OpenClaw 的 Docker 部署包装为可重复执行的基础设施代码（Infrastructure as Code）。其核心特性包括：</p>
<p><strong>Tailscale VPN 集成</strong>：Playbook 默认集成 <span style="background-color: #c0c0c0;">Tailscale</span>，Gateway 绑定到 Tailscale 虚拟网络接口而非公网接口。这意味着 OpenClaw 实例只在 Tailnet 内可达，无需暴露任何公网端口，从根本上消除了未授权访问的风险。</p>
<p><strong>UFW 防火墙配置</strong>：自动配置 <span style="background-color: #c0c0c0;">UFW（Uncomplicated Firewall）</span>规则，仅放行 SSH（22）和 Tailscale 所需端口（41641/UDP），其余入站流量全部丢弃。</p>
<p><strong>Docker 隔离</strong>：OpenClaw 运行在独立 Docker 网络中，数据卷映射到宿主机的指定路径，支持通过 Ansible 变量自定义挂载点、环境变量和资源限制。</p>
<div class="blog_h2"><span class="graybg">Nix 部署：openclaw/nix-openclaw</span></div>
<p>openclaw/nix-openclaw（611 stars）提供 Nix Flake 形式的声明式配置。对于 NixOS 用户或使用 home-manager 的开发者，这是最符合其工作流的部署方式：</p>
<pre class="crayon-plain-tag">{
  inputs.openclaw.url = "github:openclaw/nix-openclaw";
  outputs = { self, nixpkgs, openclaw }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        openclaw.nixosModules.default
        {
          services.openclaw = {
            enable = true;
            gateway.port = 18789;
            extensions = [ "discord" "telegram" "whatsapp" ];
          };
        }
      ];
    };
  };
}</pre>
<p>Nix 部署的优势在于完全的可复现性（Reproducibility）——相同的 Flake 输入必然产生相同的系统配置，消除了"在我机器上能跑"的环境差异问题。</p>
<div class="blog_h2"><span class="graybg">Windows 部署：系统托盘与 PowerToys 集成</span></div>
<p>openclaw/openclaw-windows-node（405 stars）为 Windows 提供了原生集成体验。其核心组件包括：</p>
<p><strong>系统托盘伴侣（System Tray Companion）</strong>：一个轻量级的 .NET 应用，驻留在 Windows 系统托盘。它管理 OpenClaw Gateway 进程的生命周期（启动/停止/重启），显示实时状态，并提供快捷菜单访问 Web Control UI 和日志。</p>
<p><strong>PowerToys 命令面板扩展（Command Palette Extension）</strong>：集成 Microsoft PowerToys 的命令面板（Run 插件），用户可以通过 Alt+Space 快捷键直接向 OpenClaw Agent 发送指令，无需切换到浏览器或终端窗口。</p>
<div class="blog_h2"><span class="graybg">远程 Gateway 配置（Linux）</span></div>
<p>在远程 Linux 服务器上部署 Gateway 时，安全暴露服务是核心问题。OpenClaw 提供三种模式：</p>
<p><strong>Tailscale Serve（Tailnet 内部访问）</strong>：通过 tailscale serve 将 Gateway 端口映射到 Tailscale 的 HTTPS 代理，仅 Tailnet 内的设备可访问。配合 Gateway 的 --tailscale serve 模式，自动完成证书配置和端口映射。</p>
<p><strong>Tailscale Funnel（公网 HTTPS 暴露）</strong>：tailscale funnel 模式将服务通过 Tailscale 的全球边缘网络暴露到公网，自动获取 HTTPS 证书。适用于需要外部 Webhook 回调（如 Telegram Bot）的场景。</p>
<p><strong>SSH 隧道</strong>：最传统但最灵活的方式，通过 SSH 端口转发将远程 Gateway 映射到本地：</p>
<pre class="crayon-plain-tag">ssh -L 18789:localhost:18789 -L 18790:localhost:18790 user@remote-host</pre>
<p>Gateway 的绑定模式（Bind Mode）通过 --bind 参数控制：loopback（默认）仅监听 127.0.0.1，lan 监听 0.0.0.0 以接受局域网连接。Tailscale 模式通过 --tailscale 参数设置：off（默认）、serve、funnel。</p>
<div class="blog_h2"><span class="graybg">引导向导：openclaw onboard</span></div>
<p>openclaw onboard 是 OpenClaw 的交互式初始化命令，引导用户完成从零到可用的全部配置。流程按步骤执行：</p>
<p><strong>步骤一：Gateway 配置</strong>——选择绑定模式（loopback/lan）、端口、Tailscale 模式。若检测到已有 Gateway 实例运行，则询问是否复用。</p>
<p><strong>步骤二：工作区配置</strong>——创建默认工作区目录（~/.openclaw/workspace），配置 LLM 提供商密钥（OpenAI API Key、Anthropic API Key 等），设置默认模型。</p>
<p><strong>步骤三：渠道配置</strong>——根据用户选择启用渠道（Discord Bot Token、Telegram Bot Token、WhatsApp 手机号等），验证凭据有效性。</p>
<p><strong>步骤四：Skills 配置</strong>——推荐安装热门 Skills，提示用户浏览 ClawHub 发现更多。</p>
<p>当附带 --install-daemon 标志时，向导结束后自动注册并启动系统守护进程。若出现问题，openclaw doctor 命令执行全面的健康检查：验证 Node.js 版本、检查端口占用、测试 LLM 连接、校验配置文件语法，并输出诊断报告及修复建议。</p>
<div class="blog_h1"><span class="graybg">生态全景</span></div>
<div class="blog_h2"><span class="graybg">ClawHub：官方 Skill 注册中心</span></div>
<p><span style="background-color: #c0c0c0;">ClawHub</span>（openclaw/clawhub，7,214 stars）是 OpenClaw 的官方 Skill 注册中心与分发平台。它的定位类似于 npm 之于 Node.js，或 crates.io 之于 Rust——一个集中式的包注册表，但分发的是 AI Agent 能力模块。</p>
<p>安装 Skill 只需一条命令：</p>
<pre class="crayon-plain-tag">clawhub install weather-forecast
clawhub install code-review --version 2.1.0</pre>
<p>ClawHub 的 Agent 集成能力是其核心差异化特性：当 Agent 在对话中遇到需要但当前未安装的能力时，可以自动搜索 ClawHub 并在用户确认后拉取安装。这一流程的实现路径是 Agent 调用内置的 clawhub_search 工具函数，该函数向 api.clawhub.com/v1/search 发送请求，返回匹配的 Skill 列表及其安全评级。</p>
<p>Web 端的 clawhub.com 提供可视化的市场（Marketplace）界面，支持按类别浏览、按关键词搜索、查看安装统计和社区评分。每个 Skill 页面展示其 SKILL.md 的渲染内容、依赖关系图和版本历史。</p>
<div class="blog_h2"><span class="graybg">Skills 存档与社区列表</span></div>
<p>openclaw/skills（3,622 stars）是所有 Skill 的版本存档仓库，保存了每个 Skill 每个版本的完整快照。这一设计确保即使 Skill 作者删除了某个版本，已部署的实例仍可从存档拉取。</p>
<p>VoltAgent/awesome-openclaw-skills（43,292 stars）是社区维护的精选 Skill 列表，收录超过 5,400 个经过社区验证的 Skill。其 Star 数反映了 OpenClaw Skill 生态的活跃程度——一个"awesome 列表"的 Star 数通常是其生态规模的风向标。</p>
<div class="blog_h2"><span class="graybg">Skills 系统：三层结构</span></div>
<p>OpenClaw 的 Skills 按分发方式分为三个层级：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>层级</td>
<td>存储位置</td>
<td>安装方式</td>
<td>更新策略</td>
</tr>
</thead>
<tbody>
<tr>
<td>内置 Skills（Bundled）</td>
<td>skills/ 目录，随 npm 包分发</td>
<td>自动包含</td>
<td>随 OpenClaw 版本更新</td>
</tr>
<tr>
<td>托管 Skills（Managed）</td>
<td>~/.openclaw/managed/skills/</td>
<td>clawhub install</td>
<td>clawhub update</td>
</tr>
<tr>
<td>工作区 Skills（Workspace）</td>
<td>~/.openclaw/workspace/skills/<name>/</td>
<td>手动创建</td>
<td>用户自行管理</td>
</tr>
</tbody>
</table>
<p>加载优先级从上到下递增——工作区 Skills 可以覆盖同名的托管或内置 Skill，提供最大的定制灵活性。</p>
<div class="blog_h2"><span class="graybg">Skill 开发：AgentSkills 规范</span></div>
<p>每个 Skill 由一个 SKILL.md 文件定义，这是 <span style="background-color: #c0c0c0;">AgentSkills 规范</span>的核心载体。SKILL.md 采用带有特殊 YAML Front Matter 的 Markdown 格式：</p>
<pre class="crayon-plain-tag">---
name: code-review
version: 2.1.0
description: Automated code review with multi-language support
triggers:
  - pattern: "review {file_path}"
  - pattern: "check code quality"
permissions:
  - filesystem:read
  - git:read
before_install: scripts/check-deps.sh
---

# Code Review Skill

## Instructions

You are a senior code reviewer. When triggered, analyze the provided
file for bugs, style issues, and potential improvements.

## Tools

### review_file
Analyze a single file and return findings.
- `file_path` (string, required): Path to the file to review
- `severity` (string, optional): Minimum severity to report (info|warn|error)</pre>
<p>其中 before_install 字段指定一个<span style="background-color: #c0c0c0;">安全钩子（Security Hook）</span>脚本，在安装阶段执行。该脚本用于验证系统依赖（如检查 Python 版本、确认特定二进制文件存在），并以非零退出码阻止安装。这一机制的安全意义在于：它为 Skill 作者提供了一个声明式的前置检查点，防止不兼容的 Skill 被安装到不具备运行条件的环境中。</p>
<div class="blog_h2"><span class="graybg">官方子仓库矩阵</span></div>
<p>OpenClaw 组织下维护着一系列功能各异的子仓库：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>仓库</td>
<td>Stars</td>
<td>定位</td>
</tr>
</thead>
<tbody>
<tr>
<td>openclaw/acpx</td>
<td>1,834</td>
<td>无头 ACP CLI（Agent Control Protocol 命令行客户端）</td>
</tr>
<tr>
<td>openclaw/lobster</td>
<td>992</td>
<td>工作流 Shell（Workflow Shell），交互式任务编排</td>
</tr>
<tr>
<td>openclaw/nix-openclaw</td>
<td>611</td>
<td>Nix Flake 声明式部署</td>
</tr>
<tr>
<td>openclaw/openclaw-ansible</td>
<td>545</td>
<td>Ansible Playbook 自动化部署</td>
</tr>
<tr>
<td>openclaw/openclaw-windows-node</td>
<td>405</td>
<td>Windows 系统托盘 + PowerToys 集成</td>
</tr>
<tr>
<td>openclaw/openclaw.ai</td>
<td>250</td>
<td>官方网站源码</td>
</tr>
<tr>
<td>openclaw/community</td>
<td>92</td>
<td>社区治理文档与 Discord 管理策略</td>
</tr>
<tr>
<td>openclaw/trust</td>
<td>35</td>
<td>安全策略、漏洞披露流程、审计报告</td>
</tr>
<tr>
<td>openclaw/caclawphony</td>
<td>34</td>
<td>Symphony 自主运行框架，长时间无人值守 Agent 任务</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">社区竞品与替代方案</span></div>
<p>OpenClaw 并非孤立存在。以下项目在不同维度上与其构成竞争或互补关系：</p>
<p>HKUDS/nanobot（37,216 stars）定位为"超轻量 OpenClaw 替代品"，去除了多渠道和插件系统的复杂性，聚焦于单一 CLI 场景下的极速响应。其核心卖点是亚秒级冷启动和极低内存占用，适合资源受限的边缘设备。</p>
<p>chatgpt-on-wechat/CowAgent（42,673 stars）以微信生态为核心战场，提供企业微信、公众号、小程序等深度集成。在中国市场，微信的渗透率使其成为 AI Agent 的天然入口，CowAgent 在这一垂直领域的覆盖深度超过 OpenClaw。</p>
<p>AstrBot（28,373 stars）是一个 IM 聊天机器人框架，支持 QQ、飞书、钉钉等国内主流即时通讯平台。与 OpenClaw 的平台无关性不同，AstrBot 选择深耕国内生态，提供更贴合国内开发者习惯的 API 设计。</p>
<div class="blog_h2"><span class="graybg">文档与国际化</span></div>
<p>OpenClaw 的文档基于 <span style="background-color: #c0c0c0;">Mintlify</span> 构建，部署在 docs.openclaw.ai。中文本地化版本位于 docs.openclaw.ai/zh-CN。</p>
<p>国际化（i18n）流水线的技术实现值得关注：翻译工作由 scripts/docs-i18n 脚本驱动，该脚本读取 glossary.zh-CN.json 作为术语表（Glossary），确保专有名词翻译一致（例如 "Gateway" 始终翻译为"网关"，"Skill" 保留英文）。翻译记忆（Translation Memory）存储在 zh-CN.tm.jsonl 文件中，采用 JSON Lines 格式，每行一对源文本与译文。该文件的作用类似于传统本地化工具中的 TMX 文件——当源文本发生变化时，i18n 脚本先在翻译记忆中查找完全匹配或模糊匹配，避免重复翻译已有内容。</p>
<p>社区交流的主阵地是 Discord（discord.gg/clawd），这也是获取实时开发进度和与维护者直接交流的主要渠道。</p>
<div class="blog_h1"><span class="graybg">竞品与展望</span></div>
<div class="blog_h2"><span class="graybg">全维度竞品对比</span></div>
<p>以下表格从多个维度对比 OpenClaw 与当前主流 AI Agent 框架：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>维度</td>
<td>OpenClaw</td>
<td>Manus</td>
<td>AutoGen</td>
<td>LangChain</td>
<td>OpenHands</td>
</tr>
</thead>
<tbody>
<tr>
<td>协议</td>
<td>MIT</td>
<td>闭源 SaaS</td>
<td>MIT (CC-BY-4.0 docs)</td>
<td>MIT</td>
<td>MIT</td>
</tr>
<tr>
<td>部署方式</td>
<td>本地优先 + Docker + Ansible</td>
<td>纯云端</td>
<td>本地 / 云端</td>
<td>本地 / 云端</td>
<td>Docker 容器</td>
</tr>
<tr>
<td>主语言</td>
<td>TypeScript</td>
<td>未公开</td>
<td>Python</td>
<td>Python</td>
<td>Python</td>
</tr>
<tr>
<td>多渠道</td>
<td>Discord/Telegram/WhatsApp/Slack/Web/SMS 等 15+</td>
<td>Web 唯一入口</td>
<td>无原生渠道</td>
<td>无原生渠道</td>
<td>Web UI</td>
</tr>
<tr>
<td>语音支持</td>
<td>原生 Realtime API</td>
<td>有</td>
<td>无</td>
<td>社区扩展</td>
<td>无</td>
</tr>
<tr>
<td>插件系统</td>
<td>Skills + 插件 SDK + ClawHub</td>
<td>内置工具</td>
<td>工具注册</td>
<td>工具/链/代理</td>
<td>沙箱工具</td>
</tr>
<tr>
<td>记忆系统</td>
<td>SQLite + 向量 + 知识图谱</td>
<td>云端对话历史</td>
<td>内存状态</td>
<td>多种 Memory 类型</td>
<td>对话历史</td>
</tr>
<tr>
<td>安全模型</td>
<td>三层沙箱 + 权限 DSL + 审计日志</td>
<td>平台托管</td>
<td>无内置沙箱</td>
<td>无内置沙箱</td>
<td>Docker 沙箱</td>
</tr>
<tr>
<td>社区规模</td>
<td>342K stars, 20K+ commits</td>
<td>N/A（闭源）</td>
<td>42K stars</td>
<td>105K stars</td>
<td>55K stars</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">OpenClaw 的差异化定位</span></div>
<p>从对比中可以提炼出 OpenClaw 的三个核心差异化支柱：</p>
<p><strong>本地优先（Local-First）</strong>：所有数据默认存储在用户设备上。Gateway 进程运行在本地或用户控制的服务器上，LLM API 调用从用户设备直接发出，不经过任何第三方中转服务器。这一设计对于受数据合规要求约束的企业用户尤为关键——GDPR、HIPAA 等法规对数据离境有严格限制，本地优先架构天然满足这些要求。</p>
<p><strong>多渠道原生支持（Multi-Channel Native）</strong>：不是通过适配器（Adapter）将单一接口包装成多个渠道，而是在架构层面将渠道（Channel）作为一等公民（First-Class Citizen）。每个渠道有独立的消息格式化器（Formatter）、权限模型和用户身份映射。</p>
<p><strong>MIT 全开放</strong>：不设 Enterprise 版本，不保留核心功能的闭源组件。所有功能对所有用户完全免费。这种策略在商业模式上高度依赖赞助商和社区贡献，但在信任建立上极为有效。</p>
<div class="blog_h2"><span class="graybg">赞助商策略分析</span></div>
<p>OpenAI、NVIDIA 和 Vercel 为何赞助一个本地优先的"竞争者"？这一看似矛盾的赞助关系背后是清晰的战略逻辑：</p>
<p><strong>OpenAI</strong>：OpenClaw 的每一次 Agent 调用都消耗 OpenAI 的 API Token。本地优先≠不用云模型，恰恰相反——OpenClaw 是 OpenAI API 的超级分发渠道。每个 OpenClaw 用户都是潜在的 API 付费用户，且使用频率远高于普通 ChatGPT 用户。赞助 OpenClaw 是一种<span style="background-color: #c0c0c0;">生态锁定（Ecosystem Lock-in）</span>策略：当开发者习惯了 OpenClaw + GPT-4 的工作流，切换到其他模型的成本就会显著上升。</p>
<p><strong>NVIDIA</strong>：本地推理（Local Inference）是 OpenClaw 的长期演进方向之一。当用户开始在本地运行开源 LLM 时，对 GPU 算力的需求直接转化为 NVIDIA 的硬件销售。赞助 OpenClaw 是在培育<span style="background-color: #c0c0c0;">本地推理（Local Inference）</span>的市场需求。</p>
<p><strong>Vercel</strong>：OpenClaw 的 Web Control UI、文档站点、ClawHub 市场均可部署在 Vercel 平台上。赞助开源项目是 Vercel 拓展开发者工具生态的标准动作，与其赞助 Next.js、Turborepo 的逻辑一致。</p>
<div class="blog_h2"><span class="graybg">挑战与局限性</span></div>
<p>客观评估，OpenClaw 面临以下结构性挑战：</p>
<p><strong>16,843 个待处理 Issue</strong>：截至 2026 年 4 月，仓库有超过 16,000 个开放 Issue。这一数字反映了极高的用户参与度，但同时也意味着维护团队面临巨大的分诊压力（Triage Pressure）。大量 Issue 长期无响应，社区信任可能随时间侵蚀。</p>
<p><strong>Node.js 环境门槛</strong>：对于非 JavaScript 开发者，安装和维护 Node.js 环境本身就是一个障碍。Python 生态的 AutoGen 和 LangChain 在这一点上有天然优势——Python 的安装和环境管理对数据科学家和研究人员更为友好。</p>
<p><strong>单一维护者风险（Bus Factor）</strong>：steipete 贡献了 14,756 个提交，占总提交数（20,000+）的约 73%。第二贡献者（vincentkoc，1,690 个提交）的差距巨大。这意味着项目高度依赖单一个人，其 <span style="background-color: #c0c0c0;">巴士因子（Bus Factor）</span> 接近 1——如果核心维护者因任何原因无法继续，项目存续性将受到严重威胁。</p>
<p><strong>激进的重构节奏</strong>：几乎每个版本都包含破坏性变更（Breaking Changes）。插件 API 的频繁变动导致社区 Skills 的维护成本很高——一个 Skill 可能在几周内因上游 API 变化而失效。这种"快速迭代"与"稳定平台"之间的张力是 OpenClaw 当前最大的架构风险。</p>
<div class="blog_h2"><span class="graybg">未来演进方向</span></div>
<p><strong>TypeScript 原生编译器</strong>：OpenClaw 正在跟踪 @typescript/native-preview 7.0.0-dev（基于 Go 语言实现的 TypeScript 原生编译器）。该编译器承诺 10 倍以上的编译速度提升，这对 OpenClaw 的开发体验和 CI/CD 流水线效率将产生显著影响。仓库中已有实验性分支开始适配新编译器的特性。</p>
<p><strong>插件 SDK 稳定化</strong>：当前插件系统存在多个历史遗留路径（Legacy Paths），包括已废弃但尚未删除的旧版导入方式。SDK 稳定化的核心目标是确定一个长期不变的 API 面（API Surface），将所有旧路径标记为 deprecated 并在后续版本中移除。</p>
<p><strong>微信官方集成</strong>：与腾讯的合作将带来微信渠道的官方支持，而非目前依赖第三方库的间接集成。这对中国市场的渗透率至关重要——微信的月活跃用户数超过 13 亿，官方渠道意味着更稳定的 API 和更低的封号风险。</p>
<p><strong>企业级采用路径</strong>：Ansible Playbook + Docker 容器 + 沙箱隔离的组合已为企业部署铺平道路。下一步是完善 RBAC（基于角色的访问控制）、审计日志导出（Audit Log Export）到 SIEM 系统、以及 SSO（单点登录）集成。</p>
<div class="blog_h2"><span class="graybg">终评：本地优先 AI Agent 范式的代表</span></div>
<p>OpenClaw 代表的是一种明确的技术哲学：<strong>AI Agent 应该运行在用户控制的基础设施上，数据不应离开用户的信任边界</strong>。这一立场在当前"一切皆云"的行业趋势中显得逆流而行，但正是这种逆流赋予了它独特的价值。</p>
<p>从技术实现角度看，OpenClaw 在 TypeScript 单体仓库中实现了渠道抽象、插件隔离、三层沙箱、向量记忆和跨平台原生应用等复杂功能，代码质量和架构设计的成熟度远超其仅四个月的年龄。但其单一维护者集中度、激进重构节奏和日益庞大的 Issue 积压也构成了实质性风险。</p>
<p>对于开发者而言，OpenClaw 是目前最完整的开源本地优先 AI Agent 平台，没有之一。对于企业而言，其 Docker + Ansible + 沙箱的组合提供了可审计、可隔离、可复现的部署路径。对于 AI 行业而言，它证明了"本地优先"不是一个妥协方案，而是一个可以在功能完备性上与云端 SaaS 竞品正面对抗的架构范式。</p>
<div class="blog_h1"><span class="graybg">参考资源</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td>资源</td>
<td>链接</td>
</tr>
</thead>
<tbody>
<tr>
<td>GitHub 主仓库</td>
<td><a href="https://github.com/openclaw/openclaw">github.com/openclaw/openclaw</a></td>
</tr>
<tr>
<td>官方网站</td>
<td><a href="https://openclaw.ai">openclaw.ai</a></td>
</tr>
<tr>
<td>英文文档</td>
<td><a href="https://docs.openclaw.ai">docs.openclaw.ai</a></td>
</tr>
<tr>
<td>中文文档</td>
<td><a href="https://docs.openclaw.ai/zh-CN">docs.openclaw.ai/zh-CN</a></td>
</tr>
<tr>
<td>Discord 社区</td>
<td><a href="https://discord.gg/clawd">discord.gg/clawd</a></td>
</tr>
<tr>
<td>ClawHub 市场</td>
<td><a href="https://clawhub.com">clawhub.com</a></td>
</tr>
<tr>
<td>ClawHub 源码</td>
<td><a href="https://github.com/openclaw/clawhub">github.com/openclaw/clawhub</a></td>
</tr>
<tr>
<td>Star 增长曲线</td>
<td><a href="https://star-history.com/#openclaw/openclaw">star-history.com/#openclaw/openclaw</a></td>
</tr>
<tr>
<td>DeepWiki 分析</td>
<td><a href="https://deepwiki.com/openclaw/openclaw">deepwiki.com/openclaw/openclaw</a></td>
</tr>
<tr>
<td>安全信任中心</td>
<td><a href="https://trust.openclaw.ai">trust.openclaw.ai</a></td>
</tr>
<tr>
<td>安全联络邮箱</td>
<td><a href="mailto:security@openclaw.ai">security@openclaw.ai</a></td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">官方子仓库导航</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">仓库</td>
<td style="text-align: center;">Stars</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/openclaw/openclaw">openclaw/openclaw</a></td>
<td>343,696</td>
<td>主仓库：CLI、Gateway、Agent 运行时、Plugin SDK</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/clawhub">openclaw/clawhub</a></td>
<td>7,214</td>
<td>官方 Skill 目录平台</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/skills">openclaw/skills</a></td>
<td>3,622</td>
<td>ClawHub 所有 Skill 版本归档</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/acpx">openclaw/acpx</a></td>
<td>1,834</td>
<td>无头 ACP CLI：有状态 Agent Client Protocol 会话</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/lobster">openclaw/lobster</a></td>
<td>992</td>
<td>Lobster 工作流 Shell：类型化 JSON 管线 + 审批门</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/nix-openclaw">openclaw/nix-openclaw</a></td>
<td>611</td>
<td>Nix 声明式打包支持</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/openclaw-ansible">openclaw/openclaw-ansible</a></td>
<td>545</td>
<td>Ansible 自动化部署（Tailscale + UFW + Docker）</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/openclaw-windows-node">openclaw/openclaw-windows-node</a></td>
<td>405</td>
<td>Windows 系统托盘 + PowerToys 命令面板扩展</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/openclaw.ai">openclaw/openclaw.ai</a></td>
<td>250</td>
<td>官方网站源码</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/trust">openclaw/trust</a></td>
<td>35</td>
<td>安全信任策略与威胁模型</td>
</tr>
<tr>
<td><a href="https://github.com/openclaw/caclawphony">openclaw/caclawphony</a></td>
<td>34</td>
<td>Symphony：项目任务 → 隔离自主执行</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">CLI 命令速查</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">命令</td>
<td style="text-align: center;">用途</td>
</tr>
</thead>
<tbody>
<tr>
<td>openclaw onboard</td>
<td>交互式引导安装（Gateway + 渠道 + Skills）</td>
</tr>
<tr>
<td>openclaw gateway run</td>
<td>启动 Gateway 控制平面</td>
</tr>
<tr>
<td>openclaw agent --message "..."</td>
<td>向 Agent 发送消息</td>
</tr>
<tr>
<td>openclaw channels status --probe</td>
<td>检查所有渠道连接状态</td>
</tr>
<tr>
<td>openclaw channels login</td>
<td>渠道登录（如 WhatsApp QR 扫码）</td>
</tr>
<tr>
<td>openclaw pairing approve <channel> <code></td>
<td>审批 DM 配对请求</td>
</tr>
<tr>
<td>openclaw doctor</td>
<td>诊断配置问题与安全风险</td>
</tr>
<tr>
<td>openclaw config set <key> <value></td>
<td>修改配置项</td>
</tr>
<tr>
<td>openclaw update --channel <ch></td>
<td>切换发布频道并更新</td>
</tr>
<tr>
<td>openclaw message send --to <target></td>
<td>向指定目标发送消息</td>
</tr>
<tr>
<td>openclaw gateway status</td>
<td>查看 Gateway 运行状态</td>
</tr>
<tr>
<td>openclaw nodes list</td>
<td>列出已连接的设备节点</td>
</tr>
<tr>
<td>clawhub install <skill></td>
<td>从 ClawHub 安装 Skill</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">聊天内命令速查</span></div>
<p>以下命令可在 WhatsApp、Telegram、Slack、Discord、Teams、WebChat 等渠道的对话中直接发送：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">命令</td>
<td style="text-align: center;">功能</td>
</tr>
</thead>
<tbody>
<tr>
<td>/status</td>
<td>查看当前会话状态（模型 + token 用量 + 费用）</td>
</tr>
<tr>
<td>/new 或 /reset</td>
<td>重置会话</td>
</tr>
<tr>
<td>/compact</td>
<td>压缩会话上下文（生成摘要）</td>
</tr>
<tr>
<td>/think <level></td>
<td>设置思考等级：off|minimal|low|medium|high|xhigh</td>
</tr>
<tr>
<td>/verbose on|off</td>
<td>控制详细输出</td>
</tr>
<tr>
<td>/usage off|tokens|full</td>
<td>每条回复后显示用量统计</td>
</tr>
<tr>
<td>/restart</td>
<td>重启 Gateway（群组中仅 owner 可用）</td>
</tr>
<tr>
<td>/activation mention|always</td>
<td>群组激活模式切换</td>
</tr>
<tr>
<td>/elevated on|off</td>
<td>切换提升权限的 bash 访问</td>
</tr>
<tr>
<td>/approve</td>
<td>审批挂起的工具执行或插件操作</td>
</tr>
<tr>
<td>/acp spawn codex --bind here</td>
<td>在当前对话中创建 ACP 工作区</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">数据声明</span></div>
<p>本文所有技术细节均来源于以下一手数据，不依赖任何二手社区资料：</p>
<ul>
<li>GitHub API（api.github.com/repos/openclaw/openclaw）：仓库元数据、Stars/Forks/Issues 统计、贡献者排名、Release 说明</li>
<li>AGENTS.md（仓库根目录，35,263 字节）：架构规范、模块边界、构建指南、测试策略、发布守则</li>
<li>package.json（仓库根目录）：233 个 exports 条目（含 230 个 plugin-sdk 子路径）、47 个运行时依赖、22 个开发依赖、198 个 npm scripts</li>
<li>Dockerfile 与 docker-compose.yml：完整的容器化构建与部署配置</li>
<li>README.md（GitHub API base64 解码）：官方功能列表、渠道支持、安装指南、安全模型</li>
<li>GitHub Releases API（v2026.3.31、v2026.3.28 Release Notes）：破坏性变更与新功能详情</li>
</ul>
<div class="blog_h2"><span class="graybg">最小可用配置</span></div>
<p>完成 openclaw onboard 后，系统会生成一个最小配置文件 ~/.openclaw/openclaw.json。手动配置时，最小可用的 JSON 如下：</p>
<pre class="crayon-plain-tag">{
  "agent": {
    "model": "anthropic/claude-sonnet-4-6"
  }
}</pre>
<p>仅需指定一个模型即可启动 Gateway。Agent 会使用该模型进行所有推理任务。更复杂的配置可以声明多模型故障转移、渠道接入、安全策略、沙箱模式和 Skills：</p>
<pre class="crayon-plain-tag">{
  "agent": {
    "model": "openai/gpt-5.2",
    "fallbackModels": ["anthropic/claude-sonnet-4-6", "google/gemini-2.5-flash"],
    "thinkingLevel": "medium"
  },
  "agents": {
    "defaults": {
      "workspace": "~/.openclaw/workspace",
      "sandbox": {
        "mode": "non-main"
      }
    }
  },
  "channels": {
    "telegram": {
      "botToken": "123456:ABCDEF",
      "dmPolicy": "pairing",
      "allowFrom": []
    },
    "discord": {
      "token": "your-discord-bot-token",
      "dmPolicy": "pairing"
    },
    "whatsapp": {
      "allowFrom": ["+1234567890"]
    }
  },
  "browser": {
    "enabled": true
  },
  "gateway": {
    "mode": "local",
    "auth": {
      "mode": "token"
    }
  }
}</pre>
<p>完整配置参考详见 <a href="https://docs.openclaw.ai/gateway/configuration">docs.openclaw.ai/gateway/configuration</a>。配置文件支持 JSON5 格式（允许注释和尾逗号），这是一个面向人类可读性的设计选择。</p>
<div class="blog_h2"><span class="graybg">微信接入：腾讯官方插件</span></div>
<p>对于中国用户群体，微信接入是一个高优先级需求。OpenClaw 的微信支持通过腾讯官方发布的 npm 包 @tencent-weixin/openclaw-weixin 实现，基于 iLink Bot API。这是一个具有里程碑意义的集成——标志着腾讯以官方身份参与开源 AI Agent 生态。</p>
<p>安装与激活流程：</p>
<pre class="crayon-plain-tag"># 安装微信插件
openclaw plugins install "@tencent-weixin/openclaw-weixin"

# 扫码登录
openclaw channels login --channel openclaw-weixin</pre>
<p>微信集成当前仅支持私聊（Private Chat），不支持群聊。v2.x 版本要求 OpenClaw >=2026.3.22。用户需要在微信客户端（我 → 设置 → 插件）中启用"微信 ClawBot 插件"——该功能由腾讯逐步灰度发布。</p>
<p>这种通过官方 npm 包而非逆向工程实现的微信接入路径，避免了 chatgpt-on-wechat 等项目面临的账号封禁风险，具有更高的稳定性和合规性。但目前的功能受限（仅私聊）也反映了腾讯在开放微信生态时的审慎态度。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/claw">OpenClaw学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/claw/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager</title>
		<link>https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager</link>
		<comments>https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager#comments</comments>
		<pubDate>Mon, 14 Oct 2024 06:45:45 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[Cloud]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=40243</guid>
		<description><![CDATA[<p>In this blog post, I will walk through my journey investigating and resolving an issue where my certificate request from ZeroSSL, using <a class="read-more" href="https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager">Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><p>In this blog post, I will walk through my journey investigating and resolving an issue where my certificate request from ZeroSSL, using Cert-Manager, remained in a "not ready" state for over two days. I'll cover the tools involved, provide background information, and show how I eventually identified and fixed the problem.</p>
<div class="blog_h1"><span class="graybg">Background on ACME</span></div>
<div class="blog_h2"><span class="graybg">ACME</span></div>
<p>ACME (Automatic Certificate Management Environment) is a protocol developed by the Internet Security Research Group (ISRG) to automate the process of obtaining and managing SSL/TLS certificates from Certificate Authorities (CAs). It simplifies the traditionally manual steps involved in certificate issuance by using an automated process. ACME is widely used by services like Let’s Encrypt and ZeroSSL to secure websites with HTTPS.</p>
<p>The ACME protocol automates interactions between a client (such as Cert-Manager or Certbot) and a CA, allowing the client to request, renew, and manage certificates without human intervention. ACME operates through a series of challenges that prove ownership of the domain for which a certificate is requested. Once the ownership is verified, the CA can issue the certificate.</p>
<div class="blog_h3"><span class="graybg">HTTP-01 Challenge</span></div>
<p>In this challenge, the client proves ownership of a domain by hosting a specific file at a designated path (e.g., http://gmem.cc/.well-known/acme-challenge/). The CA attempts to retrieve this file, and if successful, the challenge is validated. This challenge is commonly used for publicly accessible web servers.</p>
<div class="blog_h3"><span class="graybg">DNS-01 Challenge</span></div>
<p>In this challenge, the client proves domain ownership by creating a special DNS TXT record for the domain. The CA checks the DNS record to confirm ownership. DNS-01 is typically used for wildcard certificates or when the server is not publicly accessible because it doesn’t rely on serving files over HTTP.</p>
<div class="blog_h3"><span class="graybg">TLS-ALPN-01 Challenge</span></div>
<p>This challenge requires the client to prove control of a domain by configuring a TLS server with a special certificate during the ACME validation process. The CA then connects to the server via TLS and checks the certificate. This challenge is less common and usually used in specialized environments.</p>
<div class="blog_h1"><span class="graybg">Background on Cert-Manager</span></div>
<p>Cert-Manager is an open-source Kubernetes add-on that automates the management, issuance, and renewal of certificates within Kubernetes clusters. It integrates with various Certificate Authorities (CAs) and protocols, including ACME (used by providers like Let’s Encrypt and ZeroSSL). Cert-Manager is widely used to ensure that certificates remain valid and secure without manual intervention.</p>
<div class="blog_h2"><span class="graybg">Cert-Manager Components</span></div>
<p>When deploying Cert-Manager in Kubernetes, several key components work together to handle certificate management.</p>
<div class="blog_h3"><span class="graybg">cert-manager</span></div>
<p>The core component of the Cert-Manager system, responsible for managing the lifecycle of certificates and interacting with Issuers (such as ACME servers like Let’s Encrypt or ZeroSSL). It runs as a Kubernetes controller and is responsible for:</p>
<ol>
<li>Watching Certificate, CertificateRequest, Issuer, ClusterIssuer, Order, and Challenge resources.</li>
<li>Requesting certificates from CAs.</li>
<li>Automatically renewing certificates before expiration.</li>
<li>Handling the interactions with external CAs (via ACME, Vault, Venafi, etc.).</li>
</ol>
<p>This component performs the actual management of certificates, from creation to renewal, ensuring that the requested certificates are stored securely as Kubernetes Secrets.</p>
<div class="blog_h3"><span class="graybg">cert-manager-cainjector</span></div>
<p>The CA Injector is an additional component that works alongside Cert-Manager to inject CA data into other Kubernetes resources. It primarily operates on Kubernetes ValidatingWebhookConfiguration and MutatingWebhookConfiguration resources, injecting certificates into them automatically. This is necessary for:</p>
<ol>
<li>Mutating admission controllers that require TLS certificates for secure communication.</li>
</ol>
<ol>
<li>Ensuring that Kubernetes components relying on CA certificates have up-to-date CA data.</li>
</ol>
<p>The cainjector is critical in environments where certain Kubernetes components (e.g., webhooks) require their certificates to be signed by a trusted CA.</p>
<div class="blog_h3"><span class="graybg">cert-manager-webhook</span></div>
<p>The Webhook component provides an admission controller that validates Cert-Manager resources like Certificate, Issuer, ClusterIssuer, and CertificateRequest upon creation or update. It ensures that the Cert-Manager resources are correctly configured by:</p>
<ol>
<li>Validating resources before they are accepted into the Kubernetes API (syntax and structure).</li>
</ol>
<ol>
<li>Mutating resources to provide defaults (for example, setting default values in a Certificate resource).</li>
</ol>
<ol>
<li>Providing a layer of security and correctness by ensuring invalid configurations are caught early.</li>
</ol>
<p>The webhook helps catch configuration issues early, improving the reliability of certificate management workflows.</p>
<div class="blog_h3"><span class="graybg">cert-manager-webhook-dnspod</span></div>
<p>This webhook, which is maintained by the <a href="https://github.com/imroc/cert-manager-webhook-dnspod">community</a>, specifically handles DNS-01 challenges for domains hosted in Tencent Cloud’s DNSPod. When Cert-Manager requests a certificate using the DNS-01 challenge, it needs to create a DNS TXT record in the domain's DNS zone. cert-manager-webhook-dnspod facilitates this by interacting with the DNSPod API to manage DNS records.</p>
<p>Cert-Manager invokes this webhook when it needs to solve a DNS-01 challenge using DNSPod as the DNS provider. The webhook receives instructions from Cert-Manager, communicates with the DNSPod API to create or delete DNS TXT records, and reports back to Cert-Manager when the challenge is complete.</p>
<p>In this post the webhook was created with the following manifest:</p>
<pre class="crayon-plain-tag">apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager-webhook-dnspod
  namespace: argocd
spec:
  destination:
    namespace: cert-manager
    server: https://kubernetes.default.svc
  project: default
  source:
    repoURL: https://github.com/imroc/cert-manager-webhook-dnspod
    targetRevision: master
    path: charts/cert-manager-webhook-dnspod
    helm:
      releaseName: cert-manager-webhook-dnspod
      values: |
        groupName: acme.dnspod.tencent.com
        clusterIssuer:
          name: letsencrypt-issuer
          secretId: DNSPOD_SECRETID
          secretKey: DNSPOD_SECRETKEY
          email: gmem@me.com
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true
 </pre>
<div class="blog_h2"><span class="graybg">Cert-Manager CRDs</span></div>
<p>Cert-Manager relies on several types of custom resources that work together to manage the certificate lifecycle:</p>
<ol style="list-style-type: undefined;">
<li>Issuer/ClusterIssuer:
<ol>
<li>An Issuer or ClusterIssuer is a Cert-Manager resource that defines how certificates should be requested from a Certificate Authority (CA). The difference between them is that an Issuer is scoped to a single namespace, whereas a ClusterIssuer can be used cluster-wide.</li>
<li>Issuers can be configured to use different CA backends, such as ACME, Vault, or self-signed certificates.</li>
</ol>
</li>
<li>Certificate: The Certificate resource defines which certificates should be issued and managed by Cert-Manager. It specifies details such as the domain names, issuer to use, renewal period, and where to store the certificate (usually in a Kubernetes secret).</li>
<li>CertificateRequest: When a Certificate resource is created, Cert-Manager generates a CertificateRequest. This resource represents the actual request to the Issuer for a certificate. Cert-Manager manages these requests and handles the approval and signing process.</li>
<li>Order: When Cert-Manager requests a certificate from an ACME-based Issuer, it creates an Order resource to track the status of the certificate issuance. The Order keeps track of challenges and interactions with the ACME server.</li>
<li>Challenge: A Challenge resource represents the ACME challenge issued by the CA (such as DNS-01 or HTTP-01). The challenge proves domain ownership by requiring the client (Cert-Manager) to respond to a domain validation request from the CA.</li>
</ol>
<div class="blog_h2"><span class="graybg">Cert-Manager  Challenge Workflows</span></div>
<div class="blog_h3"><span class="graybg">HTTP-01 Challenge<br /></span></div>
<p>The HTTP-01 challenge is used when the domain is publicly accessible over HTTP. The CA validates ownership of the domain by requesting a specific file over HTTP. Cert-Manager sets up the challenge response using a Kubernetes Ingress resource.</p>
<ol>
<li>Create an Issuer or ClusterIssuer: The Issuer defines the connection to the CA (such as Let’s Encrypt or ZeroSSL) and specifies that ACME should be used with the HTTP-01 challenge.</li>
<li>Create a Certificate Resource: A Certificate resource is created that specifies the domain names for which a certificate is needed and the Issuer to use.</li>
<li>Cert-Manager Creates a CertificateRequest: Cert-Manager generates a CertificateRequest based on the Certificate resource.</li>
<li>Cert-Manager Creates an Order: Cert-Manager creates an Order resource to track the status of the certificate request with the ACME server.</li>
<li>Cert-Manager Creates an HTTP-01 Challenge: An HTTP-01 challenge is created, and Cert-Manager configures an Ingress resource to serve the challenge response at the path /.well-known/acme-challenge/.</li>
<li>ACME Server Attempts to Validate: The CA attempts to access the challenge file via the HTTP URL (e.g., http://example.com/.well-known/acme-challenge/&lt;token&gt;). If successful, the challenge is validated.</li>
<li>Certificate Issued: Once the challenge is validated, the CA issues the certificate, and Cert-Manager stores it in the specified Kubernetes Secret.</li>
</ol>
<div class="blog_h3"><span class="graybg">DNS-01 Challenge </span></div>
<p>The DNS-01 challenge is used when domain ownership must be validated via DNS records. This method is often preferred for wildcard certificates or domains that are not publicly accessible over HTTP.</p>
<ol>
<li>Create an Issuer or ClusterIssuer: The Issuer specifies that ACME should be used with the DNS-01 challenge and includes configuration for interacting with the DNS provider (e.g., AWS Route53, Cloudflare, or a custom webhook like dnspod).</li>
<li>Create a Certificate Resource: A Certificate resource is created that defines the domains for which the certificate is needed and the Issuer to use.</li>
<li>Cert-Manager Creates a CertificateRequest: Cert-Manager generates a CertificateRequest based on the Certificate resource.</li>
<li>Cert-Manager Creates an Order: Cert-Manager creates an Order resource to track the status of the certificate request with the ACME server.</li>
<li>Cert-Manager Creates a DNS-01 Challenge: A DNS-01 challenge is created, and Cert-Manager interacts with the configured DNS provider to automatically create a special TXT record for the domain (e.g., _acme-challenge.example.com).</li>
<li>ACME Server Attempts to Validate: The CA checks for the presence of the _acme-challenge.example.com TXT record in the domain’s DNS records. If the correct value is found, the challenge is validated.</li>
<li>Certificate Issued: Once the DNS challenge is validated, the CA issues the certificate, and Cert-Manager stores it in the specified Kubernetes Secret.</li>
</ol>
<div class="blog_h1"><span class="graybg">The Problem, Investigation and Fix<br /></span></div>
<div class="blog_h2"><span class="graybg">Problem Statement </span></div>
<p>I created a ClusterIssuer and a Certificate for the wildcard domain *.gmem.cc. However, after waiting for more than two days, the certificate status still showed as not ready, with the following condition in the certificate's status:</p>
<pre class="crayon-plain-tag">status:
  conditions:
    - lastTransitionTime: "2024-10-14T06:02:10Z"
      message: Issuing certificate as Secret does not exist
      observedGeneration: 1
      reason: DoesNotExist
      status: "False"
      type: Ready</pre>
<p>I checked on DNSPod and found a <pre class="crayon-plain-tag">TXT</pre> record <pre class="crayon-plain-tag">_acme-challenge.gmem.cc</pre> with the TTL set to 600 seconds.</p>
<div class="blog_h2"><span class="graybg">Initial Investigation</span></div>
<p>To diagnose the issue, I checked the status of the related Cert-Manager resources: <pre class="crayon-plain-tag">CertificateRequest</pre>, <pre class="crayon-plain-tag">Order</pre>, and <pre class="crayon-plain-tag">Challenge</pre>. These are the key resources that Cert-Manager uses to interact with ACME and handle certificate issuance. Here’s an overview of my findings:</p>
<p>CertificateRequest:</p>
<pre class="crayon-plain-tag">status:
  conditions:
    - lastTransitionTime: "2024-10-14T06:02:14Z"
      message: Certificate request has been approved by cert-manager.io
      reason: cert-manager.io
      status: "True"
      type: Approved
    - lastTransitionTime: "2024-10-14T06:02:14Z"
      message: 'Waiting on certificate issuance from order istio-system/wildcard-ssl-5pgsr-3353861729: "pending"'
      reason: Pending
      status: "False"
      type: Ready</pre>
<p>The certificate request was approved, but the system was waiting for the certificate issuance to complete.</p>
<p>Order: </p>
<pre class="crayon-plain-tag">status:
  state: pending
  authorizations:
    - challenges:
        - token: iQMwrfsFRmJ_MytUY3N4NW6QehtTn0-IEvJWAmYEw_k
          type: dns-01
          url: https://acme.zerossl.com/v2/DV90/chall/DDRBMBd9jnJo_W4EcQfSWQ
      wildcard: true</pre>
<p>The order was still in a "pending" state, and the DNS-01 challenge had not been completed yet.</p>
<p>Challenge:</p>
<pre class="crayon-plain-tag">status:
  presented: true
  processing: true
  reason: 'Waiting for DNS-01 challenge propagation: DNS record for "gmem.cc" not yet propagated'
  state: pending</pre>
<p>The challenge was waiting for DNS propagation, but apparently the <pre class="crayon-plain-tag">TXT</pre> record I mentioned above had been created two days ago.</p>
<div class="blog_h2"><span class="graybg">Global DNS Propagration Check</span></div>
<p>Mutifarious reasons can cause delay on DNS propagation, we can check whether TXT record  _acme-challenge.gmem.cc is synchronized all over the world using <a href="https://www.whatsmydns.net/">whatsmydns.net</a>:</p>
<p style="padding-left: 30px;">https://www.whatsmydns.net/#TXT/_acme-challenge.gmem.cc</p>
<p>In this case, the check result was that the record had been fully propagated.</p>
<div class="blog_h2"><span class="graybg">Cert-Manager Logs</span></div>
<p>I then checked the logs for the Cert-Manager pod to see if any errors were being reported. The relevant and repeating error message from the logs was:</p>
<p style="padding-left: 30px;">E1014 05:52:58.647839 1 sync.go:190] "cert-manager/challenges: propagation check failed" err="DNS record for \"gmem.cc\" not yet propagated"</p>
<p>At this point, it seemed that Cert-Manager was unable to see the propagated DNS records, even though I had confirmed their existence.</p>
<p>After enabling verbose logging (--v5 log level) in Cert-Manager, I finally discovered the root cause. The detailed logs revealed that Cert-Manager was checking the DNS record at an intermediate CNAME:</p>
<p style="padding-left: 30px;">I1014 06:20:33.919849 1 dns.go:116] "cert-manager/challenges/Check: checking DNS propagation"<br />I1014 06:20:33.921190 1 wait.go:90] Updating FQDN: _acme-challenge.gmem.cc. with its CNAME: lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com.<br />I1014 06:25:35.886683 1 wait.go:298] Searching fqdn "lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com." using seed nameservers [10.231.18.121:53]<br />I1014 06:25:35.886696 1 wait.go:329] Returning cached zone record "sg-tencentclb.com." for fqdn "lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com."<br />I1014 06:20:33.987786 1 wait.go:141] Looking up TXT records for "lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com."</p>
<p>The challenge was failing because the DNS record for  <pre class="crayon-plain-tag">*.gmem.cc</pre> was a  CNAME pointing to another domain, lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com, which caused Cert-Manager to search for a wrong TXT record.</p>
<div class="blog_h2"><span class="graybg">The Fix</span></div>
<p>The solution was to<span style="background-color: #c0c0c0;"> remove the wildcard record *.gmem.cc which was CNAMEed to Tencent Cloud Loadbalancer address.</span> After a few minutes, the DNS cache was invalidated and Cert-Manager finally logged something different:</p>
<p style="padding-left: 30px;">I1014 06:25:45.974354 1 wait.go:298] Searching fqdn "_acme-challenge.gmem.cc." using seed nameservers [10.231.18.121:53]<br />I1014 06:25:46.453434 1 wait.go:383] Returning discovered zone record "gmem.cc." for fqdn "_acme-challenge.gmem.cc."<br />I1014 06:25:46.454660 1 wait.go:316] Returning authoritative nameservers [c.dnspod.com., a.dnspod.com., b.dnspod.com.]<br />I1014 06:25:46.462705 1 wait.go:141] Looking up TXT records for "_acme-challenge.gmem.cc."</p>
<p>indicating that the correct TXT record was found. And from the subsequent logs some working detailed of Cert-Manager was revealed:</p>
<p style="padding-left: 30px;">I1014 06:25:47.050500 1 dns.go:128] "cert-manager/challenges/Check: waiting DNS record TTL to allow the DNS01 record to propagate for domain" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" dnsName="gmem.cc" type="DNS-01" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" domain="gmem.cc" ttl=60 fqdn="_acme-challenge.gmem.cc."</p>
<p style="padding-left: 60px;">This line indicated that after Cert-Manager validated the TXT record locally, it would wait for TTL ( 60 here ) seconds, just in case that Zero SSL server hadn't been able to see te record.</p>
<p style="padding-left: 30px;">014 06:26:47.051650 1 sync.go:359] "cert-manager/challenges/acceptChallenge: accepting challenge with ACME server" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" dnsName="gmem.cc" type="DNS-01"<br />I1014 06:26:47.051665 1 logger.go:81] "cert-manager/acme-middleware: Calling Accept"<br />I1014 06:26:49.708221 1 sync.go:376] "cert-manager/challenges/acceptChallenge: waiting for authorization for domain" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" dnsName="gmem.cc" type="DNS-01"<br />I1014 06:26:49.708250 1 logger.go:99] "cert-manager/acme-middleware: Calling WaitAuthorization"<br />I1014 06:26:50.194610 1 logs.go:199] "cert-manager/controller: Event(v1.ObjectReference{Kind:\"Challenge\", Namespace:\"istio-system\", Name:\"wildcard-ssl-5pgsr-3353861729-3026093647\", UID:\"a4b2f2d7-6185-4072-92f0-b7a89418cdb1\", APIVersion:\"acme.cert-manager.io/v1\", ResourceVersion:\"463570645\", FieldPath:\"\"}): type: 'Normal' reason: 'DomainVerified' Domain \"gmem.cc\" verified with \"DNS-01\" validation"</p>
<p style="padding-left: 60px;">After the wait, Cert-Manager called Zero SSL server for Accept and WaitAuthorization operation and the server verified that we were the owner of the domain name.</p>
<div class="blog_h1"><span class="graybg">Conclusion</span></div>
<p>In this case, the root cause of the certificate issuance failure was a CNAME record interfering with Cert-Manager's DNS-01 challenge. It had nothing to do with the ACME server but was linked to Cert-Manager's internal implementation.</p>
<p>To aviod similar issues in the future, we need to stop using wildcard domain records.</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager">Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>A Comprehensive Study of Kotlin for Java Developers</title>
		<link>https://blog.gmem.cc/comprehensive-study-kotlin-java-developers</link>
		<comments>https://blog.gmem.cc/comprehensive-study-kotlin-java-developers#comments</comments>
		<pubDate>Sun, 29 Sep 2024 12:52:40 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[Java]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=40127</guid>
		<description><![CDATA[<p>Introduction Purpose of the Study Understanding the Motivations for Learn Kotlin In the rapidly evolving field of software development, staying abreast of <a class="read-more" href="https://blog.gmem.cc/comprehensive-study-kotlin-java-developers">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/comprehensive-study-kotlin-java-developers">A Comprehensive Study of Kotlin for Java Developers</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">Introduction</span></div>
<div class="blog_h2"><span class="graybg">Purpose of the Study</span></div>
<div class="blog_h3"><span class="graybg">Understanding the Motivations for Learn Kotlin</span></div>
<p>In the rapidly evolving field of software development, staying abreast of emerging technologies is essential for maintaining a competitive edge. Kotlin, a statically typed programming language developed by JetBrains, has garnered significant attention since its release. For developers proficient in Java—especially versions 8 and earlier—exploring Kotlin offers an opportunity to enhance coding efficiency, embrace modern programming paradigms, and address some of the limitations inherent in older versions of Java.</p>
<p>The motivations for learning Kotlin are multifaceted:</p>
<ol style="list-style-type: undefined;">
<li>Modern Language Features: Kotlin introduces contemporary features such as null safety, data classes, and coroutines, which streamline coding practices and reduce common programming errors.</li>
<li>Interoperability: Kotlin is fully interoperable with Java, allowing for seamless integration into existing Java projects and the use of established Java libraries.</li>
<li>Industry Adoption: Major companies, including Google, have endorsed Kotlin for Android development, signaling a shift in industry standards and practices.</li>
</ol>
<div class="blog_h3"><span class="graybg">Setting Goals for Mastering Kotlin with a Java Background</span></div>
<p>As a Java developer delving into Kotlin, it's important to set clear, achievable goals to maximize the benefits of this study:</p>
<ol style="list-style-type: undefined;">
<li>Comprehensive Understanding: Gain a thorough grasp of Kotlin's syntax, features, and best practices.</li>
<li>Comparative Analysis: Identify and understand the similarities and differences between Kotlin and Java to leverage existing knowledge effectively.</li>
<li>Practical Application: Apply Kotlin concepts in real-world scenarios, including Android development and server-side applications.</li>
<li>Interoperability Proficiency: Learn how to integrate Kotlin code with Java, enabling the use of both languages within the same project seamlessly.</li>
<li>Code Optimization: Utilize Kotlin's features to write more concise, efficient, and maintainable code compared to traditional Java approaches.</li>
</ol>
<p>By setting these goals, the study aims to provide a structured pathway to not only learn Kotlin but also to enhance overall programming proficiency.</p>
<div class="blog_h2"><span class="graybg">Overview of Java and Kotlin</span></div>
<div class="blog_h3"><span class="graybg">Brief History and Evolution of Java up to Version 21</span></div>
<p>Java has been a cornerstone in the software development industry since its inception by Sun Microsystems in 1995. Over the years, it has undergone significant transformations, introducing features that address the evolving needs of developers and the industry at large. Below is a chronological overview of Java's evolution up to Java 21, the latest version as of October 2023.</p>
<p>Java 1.0 to Java 1.4 (1996 - 2002): Establishing the Foundation</p>
<ol style="list-style-type: undefined;">
<li>Java 1.0 (1996): The initial release provided the basic framework of the language, focusing on portability and network computing.</li>
<li>Java 1.1 to 1.4: These versions introduced inner classes, JDBC, JavaBeans, the Collections Framework, and enhanced performance and security features.</li>
</ol>
<p>Java 5 (2004): Embracing Modern Programming Concepts</p>
<ol style="list-style-type: undefined;">
<li>Generics: Allowed for type-safe collections.</li>
<li>Annotations: Provided metadata that could be processed by the compiler or at runtime.</li>
<li>Enhanced for-loop: Simplified iteration over collections and arrays.</li>
<li>Autoboxing/Unboxing: Automated conversion between primitive types and their corresponding object wrapper classes.</li>
</ol>
<p>Java 6 and Java 7 (2006 - 2011): Performance and Usability Enhancements</p>
<ol style="list-style-type: undefined;">
<li>Java 6: Focused on performance improvements and included updates to the JVM and core libraries.</li>
</ol>
<ol style="list-style-type: undefined;">
<li>Java 7:
<ol>
<li>Diamond Operator (&lt;&gt;): Simplified the use of generics.</li>
<li>Try-with-Resources: Enhanced exception handling and resource management.</li>
<li>Strings in Switch Statements: Allowed strings to be used in switch cases.</li>
</ol>
</li>
</ol>
<p>Java 8 (2014): A Paradigm Shift with Functional Programming</p>
<ol style="list-style-type: undefined;">
<li>Lambda Expressions: Introduced functional programming concepts, enabling more concise code.</li>
<li>Stream API: Provided a powerful way to process collections in a functional style.</li>
<li>Optional Class: Addressed null references by providing a container object which may or may not contain a non-null value.</li>
<li>Date and Time API: Offered a new set of classes under java.time package for date and time manipulation.</li>
</ol>
<p>Java 9 (2017): Modularization and JShell</p>
<ol style="list-style-type: undefined;">
<li>Project Jigsaw (Modules): Introduced the Java Platform Module System, allowing for better encapsulation and modularization of code.</li>
<li>JShell (REPL): Provided an interactive Read-Eval-Print Loop tool for rapid prototyping.</li>
</ol>
<p>Java 10 (2018): Local Variable Type Inference</p>
<ol style="list-style-type: undefined;">
<li>var Keyword: Enabled local variable type inference, allowing the compiler to infer the type of a variable from its initializer.</li>
</ol>
<p>Java 11 (2018): Long-Term Support and New Features</p>
<ol style="list-style-type: undefined;">
<li>Standardized HTTP Client API: Introduced a new HTTP client under java.net.http.</li>
<li>String Methods Enhancements: Added methods like isBlank(), lines(), strip(), repeat().</li>
<li>Removal of JavaFX: Decoupled JavaFX from the JDK.</li>
</ol>
<p>Java 12 to Java 15 (2019 - 2020): Incremental Improvements</p>
<ol style="list-style-type: undefined;">
<li>Java 12: Switch Expressions (Preview): Enhanced switch statements to be used as expressions.</li>
<li>Java 13: Text Blocks (Preview): Simplified the inclusion of multi-line strings.</li>
<li>Java 14:
<ol>
<li>Records (Preview): Introduced a compact syntax for declaring data classes.</li>
<li>Helpful NullPointerExceptions: Improved the detail in NullPointerException messages.</li>
</ol>
</li>
</ol>
<p>Java 15:</p>
<ol style="list-style-type: undefined;">
<li>Sealed Classes (Preview): Restricted which classes can extend or implement a class or interface.</li>
<li>Z Garbage Collector (Product Feature): Low-latency garbage collector moved from experimental to production.</li>
</ol>
<p>Java 16 and Java 17 (2021): Pattern Matching and Sealed Classes</p>
<ol style="list-style-type: undefined;">
<li>Java 16:
<ol>
<li>Pattern Matching for instanceof: Simplified the use of instanceof with pattern variables.</li>
<li>Records: Moved from preview to a standard feature.</li>
</ol>
</li>
<li>Java 17 (Long-Term Support Release):
<ol>
<li>Sealed Classes: Finalized as a standard feature.</li>
<li>Removal of Deprecated Features: Eliminated older features like the Applet API.</li>
<li>Enhanced Pseudorandom Number Generators: Introduced new interfaces and implementations for PRNGs.</li>
</ol>
</li>
</ol>
<p>Java 18 and Java 19 (2022): Incubator and Preview Features</p>
<ol style="list-style-type: undefined;">
<li>Java 18:
<ol>
<li>UTF-8 by Default: Standardized UTF-8 as the default character set.</li>
<li>Simple Web Server: Provided a command-line tool for starting a minimal web server.</li>
</ol>
</li>
<li>Java 19:
<ol>
<li>Virtual Threads (Preview): Part of Project Loom, introduced lightweight threads for concurrent programming.</li>
<li>Structured Concurrency (Incubator): Simplified multithreaded programming by treating multiple tasks running in different threads as a single unit.</li>
</ol>
</li>
</ol>
<p>Java 20 and Java 21 (2023): Advancements in Performance and Productivity</p>
<ol style="list-style-type: undefined;">
<li>Java 20:
<ol>
<li>Scoped Values (Incubator): Allowed for the sharing of immutable data within and across threads.</li>
<li>Record Patterns (Second Preview): Enhanced pattern matching for records.</li>
</ol>
</li>
<li>Java 21 (Latest LTS as of October 2023):
<ol>
<li>Virtual Threads (Standard Feature): Finalized virtual threads for high-throughput concurrent applications.</li>
<li>Sequenced Collections: Introduced interfaces to represent collections with a defined encounter order.</li>
<li>String Templates (Preview): Provided a new way to create and process strings with embedded expressions.</li>
<li>Pattern Matching for Switch (Standard Feature): Finalized pattern matching in switch expressions and statements.</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">Brief History of Kotlin</span></div>
<p>In 2010 JetBrains began the development of Kotlin, aiming to create a language that could improve developer productivity and happiness. The primary motivations were:</p>
<ol style="list-style-type: undefined;">
<li>Conciseness: Reduce boilerplate code common in Java.</li>
<li>Safety: Introduce features like null safety to prevent common errors.</li>
<li>Interoperability: Ensure seamless integration with Java code and libraries.</li>
<li>Tooling Support: Leverage JetBrains' expertise in IDE development to provide excellent tooling from the outset.</li>
</ol>
<p>In July 2011, JetBrains publicly announced Kotlin, revealing their plans to create a new language for the JVM.</p>
<p>Kotlin 1.0 (February 2016):</p>
<ol style="list-style-type: undefined;">
<li>First Stable Release: Marked the language as production-ready after years of development and refinement.</li>
<li>Core Features: Included null safety, extension functions, data classes, and higher-order functions.</li>
<li>Interoperability: Ensured 100% compatibility with Java, allowing developers to call Kotlin code from Java and vice versa</li>
<li>Google I/O Announcement: Google declared official support for Kotlin on Android, making it a first-class language for Android app development. Led to a significant surge in Kotlin adoption within the Android community.</li>
</ol>
<p>Kotlin 1.1 (March 2017):</p>
<ol style="list-style-type: undefined;">
<li>Coroutines (Experimental): Introduced coroutines for asynchronous programming, allowing developers to write non-blocking code more easily.</li>
<li>JavaScript Target: Enabled compilation of Kotlin code to JavaScript, facilitating cross-platform development.</li>
</ol>
<p>Kotlin 1.2 (November 2017):</p>
<ol style="list-style-type: undefined;">
<li>Multiplatform Projects (Experimental): Allowed sharing code between JVM and JavaScript platforms, paving the way for true cross-platform applications.</li>
<li>Improved Compilation: Enhanced compiler performance and incremental compilation support.</li>
</ol>
<p>Kotlin 1.3 (October 2018):</p>
<ol style="list-style-type: undefined;">
<li>Coroutines Become Stable: Solidified coroutines as a core feature, providing a powerful tool for asynchronous and concurrent programming.</li>
<li>Kotlin/Native: Enabled compilation to native binaries, expanding Kotlin's reach to platforms like iOS, Windows, Linux, and macOS without the need for a virtual machine.</li>
<li>Contracts: Introduced experimental support for contracts, allowing for more precise code analysis.</li>
</ol>
<p>Kotlin 1.4 (August 2020):</p>
<ol style="list-style-type: undefined;">
<li>Multiplatform Enhancements: Improved the multiplatform project support, making it more stable and easier to use.</li>
<li>Compiler Improvements: Focused on performance, resulting in faster compilation times and better IDE responsiveness.</li>
<li>Standard Library Updates: Added new functions and classes to the standard library, enhancing functionality.</li>
</ol>
<p>Kotlin 1.5 (May 2021):</p>
<ol style="list-style-type: undefined;">
<li>Language Features: Introduced JVM records, sealed interfaces, and inline classes.</li>
<li>Stability: Many experimental features were promoted to stable status.</li>
</ol>
<p>Kotlin 1.6 (November 2021):</p>
<ol style="list-style-type: undefined;">
<li>Standard Library Enhancements: Improved existing APIs and added new ones.</li>
<li>Performance: Continued focus on compiler and runtime performance optimizations.</li>
</ol>
<p>Kotlin 1.7 (June 2022):</p>
<ol style="list-style-type: undefined;">
<li>Context Receivers (Experimental): Added support for context-dependent declarations.</li>
<li>K2 Compiler (Alpha): Began work on a new frontend compiler aimed at performance improvements and better tooling.</li>
</ol>
<p>Kotlin 1.8 (January 2023):</p>
<ol style="list-style-type: undefined;">
<li>Incremental Updates: Brought further enhancements to the language and tooling.</li>
<li>Kotlin Multiplatform Mobile (KMM): Progressed towards stabilizing shared code between Android and iOS.</li>
</ol>
<p>Kotlin 1.9 (July 2023):</p>
<ol style="list-style-type: undefined;">
<li>K2 Compiler Advances: Continued development of the new compiler, improving compilation times and error diagnostics.</li>
<li>Language Features: Added new experimental features and made existing ones more stable</li>
</ol>
<p>Kotlin 2.x (May 2024):</p>
<p>Kotlin 2.0 brings significant improvements and new features that make it a more powerful, expressive, and developer-friendly language. Here are some of the significant features introduced in Kotlin 2.0:</p>
<ol style="list-style-type: undefined;">
<li>K2 Compiler (New Generation Compiler)
<ol>
<li>Improved Performance: The new K2 compiler is designed to be faster and more efficient, significantly reducing compilation times.</li>
<li>Improved Error Reporting: K2 enhances error diagnostics, providing more detailed and user-friendly error messages.</li>
<li>Unified Backend: It unifies the backend of Kotlin/Native, Kotlin/JVM, and Kotlin/JS, allowing developers to work with different platforms more seamlessly.</li>
<li>Modular and Extensible: The new architecture allows for easier integration of third-party tools, opening doors for more customization and extension.</li>
</ol>
</li>
<li>Context Receivers
<ol>
<li>This feature allows adding additional context to functions without explicitly passing it through parameters. It simplifies code that requires multiple receivers, such as in DSLs (Domain Specific Languages) and other multi-receiver scenarios.<br />
<pre class="crayon-plain-tag">fun  withDatabaseContext(block: Database.() -&gt; T): T { 
    return Database().block()
}

val result = withDatabaseContext {
    // Inside this lambda, `this` refers to a `Database` instance.
    query("SELECT * FROM users")
}</pre>
</li>
</ol>
</li>
<li>Builder Inference Improvements: Kotlin 2.0 improves type inference in builder-style APIs, making code more concise and readable. This is particularly useful for libraries like coroutines and UI libraries that leverage builder patterns.</li>
<li>Explicit API Mode for Kotlin Libraries: The explicit API mode helps developers build public libraries with stricter controls. It enforces that every member of a public API must explicitly declare its visibility and type, making the API more predictable and well-documented.</li>
<li>Improved Multiplatform Support
<ol>
<li>Multiplatform Compose: Kotlin 2.0 enhances Kotlin Multiplatform projects, especially for shared UI development. It supports libraries like Compose Multiplatform for building UIs that run on multiple platforms with the same codebase.</li>
<li>Better Gradle Integration: The new version comes with improved Gradle tooling and support for managing multiple targets more easily.</li>
</ol>
</li>
<li>New Sealed Interface Features: Kotlin 2.0 allows sealed interfaces, which, like sealed classes, restrict which classes can implement them. This improves safety in scenarios where specific hierarchies are needed.<br />
<pre class="crayon-plain-tag">sealed interface Operation
class Add(val value: Int) : Operation
class Subtract(val value: Int) : Operation</pre>
</li>
<li>
<p>Collection Literals and Destructuring in Loops: </p>
<ol>
<li>
<p>Kotlin 2.0 introduces collection literals to make collection initialization more concise.</p>
</li>
<li>
<p>Destructuring in loops is improved, allowing better handling of pairs and other data structures in iteration contexts.</p>
</li>
</ol>
</li>
<li>Value Classes (Refined): Kotlin 2.0 continues to improve value classes (formerly inline classes), ensuring that they are more efficient and flexible. Value classes can be used in cases where a small, immutable data holder is needed without the overhead of full object allocation.</li>
<li>Unit Testing Enhancements: Kotlin 2.0 improves unit testing capabilities for Kotlin Multiplatform projects, providing better tools and frameworks for cross-platform test sharing and execution.</li>
<li>Enhanced Coroutines:
<ol>
<li>Kotlin 2.0 further enhances Kotlin’s popular coroutine library with better integration, new debugging tools, and optimizations for more efficient asynchronous programming.</li>
<li>Structured Concurrency Improvements: Kotlin 2.0 adds additional safeguards and features to make structured concurrency even more robust.</li>
</ol>
</li>
<li>Function Interfaces: Kotlin 2.0 introduces Function Interfaces (aka SAM conversions), which allow developers to convert interfaces with a single abstract method into lambda expressions. This is especially useful when interoperating with Java libraries that heavily use functional interfaces.</li>
<li>Improved Null Safety Features: Kotlin 2.0 strengthens null-safety features, particularly in interoperability with Java. Improved type-checking mechanisms ensure that fewer null-pointer exceptions occur when interacting with non-null Kotlin code and nullable Java code.</li>
<li>Incremental Compilation Improvements: Kotlin 2.0 improves the incremental compilation process, reducing build times even for large projects and making the development experience smoother.</li>
<li>Backward Compatibility with Kotlin 1.x: Kotlin 2.0 is designed to be backward compatible with Kotlin 1.x, allowing gradual migration of projects without major breaking changes.</li>
</ol>
<div class="blog_h1"><span class="graybg">Kotlin Syntax Basics</span></div>
<div class="blog_h2"><span class="graybg">Main Function Declaration</span></div>
<p>In both Java and Kotlin, the main function serves as the entry point of the application. However, the syntax and structure differ between the two languages.</p>
<div class="blog_h3"><span class="graybg">Java Main Method</span></div>
<p>In Java, the main method must be declared within a class and must be public, static, and void, accepting a String[] argument:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, Java!");
    }
}</pre>
<div class="blog_h3"><span class="graybg">Kotlin Main Function</span></div>
<p>In Kotlin, the main function does not need to be part of a class and can be declared at the top level: </p>
<pre class="crayon-plain-tag">fun main(args: Array) {
    println("Hello, Kotlin!")
}</pre>
<div class="blog_h2"><span class="graybg">Variables and Data Types </span></div>
<p>Kotlin introduces a more concise and expressive way to declare variables, emphasizing immutability and type inference.</p>
<div class="blog_h3"><span class="graybg">Mutable (var) vs Immutable (val) Variables</span></div>
<ol style="list-style-type: undefined;">
<li>val (Immutable): Declares a read-only variable whose value cannot be changed once assigned.</li>
<li>var (Mutable): Declares a variable whose value can be changed.</li>
</ol>
<p>For example:</p>
<pre class="crayon-plain-tag">val name = "Alice"   // Immutable variable
var age = 30         // Mutable variable</pre>
<p>Attempting to reassign name will result in a compile-time error. age can be reassigned to a different value.</p>
<div class="blog_h3"><span class="graybg">constants</span></div>
<p>In Kotlin, you can define constants using val and const val. Both are used for read-only values, but they differ in when and how their values are initialized and how they can be used.</p>
<p>val:</p>
<ol>
<li>Declares a read-only property or variable.</li>
<li>The value is assigned at runtime.</li>
<li>Can be used anywhere in the code.</li>
<li>Can hold any type, including objects.</li>
</ol>
<p>const val:</p>
<ol>
<li>Declares a compile-time constant.</li>
<li>The value is assigned at compile time.</li>
<li>Must be a top-level or member of an object or companion object.</li>
<li>Can only hold primitive types and String.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">const val MAX_COUNT = 100
const val APP_NAME = "MyApplication"

object Constants {
    const val PI = 3.1415926535
    const val E = 2.7182818284
}


class MathUtils {
    companion object {
        const val GOLDEN_RATIO = 1.6180339887
    }
}

// Accessing the constant
val ratio = MathUtils.GOLDEN_RATIO</pre>
<div class="blog_h3"><span class="graybg">Type Inference</span></div>
<p>Kotlin can infer the type of a variable from the assigned value, so specifying the type explicitly is optional. Examples:</p>
<pre class="crayon-plain-tag">val number = 42               // Type inferred as Int
val pi = 3.1415               // Type inferred as Double
var message: String = "Hello" // Type explicitly specified</pre>
<div class="blog_h3"><span class="graybg">Primitive Types and Their Equivalents</span></div>
<p>Kotlin does not have primitive types in the same way Java does. All types are objects. However, the compiler optimizes to use primitive types where possible for performance.</p>
<ol style="list-style-type: undefined;">
<li>Numeric Types: Byte, Short, Int, Long, Float, Double</li>
<li>Other Types: Char, Boolean, String</li>
</ol>
<p>Example: </p>
<pre class="crayon-plain-tag">val count: Int = 10
// Or with type inference
val count = 10</pre>
<div class="blog_h2"><span class="graybg">Null Safety</span></div>
<p>One of the most significant features of Kotlin is its approach to null safety, which helps prevent the dreaded NullPointerException.</p>
<div class="blog_h3"><span class="graybg">Nullable and Non-Nullable Types</span></div>
<p><span style="background-color: #c0c0c0;">By default, variables in Kotlin cannot hold a null value.</span> To allow a variable to hold null, you need to declare it as nullable by adding a <pre class="crayon-plain-tag">?</pre> after the type.</p>
<pre class="crayon-plain-tag">var nonNullable: String = "Hello"
// nonNullable = null // Compile-time error

var nullable: String? = "Hello"
nullable = null       // Allowed</pre>
<div class="blog_h3"><span class="graybg">Safe Calls</span></div>
<p>To safely access properties or methods of a nullable variable, use the safe call operator <pre class="crayon-plain-tag">?.</pre>. </p>
<pre class="crayon-plain-tag">// If nullable is not null, length will hold the length of the string.
// If nullable is null, length will be null.
val length = nullable?.length</pre>
<div class="blog_h3"><span class="graybg">Elvis Operator<br /></span></div>
<p>The Elvis operator <pre class="crayon-plain-tag">?:</pre> in Kotlin provides a concise way to handle nullable expressions by specifying a default value when an expression evaluates to null. It's particularly useful when you want to assign a value that might be null but have an alternative ready if it is.</p>
<pre class="crayon-plain-tag">// If expression is not null, result will be the value of expression.
// If expression is null, result will be defaultValue.
val result = expression ?: defaultValue</pre>
<div class="blog_h3"><span class="graybg">Non-Null Assertion Operator</span></div>
<p>The non-null assertion operator !! in Kotlin is used to explicitly assert that a nullable variable or expression is not null. If it is null, the operator will throw a <pre class="crayon-plain-tag">KotlinNullPointerException</pre> at runtime.</p>
<pre class="crayon-plain-tag">// If nullableValue is not null, nonNullableValue will hold its value.
// If nullableValue is null, a KotlinNullPointerException is thrown.
val nonNullableValue = nullableValue!!</pre>
<div class="blog_h3"><span class="graybg">Safe Casting</span></div>
<p>Use <pre class="crayon-plain-tag">as?</pre> for safe casting that returns null if the cast is unsuccessful.</p>
<pre class="crayon-plain-tag">val obj: Any = "Kotlin"
val str: String? = obj as? String</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java </span></div>
<p>In Java, all object references can be null, and there's no language-level enforcement to prevent NullPointerException. </p>
<pre class="crayon-plain-tag">String message = null;
int length = message.length(); // Throws NullPointerException</pre>
<p>To address issues related to null values, Java 8 introduced the <pre class="crayon-plain-tag">Optional</pre> class in the java.util package. Optional is a container object that may or may not contain a non-null value. It provides methods to handle the presence or absence of a value without directly using null.</p>
<p>Example of Using Optional:</p>
<pre class="crayon-plain-tag">import java.util.Optional;

Optional optionalMessage = Optional.ofNullable(getMessage());

if (optionalMessage.isPresent()) {
    int length = optionalMessage.get().length();
} else {
    int length = 0;
}</pre>
<p> Or using a functional style:</p>
<pre class="crayon-plain-tag">int length = optionalMessage.map(String::length).orElse(0);</pre>
<p>Limitations of Optional in Java:</p>
<ol style="list-style-type: undefined;">
<li>Not a Language-Level Feature: Optional is a library class, not a language construct. It doesn't enforce null safety at the type system level.</li>
<li>Limited Usage: It's primarily intended for return types and not recommended for fields or method parameters, limiting its scope.</li>
<li>Verbosity: Using Optional can make the code more verbose compared to Kotlin's null handling.</li>
<li>Performance Overhead: Wrapping values in Optional can introduce performance overhead due to additional object creation.</li>
</ol>
<div class="blog_h2"><span class="graybg">String Templates</span></div>
<p>Kotlin allows embedding variables and expressions within strings using string templates.</p>
<ol>
<li>Variable Interpolation: Use <pre class="crayon-plain-tag">$variableName</pre></li>
<li>Expression Interpolation: Use <pre class="crayon-plain-tag">${expression}</pre></li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">val name = "Alice"
val greeting = "Hello, $name!"
println(greeting) // Output: Hello, Alice!

val age = 30
println("In 5 years, ${name} will be ${age + 5} years old.")
// Output: In 5 years, Alice will be 35 years old.</pre>
<div class="blog_h2"><span class="graybg">Kotlin Operators Overview</span></div>
<p>In Kotlin, operators can be classified into various categories, similar to Java. However, there are some key differences. Below is a comprehensive table of Kotlin operators and their usage. Where applicable, significant differences between Kotlin and Java are highlighted.</p>
<div class="blog_h3"><span class="graybg">Assignment Operators</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Operator</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
<td style="text-align: center;"><strong>Kotlin Example</strong></td>
<td style="text-align: center;"><strong>Difference with Java</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>=</td>
<td>Simple assignment</td>
<td><pre class="crayon-plain-tag">a = b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>+=</td>
<td>Add and assign</td>
<td><pre class="crayon-plain-tag">a += b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>-=</td>
<td>Subtract and assign</td>
<td><pre class="crayon-plain-tag">a -= b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>*=</td>
<td>Multiply and assign</td>
<td><pre class="crayon-plain-tag">a *= b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>/=</td>
<td>Divide and assign</td>
<td><pre class="crayon-plain-tag">a /= b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>%=</td>
<td>Modulus and assign</td>
<td><pre class="crayon-plain-tag">a %= b</pre></td>
<td>No difference</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">Arithmetic Operators</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Operator</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
<td style="text-align: center;"><strong>Kotlin Example</strong></td>
<td style="text-align: center;"><strong>Difference with Java</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>+</td>
<td>Addition</td>
<td><pre class="crayon-plain-tag">a + b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>-</td>
<td>Subtraction</td>
<td><pre class="crayon-plain-tag">a - b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>*</td>
<td>Multiplication</td>
<td><pre class="crayon-plain-tag">a * b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>/</td>
<td>Division</td>
<td><pre class="crayon-plain-tag">a / b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>%</td>
<td>Modulus</td>
<td><pre class="crayon-plain-tag">a % b</pre></td>
<td>No difference</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">Comparison Operators</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Operator</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
<td style="text-align: center;"><strong>Kotlin Example</strong></td>
<td style="text-align: center;"><strong>Difference with Java</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>==</td>
<td>Equal to</td>
<td><pre class="crayon-plain-tag">a == b</pre></td>
<td>Kotlin compares values, Java compares references</td>
</tr>
<tr>
<td>!=</td>
<td>Not equal to</td>
<td><pre class="crayon-plain-tag">a != b</pre></td>
<td>Kotlin compares values, Java compares references</td>
</tr>
<tr>
<td>&gt;</td>
<td>Greater than</td>
<td><pre class="crayon-plain-tag">a &gt; b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>&lt;</td>
<td>Less than</td>
<td><pre class="crayon-plain-tag">a &lt; b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>&gt;=</td>
<td>Greater than or equal to</td>
<td><pre class="crayon-plain-tag">a &gt;= b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>&lt;=</td>
<td>Less than or equal to</td>
<td><pre class="crayon-plain-tag">a &lt;= b</pre></td>
<td>No difference</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">Logical Operators</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Operator</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
<td style="text-align: center;"><strong>Kotlin Example</strong></td>
<td style="text-align: center;"><strong>Difference with Java</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>&amp;&amp;</td>
<td>Logical AND</td>
<td><pre class="crayon-plain-tag">a &amp;&amp; b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>||</td>
<td>Logical OR</td>
<td><pre class="crayon-plain-tag">a || b</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>!</td>
<td>Logical NOT</td>
<td><pre class="crayon-plain-tag">!a</pre></td>
<td>No difference</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">Bitwise Operators</span></div>
<p>Unlike Java, Kotlin does not have specific bitwise operators (<code>&amp;</code>, <code>|</code>, etc.). Instead, it uses functions like <code>and()</code>, <code>or()</code>, <code>xor()</code>, <code>inv()</code> for bitwise operations.</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Operator</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
<td style="text-align: center;"><strong>Kotlin Example</strong></td>
<td style="text-align: center;"><strong>Difference with Java</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>and()</td>
<td>Bitwise AND</td>
<td><pre class="crayon-plain-tag">a.and(b)</pre></td>
<td>Kotlin uses a function instead of the <code>&amp;</code> operator</td>
</tr>
<tr>
<td>or()</td>
<td>Bitwise OR</td>
<td><pre class="crayon-plain-tag">a.or(b)</pre></td>
<td>Kotlin uses a function instead of the <code>|</code> operator</td>
</tr>
<tr>
<td>xor()</td>
<td>Bitwise XOR</td>
<td><pre class="crayon-plain-tag">a.xor(b)</pre></td>
<td>Kotlin uses a function instead of the <code>^</code> operator</td>
</tr>
<tr>
<td>inv()</td>
<td>Bitwise inversion</td>
<td><pre class="crayon-plain-tag">a.inv()</pre></td>
<td>Kotlin uses a function instead of the <code>~</code> operator</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">Other Operators</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Operator</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
<td style="text-align: center;"><strong>Kotlin Example</strong></td>
<td style="text-align: center;"><strong>Difference with Java</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>in</td>
<td>Checks if a value is in a collection</td>
<td><pre class="crayon-plain-tag">x in array</pre></td>
<td>No Java equivalent</td>
</tr>
<tr>
<td>!in</td>
<td>Checks if a value is not in a collection</td>
<td><pre class="crayon-plain-tag">x !in array</pre></td>
<td>No Java equivalent</td>
</tr>
<tr>
<td>is</td>
<td>Checks if an object is of a certain type</td>
<td><pre class="crayon-plain-tag">x is String</pre></td>
<td>Kotlin uses <code>is</code> instead of <code>instanceof</code> in Java</td>
</tr>
<tr>
<td>as</td>
<td>Type casting</td>
<td><pre class="crayon-plain-tag">x as String</pre></td>
<td>Kotlin uses <code>as</code> for type casting</td>
</tr>
<tr>
<td>* (spread)</td>
<td>Spread operator (used to pass multiple arguments)</td>
<td><pre class="crayon-plain-tag">foo(*args)</pre></td>
<td>Kotlin uses the spread operator to pass arrays or varargs, while Java requires manual array expansion.</td>
</tr>
<tr>
<td>?.</td>
<td>Safe call operator (used to handle nullability)</td>
<td><pre class="crayon-plain-tag">a?.length</pre></td>
<td>No Java equivalent (in Java, null checks are required)</td>
</tr>
<tr>
<td>?:</td>
<td>Elvis operator (provides default value if null)</td>
<td><pre class="crayon-plain-tag">a ?: "default"</pre></td>
<td>No Java equivalent (in Java, null checks and ternary are needed)</td>
</tr>
<tr>
<td>!!</td>
<td>Not-null assertion (throws exception if value is null)</td>
<td><pre class="crayon-plain-tag">a!!</pre></td>
<td>No direct equivalent in Java (Java requires manual null checking and exception handling)</td>
</tr>
<tr>
<td>++</td>
<td>Increment (pre/post)</td>
<td><pre class="crayon-plain-tag">a++</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>--</td>
<td>Decrement (pre/post)</td>
<td><pre class="crayon-plain-tag">a--</pre></td>
<td>No difference</td>
</tr>
<tr>
<td>..</td>
<td>Range operator</td>
<td><pre class="crayon-plain-tag">1..5</pre></td>
<td>No direct Java equivalent</td>
</tr>
<tr>
<td>::</td>
<td>Callable reference (method or constructor)</td>
<td><pre class="crayon-plain-tag">::foo</pre></td>
<td>Similar to Java method references</td>
</tr>
<tr>
<td>[]</td>
<td>Index access</td>
<td><pre class="crayon-plain-tag">array[0]</pre></td>
<td>Same as Java</td>
</tr>
<tr>
<td>()</td>
<td>Invoke operator</td>
<td><pre class="crayon-plain-tag">myFunction()</pre></td>
<td>No direct Java equivalent (Kotlin allows operator overloading)</td>
</tr>
</tbody>
</table>
<p>Understanding these operators, especially those unique to Kotlin, like the spread operator and null-safe operators, will make coding more efficient and error-free compared to Java.</p>
<div class="blog_h2"><span class="graybg">Kotlin Keywords Overview</span></div>
<p>Kotlin reserves certain keywords for defining syntax elements like functions, classes, and more. These keywords cannot be used as identifiers (such as variable or function names) unless escaped with backticks. Below is a table listing all Kotlin keywords and their purposes.</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;"><strong>Keyword</strong></td>
<td style="text-align: center;"><strong>Description</strong></td>
</tr>
</thead>
<tbody>
<tr>
<td>abstract</td>
<td>Used to declare an abstract class or function.</td>
</tr>
<tr>
<td>annotation</td>
<td>Defines an annotation class.</td>
</tr>
<tr>
<td>as</td>
<td>Used for type casting.</td>
</tr>
<tr>
<td>break</td>
<td>Terminates the nearest enclosing loop.</td>
</tr>
<tr>
<td>by</td>
<td>Used for delegation and property delegation.</td>
</tr>
<tr>
<td>catch</td>
<td>Handles exceptions in a try block.</td>
</tr>
<tr>
<td>class</td>
<td>Defines a class.</td>
</tr>
<tr>
<td>companion</td>
<td>Declares a companion object within a class.</td>
</tr>
<tr>
<td>const</td>
<td>Declares compile-time constants.</td>
</tr>
<tr>
<td>continue</td>
<td>Skips the current iteration of the nearest enclosing loop.</td>
</tr>
<tr>
<td>crossinline</td>
<td>Prevents non-local returns from lambda expressions.</td>
</tr>
<tr>
<td>data</td>
<td>Declares a data class.</td>
</tr>
<tr>
<td>do</td>
<td>Used with `while` to create a do-while loop.</td>
</tr>
<tr>
<td>else</td>
<td>Specifies the alternative branch in an if-expression.</td>
</tr>
<tr>
<td>enum</td>
<td>Declares an enum class.</td>
</tr>
<tr>
<td>external</td>
<td>Marks a declaration as implemented in native code.</td>
</tr>
<tr>
<td>false</td>
<td>A boolean literal value representing "false".</td>
</tr>
<tr>
<td>final</td>
<td>Prevents a class or function from being overridden.</td>
</tr>
<tr>
<td>for</td>
<td>Used to create a for loop.</td>
</tr>
<tr>
<td>fun</td>
<td>Defines a function.</td>
</tr>
<tr>
<td>if</td>
<td>Specifies a conditional expression.</td>
</tr>
<tr>
<td>in</td>
<td>Checks if a value belongs to a range or collection.</td>
</tr>
<tr>
<td>inline</td>
<td>Used to request that a function be inlined.</td>
</tr>
<tr>
<td>inner</td>
<td>Declares an inner class that holds a reference to its outer class.</td>
</tr>
<tr>
<td>interface</td>
<td>Defines an interface.</td>
</tr>
<tr>
<td>is</td>
<td>Checks if a value is of a specific type.</td>
</tr>
<tr>
<td>lateinit</td>
<td>Delays initialization of a variable.</td>
</tr>
<tr>
<td>noinline</td>
<td>Prevents inlining of lambda expressions in inline functions.</td>
</tr>
<tr>
<td>null</td>
<td>A special literal representing "null".</td>
</tr>
<tr>
<td>object</td>
<td>Declares an object, which is a singleton.</td>
</tr>
<tr>
<td>open</td>
<td>Allows a class or function to be overridden.</td>
</tr>
<tr>
<td>operator</td>
<td>Marks a function as an operator for operator overloading.</td>
</tr>
<tr>
<td>out</td>
<td>Defines covariance in generics.</td>
</tr>
<tr>
<td>override</td>
<td>Overrides a function or property from a superclass or interface.</td>
</tr>
<tr>
<td>package</td>
<td>Declares the package for the file.</td>
</tr>
<tr>
<td>private</td>
<td>Defines the visibility of a declaration to be within the containing class or file.</td>
</tr>
<tr>
<td>protected</td>
<td>Defines visibility to be within the class and its subclasses.</td>
</tr>
<tr>
<td>public</td>
<td>Defines visibility to be accessible from anywhere.</td>
</tr>
<tr>
<td>return</td>
<td>Exits from the nearest enclosing function.</td>
</tr>
<tr>
<td>sealed</td>
<td>Declares a sealed class, which restricts subclassing to within the same file.</td>
</tr>
<tr>
<td>super</td>
<td>Refers to the superclass's implementation.</td>
</tr>
<tr>
<td>this</td>
<td>Refers to the current instance of a class.</td>
</tr>
<tr>
<td>throw</td>
<td>Throws an exception.</td>
</tr>
<tr>
<td>true</td>
<td>A boolean literal value representing "true".</td>
</tr>
<tr>
<td>try</td>
<td>Starts a block of code that may throw an exception.</td>
</tr>
<tr>
<td>typealias</td>
<td>Defines a new name for an existing type.</td>
</tr>
<tr>
<td>val</td>
<td>Declares a read-only property or local variable.</td>
</tr>
<tr>
<td>var</td>
<td>Declares a mutable property or local variable.</td>
</tr>
<tr>
<td>vararg</td>
<td>Allows a function to accept a variable number of arguments.</td>
</tr>
<tr>
<td>when</td>
<td>Acts as a replacement for the switch statement.</td>
</tr>
<tr>
<td>where</td>
<td>Specifies constraints on type parameters.</td>
</tr>
<tr>
<td>while</td>
<td>Starts a while loop.</td>
</tr>
</tbody>
</table>
<p>These keywords are essential for understanding the Kotlin language syntax. By knowing their purpose, you can write cleaner and more efficient Kotlin code. Some keywords like <pre class="crayon-plain-tag">val</pre>, <pre class="crayon-plain-tag">var</pre>, and <pre class="crayon-plain-tag">fun</pre> have no direct equivalent in Java, showcasing Kotlin's unique features.</p>
<div class="blog_h1"><span class="graybg">Control Flow Constructs</span></div>
<p>Control flow constructs are essential in any programming language as they dictate the order in which statements are executed. Kotlin offers a rich set of control flow statements that are both expressive and concise. In this section, we'll explore how Kotlin handles conditional statements and loops, highlighting the differences and similarities with Java.</p>
<div class="blog_h2"><span class="graybg">Conditional Statements</span></div>
<p>Kotlin provides powerful conditional statements, including <pre class="crayon-plain-tag">if</pre> expressions and the versatile <pre class="crayon-plain-tag">when</pre> expression. These constructs allow for more expressive and concise code compared to Java's traditional if-else and switch statements.</p>
<div class="blog_h3"><span class="graybg">if and else Expressions</span></div>
<p>In Kotlin, <span style="background-color: #c0c0c0;">if and else are expressions, meaning they return a value</span>. This allows you to assign the result of an if expression directly to a variable, enhancing code conciseness.</p>
<pre class="crayon-plain-tag">val result = if (condition) {
    // Block of code
    valueIfTrue
} else {
    // Block of code
    valueIfFalse
}</pre>
<p>Example: </p>
<pre class="crayon-plain-tag">val max = if (a &gt; b) a else b</pre>
<p>Equivalent Java Code:</p>
<pre class="crayon-plain-tag">int max = (a &gt; b) ? a : b;</pre>
<p>Kotlin allows for multiple else if branches within an if expression.</p>
<pre class="crayon-plain-tag">val grade = if (score &gt;= 90) {
    "A"
} else if (score &gt;= 80) {
    "B"
} else if (score &gt;= 70) {
    "C"
} else if (score &gt;= 60) {
    "D"
} else {
    "F"
}</pre>
<p>Equivalent Java Code:</p>
<pre class="crayon-plain-tag">String grade;
if (score &gt;= 90) {
    grade = "A";
} else if (score &gt;= 80) {
    grade = "B";
} else if (score &gt;= 70) {
    grade = "C";
} else if (score &gt;= 60) {
    grade = "D";
} else {
    grade = "F";
}</pre>
<div class="blog_h3"><span class="graybg">The When expression </span></div>
<p>Kotlin's when expression is a powerful and flexible construct that can replace Java's switch statement. Starting from Java 14, Java introduced switch expressions, which bring some of the capabilities of Kotlin's when to Java.</p>
<p>Syntax of Kotlin's when Expression:</p>
<pre class="crayon-plain-tag">when (expression) {
    value1 -&gt; result1
    value2, value3 -&gt; result2
    in range -&gt; result3
    !in range -&gt; result4
    is Type -&gt; result5
    else -&gt; defaultResult
}</pre>
<p>Example:</p>
<pre class="crayon-plain-tag">fun determineResponse(input: Any): String {
    return when (input) {
        // Matching a specific value
        1 -&gt; "You entered one."
        
        // Matching multiple values
        2, 3 -&gt; "You entered two or three."

        // Matching a value within a range
        in 4..10 -&gt; "Your number is in the range of 4 to 10."

        // Matching a value outside of a range
        !in 11..20 -&gt; "Your number is not in the range of 11 to 20."

        // Matching by type
        is String -&gt; "You entered a string."

        // Default case
        else -&gt; "I don't know what you entered."
    }
}</pre>
<p>Java 14 introduced switch expressions, which can <span style="background-color: #c0c0c0;">return a value and use the arrow syntax -&gt; </span></p>
<pre class="crayon-plain-tag">int day = 3;

String dayName = switch (day) {
    case 1 -&gt; "Monday";
    case 2 -&gt; "Tuesday";
    case 3 -&gt; "Wednesday";
    case 4 -&gt; "Thursday";
    case 5 -&gt; "Friday";
    case 6, 7 -&gt; "Weekend";
    default -&gt; "Invalid day";
};

System.out.println(dayName); // Output: Wednesday</pre>
<p>Kotlin's when can perform type checks using <pre class="crayon-plain-tag">is</pre>. Java 16 introduced Pattern Matching for instanceof, and Java 17 enhanced pattern matching in switch statements (preview feature). </p>
<pre class="crayon-plain-tag">// Kotlin Example:
fun describe(obj: Any): String =
    when (obj) {
        is Int -&gt; "Integer"
        is String -&gt; "String of length ${obj.length}"
        is Boolean -&gt; "Boolean"
        else -&gt; "Unknown"
    }

println(describe("Hello")) // Output: String of length 5</pre><br />
<pre class="crayon-plain-tag">// Java Example with Pattern Matching (instanceof) (Java 16 and Later):
public String describe(Object obj) {
    if (obj instanceof Integer) {
        return "Integer";
    } else if (obj instanceof String s) {
        return "String of length " + s.length();
    } else if (obj instanceof Boolean) {
        return "Boolean";
    } else {
        return "Unknown";
    }
}

// Java Example with Pattern Matching in switch (Java 17 Preview):
public String describe(Object obj) {
    return switch (obj) {
        case Integer i -&gt; "Integer";
        case String s -&gt; "String of length " + s.length();
        case Boolean b -&gt; "Boolean";
        default -&gt; "Unknown";
    };
}</pre>
<div class="blog_h2"><span class="graybg">Loops</span></div>
<p>Kotlin offers loops that are similar to Java's but with enhanced features and more concise syntax.</p>
<div class="blog_h3"><span class="graybg">for Loop</span></div>
<p>Kotlin's for loop is designed to iterate over any iterable, including ranges, arrays, and collections.</p>
<p>Iterating Over Ranges with Kotlin:</p>
<pre class="crayon-plain-tag">// Iterating Over Ranges
for (i in 1..5) {
    print("$i ") // Output: 1 2 3 4 5
}</pre>
<p>Java Equivalent Using for Loop: </p>
<pre class="crayon-plain-tag">for (int i = 1; i &lt;= 5; i++) {
    System.out.print(i + " "); // Output: 1 2 3 4 5
}</pre>
<p>Java Equivalent Using Streams (Java 8 and Later) :</p>
<pre class="crayon-plain-tag">IntStream.rangeClosed(1, 5).forEach(i -&gt; System.out.print(i + " "));
// Output: 1 2 3 4 5</pre>
<p>Iterating Over Collections with Kotlin:</p>
<pre class="crayon-plain-tag">val fruits = listOf("Apple", "Banana", "Cherry")

for (fruit in fruits) {
    println(fruit)
}</pre>
<p>Java enhanced for loop:</p>
<pre class="crayon-plain-tag">List fruits = List.of("Apple", "Banana", "Cherry");

for (String fruit : fruits) {
    System.out.println(fruit);
}</pre>
<div class="blog_h3"><span class="graybg">while and do-while Loops</span></div>
<p>These loops function similarly in both Kotlin and Java. </p>
<pre class="crayon-plain-tag">var count = 5
while (count &gt; 0) {
    println(count)
    count--
}</pre>
<div class="blog_h3"><span class="graybg">Ranges and Progressions</span></div>
<p>Kotlin provides range expressions that simplify loop constructs.</p>
<pre class="crayon-plain-tag">// Exclusive Range:
for (i in 1 until 5) {
    print("$i ") // Output: 1 2 3 4
}

// Downward Range:
for (i in 5 downTo 1) {
    print("$i ") // Output: 5 4 3 2 1
}


// Stepped Range:
for (i in 1..10 step 2) {
    print("$i ") // Output: 1 3 5 7 9
}</pre>
<p>Java can use IntStream with methods like range, rangeClosed, and custom steps. </p>
<pre class="crayon-plain-tag">IntStream.iterate(1, i -&gt; i + 2)
    .limit(5)
    .forEach(i -&gt; System.out.print(i + " "));
// Output: 1 3 5 7 9</pre>
<div class="blog_h3"><span class="graybg">break and continue with Labels </span></div>
<p>Kotlin allows labeled break and continue statements for controlling nested loops.</p>
<pre class="crayon-plain-tag">outer@ for (i in 1..5) {
    for (j in 1..5) {
        if (i * j &gt; 10) break@outer
        println("i = $i, j = $j")
    }
}</pre>
<p>Java Equivalent Using for labeled loops:</p>
<pre class="crayon-plain-tag">outer: // Label for the outer loop
for (int i = 1; i &lt;= 5; i++) {
    for (int j = 1; j &lt;= 5; j++) {
        if (i * j &gt; 10) {
            break outer; // Break the outer loop using the label
        }
        System.out.println("i = " + i + ", j = " + j);
    }
}</pre>
<div class="blog_h2"><span class="graybg">Exception Handling</span></div>
<p>Exception handling in Kotlin is similar to Java but with some key differences.</p>
<div class="blog_h3"><span class="graybg">Try-Catch Blocks</span></div>
<pre class="crayon-plain-tag">try {
    // Code that may throw an exception
} catch (e: ExceptionType) {
    // Handle exception
} finally {
    // Optional finally block
}</pre>
<p>Example:</p>
<pre class="crayon-plain-tag">try {
    val result = numerator / denominator
} catch (e: ArithmeticException) {
    println("Cannot divide by zero")
} finally {
    println("Execution completed")
}</pre>
<div class="blog_h3"><span class="graybg">Checked vs. Unchecked Exceptions </span></div>
<p>Kotlin:</p>
<ol style="list-style-type: undefined;">
<li>All exceptions are unchecked.</li>
<li>No need to declare exceptions with throws.</li>
<li>Reduces boilerplate code.</li>
</ol>
<p>Java:</p>
<ol style="list-style-type: undefined;">
<li>Differentiates between checked and unchecked exceptions.</li>
<li>Checked exceptions must be declared or handled.</li>
<li>Can lead to verbose code with try-catch blocks.</li>
</ol>
<div class="blog_h3"><span class="graybg">Try-with-Resources </span></div>
<p>Java 7 introduced the try-with-resources statement to manage resources automatically.</p>
<pre class="crayon-plain-tag">try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}</pre>
<p>Kotlin provides the <pre class="crayon-plain-tag">use</pre> extension function for resource management.</p>
<pre class="crayon-plain-tag">BufferedReader(FileReader("file.txt")).use { reader -&gt;
    var line = reader.readLine()
    while (line != null) {
        println(line)
        line = reader.readLine()
    }
}</pre>
<p>The use function ensures the resource is closed after use. </p>
<div class="blog_h3"><span class="graybg">Exception Handling with Coroutines</span></div>
<p>Kotlin's coroutines provide advanced exception handling mechanisms. <span style="background-color: #c0c0c0;">Exceptions in coroutines can be handled within the coroutine or propagated to the caller</span>.</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            // Coroutine code that may throw an exception
        } catch (e: Exception) {
            // Handle exception
        }
    }
    job.join()
}</pre>
<p>Java's Approach to Asynchronous Exception Handling:  Java uses CompletableFuture and ExecutorService for asynchronous programming, with exception handling mechanisms.</p>
<pre class="crayon-plain-tag">CompletableFuture future = CompletableFuture.runAsync(() -&gt; {
    try {
        // Asynchronous code
    } catch (Exception e) {
        // Handle exception
    }
});

future.join();</pre>
<div class="blog_h1"><span class="graybg">Functions in Kotlin</span></div>
<p>Functions are fundamental building blocks in Kotlin, and they come with a variety of features that enhance code readability, conciseness, and expressiveness. In this section, we'll explore how functions in Kotlin differ from those in Java.</p>
<div class="blog_h2"><span class="graybg">Function Declaration and Calling</span></div>
<div class="blog_h3"><span class="graybg">Basic Function Syntax</span></div>
<p>In Kotlin, functions are declared using the <pre class="crayon-plain-tag">fun</pre> keyword, followed by the function name, parameter list, and return type:</p>
<pre class="crayon-plain-tag">fun functionName(parameter1: Type1, parameter2: Type2): ReturnType {
    // function body
    return result
}

// Example
fun add(a: Int, b: Int): Int {
    return a + b
}</pre>
<div class="blog_h3"><span class="graybg">Single-Expression Functions</span></div>
<p>For functions that return a single expression, Kotlin allows you to simplify the syntax using the equals sign =.</p>
<pre class="crayon-plain-tag">fun multiply(a: Int, b: Int): Int = a * b
// If the return type can be inferred, you can omit it:
fun multiply(a: Int, b: Int) = a * b</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>In Java, methods must be declared within a class, and the syntax is more verbose.</p>
<pre class="crayon-plain-tag">public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}</pre>
<p>Starting from Java 8, you can define static methods in interfaces and use lambda expressions, but you still need to define methods within a class or interface.</p>
<p>Java Example with Lambda (Java 8 and Later):</p>
<pre class="crayon-plain-tag">BiFunction&lt;Integer, Integer, Integer&gt; add = (a, b) -&gt; a + b;
int sum = add.apply(5, 3);</pre>
<div class="blog_h2"><span class="graybg">Default and Named Arguments</span></div>
<div class="blog_h3"><span class="graybg">Default Arguments</span></div>
<p>Kotlin allows you to specify default values for function parameters. If an argument is not provided, the default value is used.</p>
<pre class="crayon-plain-tag">fun greet(name: String = "Guest") {
    println("Hello, $name!")
}

greet()            // Output: Hello, Guest!
greet("Alice")     // Output: Hello, Alice!</pre>
<div class="blog_h3"><span class="graybg">Named Arguments </span></div>
<p>When calling a function, you can specify the names of the parameters, allowing you to pass arguments in any order and enhance code readability.</p>
<pre class="crayon-plain-tag">fun displayInfo(name: String, age: Int, country: String) {
    println("$name is $age years old from $country.")
}

displayInfo(age = 30, country = "USA", name = "Bob")</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>In Java, prior to version 15, there is no direct support for default or named arguments in methods. You typically overload methods to achieve similar functionality.</p>
<p>Java Example with Method Overloading:</p>
<pre class="crayon-plain-tag">public void greet() {
    greet("Guest");
}

public void greet(String name) {
    System.out.println("Hello, " + name + "!");
}</pre>
<p>Named arguments are not supported in Java. However, starting from Java 15, the introduction of Records allows for more concise data carriers, but they don't provide named arguments for methods.</p>
<pre class="crayon-plain-tag">// Example Using Records in Java 15
public record Person(String name, int age) {}

public class Main {
    public static void main(String[] args) {
        // Creating an instance of Person
        Person person = new Person("Alice", 30);
        displayInfo(person);
    }

    public static void displayInfo(Person person) {
        System.out.println(person.name() + " is " + person.age() + " years old.");
    }
}</pre>
<div class="blog_h2"><span class="graybg">Extension Functions</span></div>
<p>Extension functions in Kotlin allow you to add new functions to existing classes without inheriting from them or using design patterns like Decorator.</p>
<pre class="crayon-plain-tag">fun String.isPalindrome(): Boolean {
    return this == this.reversed()
}

val word = "level"
println(word.isPalindrome()) // Output: true</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>Java does not support extension functions directly. To achieve similar functionality, you would create utility classes with static methods.</p>
<p>Java Example:</p>
<pre class="crayon-plain-tag">public class StringUtils {
    public static boolean isPalindrome(String s) {
        return s.equals(new StringBuilder(s).reverse().toString());
    }
}

String word = "level";
System.out.println(StringUtils.isPalindrome(word)); // Output: true</pre>
<p>With Java 8 and later, you can use default methods in interfaces to provide implementations, but this requires modifying the interface and doesn't allow adding methods to existing classes like String. </p>
<div class="blog_h2"><span class="graybg">Higher-Order Functions and Lambdas</span></div>
<p>Kotlin treats functions as first-class citizens, meaning you can store functions in variables, pass them as parameters, and return them from other functions.</p>
<div class="blog_h3"><span class="graybg">Higher-Order Functions</span></div>
<p>A higher-order function is a function that takes functions as parameters or returns a function.</p>
<pre class="crayon-plain-tag">fun operate(a: Int, b: Int, operation: (Int, Int) -&gt; Int): Int {
    return operation(a, b)
}

// ::add syntax stands for a function reference
val sum = operate(4, 5, ::add)
println(sum) // Output: 9

// Using lambda expression
// Trailing Lambda Syntax: if the last parameter of the function is a lambda, Kotlin allows 
// you to move the lambda outside of the parentheses for improved readability:
val product = operate(4, 5) { x, y -&gt; x * y }
println(product) // Output: 20</pre>
<div class="blog_h3"><span class="graybg">Lambdas and Anonymous Functions</span></div>
<p>Lambda expressions provide a concise way to represent functions.</p>
<pre class="crayon-plain-tag">val lambdaName: (InputType) -&gt; ReturnType = { arguments -&gt; body }</pre>
<p>Example:</p>
<pre class="crayon-plain-tag">val square: (Int) -&gt; Int = { number -&gt; number * number }
println(square(6)) // Output: 36</pre>
<div class="blog_h3"><span class="graybg">Trailing Lambda</span></div>
<p>Kotlin provides a concise and expressive syntax for passing lambda expressions as function parameters. One of the features that enhance code readability is the trailing lambda syntax. This allows you to <span style="background-color: #c0c0c0;">pass a lambda expression after the function call parentheses</span>, which can make your code more readable, especially when working with higher-order functions.</p>
<pre class="crayon-plain-tag">// Standard syntax
functionName(parameters..., { lambda_parameters -&gt; lambda_body })

// Trailing lambda syntax
functionName(parameters...) { lambda_parameters -&gt; lambda_body }

// If the lambda is the only parameter
functionName { lambda_parameters -&gt; lambda_body }</pre>
<p>Examples:</p>
<pre class="crayon-plain-tag">fun performOperation(x: Int, operation: (Int) -&gt; Int): Int {
    return operation(x)
}

// Standard Syntax
val result = performOperation(5, { num -&gt; num * num })
println(result) // Output: 25


// Trailing Lambda Syntax
val result = performOperation(5) { num -&gt;
    num * num
}
println(result) // Output: 25</pre>
<p>If a function takes only a lambda parameter, you can omit the parentheses entirely.</p>
<pre class="crayon-plain-tag">fun repeatAction(times: Int, action: () -&gt; Unit) {
    for (i in 1..times) {
        action()
    }
}

repeatAction(3) {
    println("Hello, World!")
}
// Output:
// Hello, World!
// Hello, World!
// Hello, World!</pre>
<p>Kotlin's standard library provides many functions that take lambdas as parameters. Trailing lambdas can make these calls more readable.</p>
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3, 4, 5)

// Standard syntax
val evenNumbers = numbers.filter({ it % 2 == 0 })

// Trailing lambda syntax
val evenNumbers = numbers.filter { it % 2 == 0 }

println(evenNumbers) // Output: [2, 4]


val result = numbers
    .filter { it &gt; 2 }
    .map { it * it }
    .also { println("Squared numbers: $it") }</pre>
<div class="blog_h3"><span class="graybg">Using the it Keyword in Lambda </span></div>
<p>In lambdas with a single parameter, you can omit the parameter declaration and use the implicit <pre class="crayon-plain-tag">it</pre> variable.</p>
<pre class="crayon-plain-tag">val squares = numbers.map { it * it }
println(squares) // Output: [1, 4, 9, 16, 25]
 </pre>
<div class="blog_h3"><span class="graybg">Inline Functions</span></div>
<p>Kotlin allows you to declare functions as <pre class="crayon-plain-tag">inline</pre> to reduce overhead associated with higher-order functions.</p>
<pre class="crayon-plain-tag">inline fun performOperation(a: Int, b: Int, operation: (Int, Int) -&gt; Int): Int {
    return operation(a, b)
}</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java </span></div>
<p>Java 8 introduced lambda expressions and functional interfaces, enabling functional programming paradigms.</p>
<pre class="crayon-plain-tag">BiFunction&lt;Integer, Integer, Integer&gt; add = (a, b) -&gt; a + b;
int sum = add.apply(4, 5);
System.out.println(sum); // Output: 9</pre>
<p>Java doesn't support higher-order functions in the same way as Kotlin. You can pass functional interfaces as parameters, but the syntax is more verbose. </p>
<pre class="crayon-plain-tag">public int operate(int a, int b, BiFunction&lt;Integer, Integer, Integer&gt; operation) {
    return operation.apply(a, b);
}

int product = operate(4, 5, (x, y) -&gt; x * y);
System.out.println(product); // Output: 20</pre>
<div class="blog_h2"><span class="graybg">Tail Recursion</span></div>
<p>Kotlin supports tail recursive functions using the <pre class="crayon-plain-tag">tailrec</pre> modifier, which optimizes recursive calls to prevent stack overflow errors.</p>
<pre class="crayon-plain-tag">tailrec fun factorial(n: Int, accumulator: Int = 1): Int {
    return if (n &lt;= 1) accumulator else factorial(n - 1, n * accumulator)
}

println(factorial(5)) // Output: 120</pre>
<p>The tailrec modifier in Kotlin transforms a recursive function into an iterative one during compilation, optimizing it to prevent stack overflow issues that can occur with deep recursion. This is achieved by performing tail call optimization (TCO). Here's a breakdown of the exact effect of the tailrec keyword:</p>
<ol>
<li>Tail Call Optimization (TCO): In Kotlin, <span style="background-color: #c0c0c0;">a function call is considered a tail call if it's the last operation to be executed in a function</span>. If the recursive call is in the tail position (i.e., it is the last thing the function does before returning), Kotlin replaces the recursive call with a loop during compilation, avoiding the need for additional stack frames for each recursive call.</li>
<li>Stack Frame Elimination: Normally, every function call adds a new stack frame, which can lead to stack overflow errors for deep recursion. The tailrec modifier removes the need for these additional frames by reusing the current function’s stack frame.</li>
<li>Iterative Approach: When you mark a function with tailrec, the compiler rewrites the recursive function into a loop under the hood. This makes the recursion as efficient as a traditional loop, avoiding the overhead of managing recursion in the stack.</li>
</ol>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>Java does not have built-in support for tail call optimization. Recursive functions can lead to stack overflow errors if not carefully managed.</p>
<div class="blog_h2"><span class="graybg">Local Functions and Closures</span></div>
<p>Kotlin allows you to define functions inside other functions, and these inner functions can access variables from the outer function.</p>
<pre class="crayon-plain-tag">fun greeting(): () -&gt; Unit {
    val message = "Hello"
    fun sayHello() {
        println(message)
    }
    return ::sayHello
}

val greet = greeting()
greet() // Output: Hello</pre>
<p>A function inside another function is called a Closure because it "close over" its surrounding environment, meaning it captures and remembers the state of variables from the outer function, even after that outer function has finished executing.</p>
<div class="blog_h3"><span class="graybg"> Comparison with Java</span></div>
<p>Java supports lambda expressions and anonymous inner classes but does not support defining named local functions inside methods.</p>
<div class="blog_h2"><span class="graybg">Function Literals with Receiver</span></div>
<p>Kotlin allows you to define lambda expressions that have a receiver object, enabling a DSL-like syntax.</p>
<pre class="crayon-plain-tag">// The lambda (builderAction) is an extension function on StringBuilder that doesn't return any value (Unit).
// Unit is a special type that is used to indicate the absence of a meaningful return value from a function. 
// It is similar to void in languages like Java or C, but it is treated as a real type in Kotlin, and it has
// only one value, which is Unit
fun buildString(builderAction: StringBuilder.() -&gt; Unit): String {
    val sb = StringBuilder()
    sb.builderAction()
    return sb.toString()
}

val result = buildString {
    append("Hello, ")
    append("World!")
}

println(result) // Output: Hello, World!</pre>
<p>The <pre class="crayon-plain-tag">{ ... }</pre> after buildString is Kotlin's trailing lambda syntax for better readability. </p>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>Java does not support function literals with receivers. Achieving similar functionality would require more verbose code and design patterns.</p>
<div class="blog_h2"><span class="graybg">Operator Overloading</span></div>
<p>Kotlin allows you to provide implementations for predefined operators for your own types by overloading them. </p>
<pre class="crayon-plain-tag">// In Kotlin, a data class is a special type of class that is primarily used to hold data. 
// It automatically generates useful methods like equals(), hashCode(), toString(), and copy() for you, 
// based on the properties you define. This makes it especially handy when creating simple classes whose 
// main purpose is to store state (i.e., the values of its properties).
data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point) = Point(x + other.x, y + other.y)
}

val p1 = Point(2, 3)
val p2 = Point(4, 5)
val sum = p1 + p2
println(sum) // Output: Point(x=6, y=8)</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>Java does not support operator overloading, except for the + operator for string concatenation. You would need to define methods like add to achieve similar functionality. </p>
<div class="blog_h2"><span class="graybg">Coroutines</span></div>
<p>Although coroutines are covered in detail in a later section, it's worth mentioning that Kotlin functions can be declared with the <pre class="crayon-plain-tag">suspend</pre> modifier to support asynchronous operations.</p>
<pre class="crayon-plain-tag">suspend fun fetchData(): String {
    // Simulate a long-running operation
    delay(1000)
    return "Data fetched"
}</pre>
<p>suspend functions are special functions in Kotlin that <span style="background-color: #c0c0c0;">can be paused and resumed at a later time without blocking the thread they are running on</span>.</p>
<p>To use the fetchData() function, you'll <span style="background-color: #c0c0c0;">need to call it within a coroutine scope, as suspend functions can only be called from within another suspend function or a coroutine</span>.</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*

fun main() {
    // Start a new coroutine:  This creates a coroutine scope that blocks the main thread 
    // until all coroutines inside it complete.
    runBlocking {
        println("Fetching data...")
        val result = fetchData() // Calls the suspend function
        println(result) // Prints: Data fetched
    }
}</pre>
<div class="blog_h3"><span class="graybg">Comparison with Java</span></div>
<p>Starting from Java 19, Virtual Threads (Project Loom) have been introduced to support lightweight concurrency. However, as of Java 21, coroutines as language constructs are not available. Asynchronous programming in Java is typically handled using CompletableFuture, reactive streams, or third-party libraries.</p>
<p>Java Example with CompletableFuture:</p>
<pre class="crayon-plain-tag">CompletableFuture future = CompletableFuture.supplyAsync(() -&gt; {
    // Simulate long-running operation
    Thread.sleep(1000);
    return "Data fetched";
});

future.thenAccept(System.out::println);</pre>
<div class="blog_h2"><span class="graybg">Special Kotlin Functions</span></div>
<p>Kotlin provides several special functions like <pre class="crayon-plain-tag">with</pre>, <pre class="crayon-plain-tag">apply</pre>, <pre class="crayon-plain-tag">run</pre>, <pre class="crayon-plain-tag">let</pre>, and more to simplify common operations. These functions primarily help manage scope, allow concise object configuration, and reduce boilerplate code. Let's explore these functions with examples.</p>
<div class="blog_h3"><span class="graybg">with</span></div>
<p>The <pre class="crayon-plain-tag">with</pre> function is used to call multiple functions on the same object without repeating its name. It is typically used for operating on an object in a block of code.</p>
<pre class="crayon-plain-tag">data class User(val name: String, var age: Int, var city: String)

fun main() {
    val user = User("John", 25, "New York")
    with(user) {
        println(name)  // Access `name` directly
        age += 1
        println("Updated age: $age")
    }
}</pre>
<p>Output:</p>
<pre class="crayon-plain-tag">John
Updated age: 26</pre>
<p>In the example, <pre class="crayon-plain-tag">with</pre> allows access to the properties of <pre class="crayon-plain-tag">user</pre> without explicitly referring to the object.</p>
<div class="blog_h3"><span class="graybg">apply</span></div>
<p>The <pre class="crayon-plain-tag">apply</pre> function is used to initialize or configure an object. It returns the object itself after applying the configuration.</p>
<pre class="crayon-plain-tag">val user = User("John", 25, "New York").apply {
    age = 26
    city = "San Francisco"
}
println(user)  // Output: User(name=John, age=26, city=San Francisco)</pre>
<p>The <pre class="crayon-plain-tag">apply</pre> function is commonly used for initializing or setting properties in a concise manner.</p>
<div class="blog_h3"><span class="graybg">let</span></div>
<p>The <pre class="crayon-plain-tag">let</pre> function is useful for performing operations on a non-null object and is often used in combination with the safe-call operator (<pre class="crayon-plain-tag">?.</pre>).</p>
<pre class="crayon-plain-tag">val name: String? = "John"
name?.let {
    println("Hello, $it")  // Output: Hello, John
}</pre>
<p>If <pre class="crayon-plain-tag">name</pre> is not null, <pre class="crayon-plain-tag">let</pre> executes the block and prints the value.</p>
<div class="blog_h3"><span class="graybg">run</span></div>
<p>The <pre class="crayon-plain-tag">run</pre> function is similar to <pre class="crayon-plain-tag">let</pre>, but instead of returning the object, it returns the result of the lambda expression. It's great for scoping and executing code in a context.</p>
<pre class="crayon-plain-tag">val user = User("John", 25, "New York")
val result = user.run {
    "User's name is $name, age is $age"
}
println(result)  // Output: User's name is John, age is 25</pre>
<p>In this example, <pre class="crayon-plain-tag">run</pre> returns the result of the expression, not the object itself.</p>
<div class="blog_h3"><span class="graybg">also</span></div>
<p>The <pre class="crayon-plain-tag">also</pre> function is similar to <pre class="crayon-plain-tag">apply</pre>, but it is used to perform additional actions such as logging, side-effects, or validation without affecting the object’s state. It returns the object itself.</p>
<pre class="crayon-plain-tag">val user = User("John", 25, "New York").also {
    println("User created: $it")
}
println(user)  // Output: User(name=John, age=25, city=New York)</pre>
<p><pre class="crayon-plain-tag">also</pre> is often used when you need to perform operations like logging or debugging without changing the object.</p>
<div class="blog_h3"><span class="graybg">takeIf and takeUnless</span></div>
<p>The <pre class="crayon-plain-tag">takeIf</pre> function returns the object if the provided predicate is true; otherwise, it returns null. The opposite is <pre class="crayon-plain-tag">takeUnless</pre>, which returns the object if the predicate is false.</p>
<pre class="crayon-plain-tag">val user = User("John", 25, "New York")

val result = user.takeIf { it.age &gt;= 18 }  // Returns user if age &gt;= 18
println(result)  // Output: User(name=John, age=25, city=New York)

val resultNull = user.takeUnless { it.age &lt; 18 }  // Returns user if age &gt;= 18
println(resultNull)  // Output: User(name=John, age=25, city=New York)</pre>
<p>Both <pre class="crayon-plain-tag">takeIf</pre> and <pre class="crayon-plain-tag">takeUnless</pre> are useful for performing conditional operations on an object.</p>
<p>These special functions in Kotlin provide a powerful way to write concise and expressive code. By using these functions effectively, you can avoid boilerplate code and make your programs easier to understand and maintain.</p>
<div class="blog_h1"><span class="graybg">Object-Oriented Programming</span></div>
<p>Object-Oriented Programming (OOP) is a paradigm centered around objects and classes, enabling developers to model real-world entities and relationships in code. Both Kotlin and Java are object-oriented languages, but Kotlin introduces several enhancements and syntactic sugar to make OOP more concise and expressive. This section explores how Kotlin handles OOP concepts compared to Java. </p>
<div class="blog_h2"><span class="graybg">Classes and Objects</span></div>
<div class="blog_h3"><span class="graybg">Class Declaration</span></div>
<p>In Kotlin, classes are declared using the <pre class="crayon-plain-tag">class</pre> keyword, and you can define properties and methods within them. Unlike Java, <span style="background-color: #c0c0c0;">Kotlin does not require you to place each class in a separate file or match the filename with the class name</span>.</p>
<p>Example:</p>
<pre class="crayon-plain-tag">class Person {
    var name: String = ""
    var age: Int = 0

    fun greet() {
        println("Hello, my name is $name.")
    }
}</pre>
<p>Equivalent Java code:</p>
<pre class="crayon-plain-tag">public class Person {
    private String name = "";
    private int age = 0;

    public void greet() {
        System.out.println("Hello, my name is " + name + ".");
    }
}</pre>
<div class="blog_h3"><span class="graybg">Primary Constructors </span></div>
<p>Kotlin introduces the concept of primary constructors, which are <span style="background-color: #c0c0c0;">declared in the class header and can initialize properties directly</span>.</p>
<pre class="crayon-plain-tag">// val name: String declares an immutable property.
// var age: Int declares a mutable property.
// Properties are initialized through the constructor.
class Person(val name: String, var age: Int) {
    fun greet() {
        println("Hello, my name is $name.")
    }
}

// Instantiate a person
val personInstance = Person(nameArgument, ageArgument)</pre>
<p>Equivalent Java code: </p>
<pre class="crayon-plain-tag">public class Person {
    private final String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public void greet() {
        System.out.println("Hello, my name is " + name + ".");
    }
    
    // Getters and setters omitted for brevity
}

// Instantiate a person
Person personInstance = new Person(nameArgument, ageArgument);</pre>
<div class="blog_h3"><span class="graybg">Initializer Blocks</span></div>
<p>In Kotlin, initializer blocks can be used to execute code during object creation.</p>
<pre class="crayon-plain-tag">class Person(val name: String, var age: Int) {
    init {
        println("Person initialized with name = $name and age = $age")
    }
}</pre>
<div class="blog_h3"><span class="graybg">Secondary Constructors</span></div>
<p>Kotlin allows secondary constructors for additional initialization logic. </p>
<pre class="crayon-plain-tag">class Person {
    var name: String
    var age: Int

    constructor(name: String) {
        this.name = name
        this.age = 0
    }

    constructor(name: String, age: Int) {
        this.name = name
        this.age = age
    }
}</pre>
<p>Please note that Secondary Constructors are less common due to the flexibility of default parameters.</p>
<div class="blog_h3"><span class="graybg">Properties and Fields </span></div>
<p>In Kotlin, properties are a central concept that combines a field (to hold data) and optional accessors (getters and setters) into a single, concise syntax. This approach simplifies code and reduces boilerplate compared to Java, where you typically need to declare private fields and provide public getter and setter methods separately.</p>
<pre class="crayon-plain-tag">//  declares a mutable property name of type String with an initial value of "Unknown".
var name: String = "Unknown"</pre>
<p>For a mutable property declared with <pre class="crayon-plain-tag">var</pre>, Kotlin automatically generates:</p>
<ol style="list-style-type: undefined;">
<li>A getter method to retrieve the property's value.</li>
<li>A setter method to set or modify the property's value.</li>
</ol>
<p>For an immutable property declared with <pre class="crayon-plain-tag">val</pre>, Kotlin only generates a getter. </p>
<p>You can customize the getter and setter if you need additional logic when accessing or modifying the property. Syntax for Custom Accessors:</p>
<pre class="crayon-plain-tag">var propertyName: Type = initialValue
    get() {
        // Custom getter logic
    }
    set(value) {
        // Custom setter logic
    }</pre>
<p>Within the getter and setter, <pre class="crayon-plain-tag">field</pre> is a special backing field identifier provided by Kotlin. It refers to the actual storage of the property's value.</p>
<pre class="crayon-plain-tag">var name: String = "Unknown"
    get() = field
    set(value) {
        field = value.capitalize()
    }</pre>
<p>In Java, we always need to explicitly implement getter and setter:</p>
<pre class="crayon-plain-tag">private String name = "Unknown";

public String getName() {
    return name;
}

public void setName(String name) {
    this.name = capitalize(name);
}

private String capitalize(String value) {
    // Capitalize logic
}</pre>
<div class="blog_h2"><span class="graybg">Inheritance and Interfaces</span></div>
<div class="blog_h3"><span class="graybg">Inheritance</span></div>
<p>I<span style="background-color: #c0c0c0;">n Kotlin, classes are final by default</span>. To allow a class to be subclassed, you must mark it with the <pre class="crayon-plain-tag">open</pre> keyword.</p>
<pre class="crayon-plain-tag">open class Animal {
    open fun sound() {
        println("Some sound")
    }
}

class Dog : Animal() {
    override fun sound() {
        println("Bark")
    }
}</pre>
<p>In Java, classes and methods are open for extension by default unless marked as final:</p>
<pre class="crayon-plain-tag">public class Animal {
    public void sound() {
        System.out.println("Some sound");
    }
}

public class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("Bark");
    }
}</pre>
<div class="blog_h3"><span class="graybg">Interfaces </span></div>
<p>Kotlin interfaces<span style="background-color: #c0c0c0;"> can contain both abstract methods and method implementations</span>.</p>
<pre class="crayon-plain-tag">interface Movable {
    fun move()
    fun stop() {
        println("Stopped moving")
    }
}

class Vehicle : Movable {
    override fun move() {
        println("Vehicle is moving")
    }
}</pre>
<p>From Java 8,  interfaces can provide default implementations:</p>
<pre class="crayon-plain-tag">public interface Movable {
    void move();
    
    default void stop() {
        System.out.println("Stopped moving");
    }
}

public class Vehicle implements Movable {
    @Override
    public void move() {
        System.out.println("Vehicle is moving");
    }
}</pre>
<div class="blog_h2"><span class="graybg">Multiple Inheritance of Behavior</span></div>
<p>Kotlin allows a class to implement multiple interfaces, and if <span style="background-color: #c0c0c0;">there are conflicts, you must override the conflicting methods</span>.</p>
<pre class="crayon-plain-tag">interface A {
    fun show() {
        println("A")
    }
}

interface B {
    fun show() {
        println("B")
    }
}

class C : A, B {
    override fun show() {
        super<a>.show()
        super<b>.show()
    }
}

val c = C()
c.show()
// Output:
// A
// B
</b></a></pre>
<p>In Java you need to do something similar to solve the conflict:</p>
<pre class="crayon-plain-tag">public interface A {
    default void show() {
        System.out.println("A");
    }
}

public interface B {
    default void show() {
        System.out.println("B");
    }
}

public class C implements A, B {
    @Override
    public void show() {
        A.super.show();
        B.super.show();
    }
}</pre>
<div class="blog_h2"><span class="graybg">Data Classes</span></div>
<p>Data classes in Kotlin are designed to hold data. The compiler automatically generates <pre class="crayon-plain-tag">equals()</pre>, <pre class="crayon-plain-tag">hashCode()</pre>, <pre class="crayon-plain-tag">toString()</pre>, and <pre class="crayon-plain-tag">copy()</pre> methods.</p>
<pre class="crayon-plain-tag">data class User(val name: String, val age: Int)

val user1 = User("Alice", 30)
println(user1) // Output: User(name=Alice, age=30)

val user2 = user1.copy(age = 31)
println(user2) // Output: User(name=Alice, age=31)</pre>
<p>Java Equivalent Prior to Records:</p>
<pre class="crayon-plain-tag">public class User {
    private final String name;
    private final int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // Getters, equals(), hashCode(), and toString() methods
    // Need to be manually implemented or generated by the IDE
}</pre>
<p>Java Equivalent with Records ( Java 16+ ):</p>
<pre class="crayon-plain-tag">public record User(String name, int age) {}

User user1 = new User("Alice", 30);
System.out.println(user1); // Output: User[name=Alice, age=30]

User user2 = new User(user1.name(), 31);
System.out.println(user2); // Output: User[name=Alice, age=31]</pre>
<div class="blog_h2"><span class="graybg">Sealed Classes and Interfaces</span></div>
<p>Sealed classes and interfaces restrict the hierarchy to a finite set of subclasses, known at compile time.</p>
<pre class="crayon-plain-tag">sealed class Result

// Declares a data class Success that inherits from Result
data class Success(val data: String) : Result()
data class Error(val exception: Exception) : Result()
// Declares a singleton object Loading that inherits from Result.
object Loading : Result()</pre>
<p>Java 17 introduced sealed classes and interfaces. </p>
<pre class="crayon-plain-tag">public sealed class Result permits Success, Error, Loading {}

public final class Success extends Result {
    private final String data;
    // Constructor, getters, etc.
}

public final class Error extends Result {
    private final Exception exception;
    // Constructor, getters, etc.
}

public final class Loading extends Result {}</pre>
<div class="blog_h2"><span class="graybg">Visibility Modifiers</span></div>
<p>Kotlin offers several visibility modifiers:</p>
<ol style="list-style-type: undefined;">
<li><span style="background-color: #c0c0c0;">public (default): Visible everywhere.</span></li>
<li><span style="background-color: #c0c0c0;">internal: Visible within the same module.</span></li>
<li>protected: Visible to subclasses.</li>
<li>private: Visible within the containing declaration.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">class Example {
    private val x = 1
    internal val y = 2
    protected val z = 3
    val w = 4 // Public by default
}</pre>
<p>Java Visibility Modifiers: </p>
<ol style="list-style-type: undefined;">
<li>public: Visible everywhere.</li>
<li>protected: Visible within the package and subclasses.</li>
<li><span style="background-color: #c0c0c0;">Package-private (default): Visible within the package.</span></li>
<li>private: Visible within the class.</li>
</ol>
<div class="blog_h2"><span class="graybg">Nested and Inner Classes</span></div>
<div class="blog_h3"><span class="graybg">Nested Classes</span></div>
<p>In Kotlin, <span style="background-color: #c0c0c0;">a nested class is static by default</span>. It does not hold a reference to the outer class.</p>
<pre class="crayon-plain-tag">class Outer {
    class Nested {
        fun hello() = "Hello from Nested"
    }
}

val message = Outer.Nested().hello()
println(message) // Output: Hello from Nested</pre>
<p>Java static netsted class:</p>
<pre class="crayon-plain-tag">public class Outer {
    public static class Nested {
        public String hello() {
            return "Hello from Nested";
        }
    }
}

String message = new Outer.Nested().hello();
System.out.println(message); // Output: Hello from Nested</pre>
<div class="blog_h3"><span class="graybg">Inner Classes</span></div>
<p>An inner class in Kotlin holds a reference to the outer class and can access its members.</p>
<pre class="crayon-plain-tag">class Outer(val name: String) {
    inner class Inner {
        fun greet() = "Hello from $name's Inner"
    }
}

val outer = Outer("Kotlin")
val message = outer.Inner().greet()
println(message) // Output: Hello from Kotlin's Inner</pre>
<p>Java Inner Class:</p>
<pre class="crayon-plain-tag">public class Outer {
    private String name;

    public Outer(String name) {
        this.name = name;
    }

    public class Inner {
        public String greet() {
            return "Hello from " + name + "'s Inner";
        }
    }
}

Outer outer = new Outer("Java");
String message = outer.new Inner().greet();
System.out.println(message); // Output: Hello from Java's Inner</pre>
<div class="blog_h3"><span class="graybg">Nested/Inner Data Class</span></div>
<p>Data classes can also be nested or inner class:</p>
<pre class="crayon-plain-tag">// Nested
data class Person(val name: String, val age: Int) {
    data class Address(val street: String, val city: String)
}

fun main() {
    val address = Person.Address("Main St", "Springfield")
    val person = Person("John Doe", 30)

    println(person)  // Output: Person(name=John Doe, age=30)
    println(address) // Output: Address(street=Main St, city=Springfield)
}


// Inner
data class Person(val name: String, val age: Int) {
    inner data class Address(val street: String, val city: String) {
        fun getFullAddress(): String {
            return "$name lives at $street, $city"  // Accessing outer class (Person) properties
        }
    }
}

fun main() {
    val person = Person("John Doe", 30)
    val address = person.Address("Main St", "Springfield")

    println(address.getFullAddress())  // Output: John Doe lives at Main St, Springfield
}</pre>
<div class="blog_h2"><span class="graybg">Object Declarations and Companion Objects</span></div>
<div class="blog_h3"><span class="graybg">Singleton Objects</span></div>
<p>Kotlin provides a concise way to create singleton objects using <pre class="crayon-plain-tag">object</pre> declarations.</p>
<pre class="crayon-plain-tag">object Singleton {
    fun greet() = "Hello from Singleton"
}

println(Singleton.greet()) // Output: Hello from Singleton</pre>
<p> In Java, you typically use a class with a private constructor and a static instance.</p>
<pre class="crayon-plain-tag">public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    
    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }

    public String greet() {
        return "Hello from Singleton";
    }
}

System.out.println(Singleton.getInstance().greet()); // Output: Hello from Singleton</pre>
<div class="blog_h3"><span class="graybg">Companion Objects</span></div>
<p>In Kotlin, companion objects can hold static members and factory methods.</p>
<pre class="crayon-plain-tag">class MyClass {
    companion object {
        const val CONSTANT = 100
        fun create(): MyClass = MyClass()
    }
}

println(MyClass.CONSTANT) // Output: 100
val instance = MyClass.create()</pre>
<p>Java uses static members and methods.</p>
<pre class="crayon-plain-tag">public class MyClass {
    public static final int CONSTANT = 100;

    public static MyClass create() {
        return new MyClass();
    }
}

System.out.println(MyClass.CONSTANT); // Output: 100
MyClass instance = MyClass.create();</pre>
<div class="blog_h2"><span class="graybg">Delegation</span></div>
<div class="blog_h3"><span class="graybg">Class Delegation</span></div>
<p>Kotlin supports delegation of interface implementation to another object.</p>
<pre class="crayon-plain-tag">interface Printer {
    fun print()
}

class ConsolePrinter : Printer {
    override fun print() {
        println("Printing to console")
    }
}

class PrinterManager(printer: Printer) : Printer by printer

val manager = PrinterManager(ConsolePrinter())
manager.print() // Output: Printing to console</pre>
<p>The syntax <pre class="crayon-plain-tag">Printer by printer</pre>in the above example is called <span style="background-color: #c0c0c0;">delegation in Kotlin. It means that PrinterManager will automatically forward (delegate) all calls to the print() method to the printer object that was passed in</span>.</p>
<p>Java does not have built-in support for class delegation. You would need to implement the methods and delegate calls manually.</p>
<pre class="crayon-plain-tag">public interface Printer {
    void print();
}

public class ConsolePrinter implements Printer {
    @Override
    public void print() {
        System.out.println("Printing to console");
    }
}

public class PrinterManager implements Printer {
    private final Printer printer;

    public PrinterManager(Printer printer) {
        this.printer = printer;
    }

    @Override
    public void print() {
        printer.print();
    }
}

PrinterManager manager = new PrinterManager(new ConsolePrinter());
manager.print(); // Output: Printing to console</pre>
<div class="blog_h3"><span class="graybg">Property Delegation</span></div>
<p>Kotlin also allows delegation of property getters and setters.</p>
<pre class="crayon-plain-tag">import kotlin.properties.Delegates

class User {
    // A mutable property (var) named name of type String
    // Use Kotlin's Delegates.observable delegate to manage the behavior of the name property. 
    //   This function allows you to observe changes to the property and take some action 
    //   whenever the property's value is changed.
    // The initial value of the name property is set to ""
    // The last parameter of Delegates.observable is a
    var name: String by Delegates.observable("") { prop, old, new -&gt;
        println("Property '${prop.name}' changed from '$old' to '$new'")
    }
}

val user = User()
user.name = "Alice" // Output: Property 'name' changed from '' to 'Alice'</pre>
<div class="blog_h2"><span class="graybg">Abstract Classes </span></div>
<p>Abstract classes in Kotlin are declared using the abstract keyword and can contain abstract members that must be implemented by subclasses.</p>
<pre class="crayon-plain-tag">abstract class Vehicle {
    abstract fun drive()
    fun stop() {
        println("Vehicle stopped")
    }
}

class Car : Vehicle() {
    override fun drive() {
        println("Car is driving")
    }
}

val car = Car()
car.drive() // Output: Car is driving
car.stop()  // Output: Vehicle stopped</pre>
<p>Equivalent Java code:</p>
<pre class="crayon-plain-tag">public abstract class Vehicle {
    public abstract void drive();
    
    public void stop() {
        System.out.println("Vehicle stopped");
    }
}

public class Car extends Vehicle {
    @Override
    public void drive() {
        System.out.println("Car is driving");
    }
}

Car car = new Car();
car.drive(); // Output: Car is driving
car.stop();  // Output: Vehicle stopped</pre>
<div class="blog_h2"><span class="graybg">Enum Classes</span></div>
<p>Enum classes represent a fixed set of constants.</p>
<pre class="crayon-plain-tag">enum class Direction {
    NORTH, SOUTH, EAST, WEST
}

val dir = Direction.NORTH
println(dir) // Output: NORTH</pre>
<p>Java Enum Class:</p>
<pre class="crayon-plain-tag">public enum Direction {
    NORTH, SOUTH, EAST, WEST;
}

Direction dir = Direction.NORTH;
System.out.println(dir); // Output: NORTH</pre>
<div class="blog_h2"><span class="graybg">Inline Classes and Value Classes </span></div>
<p>Kotlin introduces inline classes (now known as value classes) to create type-safe wrappers without runtime overhead.</p>
<p>Kotlin Value Class (Kotlin 1.5 and Later):</p>
<pre class="crayon-plain-tag">@JvmInline
value class Email(val address: String)

fun sendEmail(email: Email) {
    // ...
}

val email = Email("test@example.com")
sendEmail(email)</pre>
<p>In ths example above, Email is a wrapper around String but without additional allocation.</p>
<div class="blog_h2"><span class="graybg">Object Equality</span></div>
<p>Kotlin distinguishes between structural equality (==) and referential equality (===).</p>
<ol>
<li><pre class="crayon-plain-tag">a == b</pre> checks if the values are equal (calls equals()).</li>
<li><pre class="crayon-plain-tag">a === b</pre> checks if the references are the same.</li>
</ol>
<p>Java Equivalent:</p>
<ol style="list-style-type: undefined;">
<li><pre class="crayon-plain-tag">a.equals(b)</pre> checks value equality.</li>
<li><pre class="crayon-plain-tag">a == b</pre> checks reference equality.</li>
</ol>
<div class="blog_h1"><span class="graybg">Coroutines</span></div>
<div class="blog_h2"><span class="graybg">Introduction to Coroutines</span></div>
<p>Coroutines are a powerful feature in Kotlin that facilitate asynchronous and non-blocking programming. They are lightweight threads that allow you to write asynchronous code in a sequential and readable manner.</p>
<div class="blog_h3"><span class="graybg">The Problem with Traditional Asynchronous Code</span></div>
<p>In traditional asynchronous programming, especially in Java, handling asynchronous tasks often leads to:</p>
<ol style="list-style-type: undefined;">
<li>Callback Hell: Nested callbacks that make code hard to read and maintain.</li>
<li>Complex Thread Management: Manual handling of threads, synchronization, and locking mechanisms.</li>
</ol>
<div class="blog_h3"><span class="graybg">Kotlin's Solution: Coroutines</span></div>
<p>Kotlin coroutines simplify asynchronous programming by:</p>
<ol>
<li>Suspending Functions: Functions that can suspend execution without blocking the thread.</li>
<li>Structured Concurrency: Managing coroutines in a structured way to avoid leaks and ensure proper cancellation.</li>
</ol>
<div class="blog_h3"><span class="graybg">How Coroutines Differ from Traditional Threading</span></div>
<ol style="list-style-type: undefined;">
<li>Lightweight: Coroutines are much lighter than threads. You can run thousands of coroutines without significant overhead.</li>
<li>Non-blocking: <span style="background-color: #c0c0c0;">Suspending a coroutine doesn't block the underlying thread</span>, allowing other coroutines to run.</li>
<li>Simplified Syntax: Coroutines allow writing asynchronous code sequentially, improving readability.</li>
</ol>
<div class="blog_h2"><span class="graybg">Coroutine Builders</span></div>
<p>Coroutine builders are functions that help you create and start coroutines.</p>
<div class="blog_h3"><span class="graybg">launch</span></div>
<p>Starts a new coroutine without blocking the current thread. It returns a Job that can be used to manage the coroutine.</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}
// Output:
// Hello,
// World!</pre>
<div class="blog_h3"><span class="graybg">async</span></div>
<p>Starts a new coroutine and returns a <pre class="crayon-plain-tag">Deferred</pre> result (similar to <pre class="crayon-plain-tag">Future</pre> in Java).</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred = async {
        delay(1000L)
        "Result"
    }
    println("Waiting for result...")
    val result = deferred.await()
    println("Result: $result")
}
// Output:
// Waiting for result...
// Result: Result</pre>
<div class="blog_h3"><span class="graybg">runBlocking</span></div>
<p>Bridges the gap between regular blocking code and suspending code. It blocks the current thread until its coroutine completes.</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Start")
    delay(1000L)
    println("End")
}
// Output:
// Start
// End</pre>
<div class="blog_h2"><span class="graybg">Suspending Functions</span></div>
<p>Functions marked with the suspend keyword that can suspend execution without blocking the thread. This kind of functions can only be called from within a coroutine or another suspending function.</p>
<pre class="crayon-plain-tag">suspend fun fetchData(): String {
    delay(1000L) // Simulate long-running task
    return "Data"
}

fun main() = runBlocking {
    val data = fetchData()
    println("Fetched: $data")
}</pre>
<div class="blog_h2"><span class="graybg">Coroutine Scope and Context</span></div>
<div class="blog_h3"><span class="graybg">Coroutine Scope</span></div>
<p>Coroutine scope defines the lifecycle of coroutines and provides context for them:</p>
<ol>
<li>GlobalScope: Lives for the entire lifetime of the application:
<ol>
<li>Coroutines launched in this scope live for the entire lifetime of the application.</li>
<li>These coroutines are not bound to any specific lifecycle (like an activity or a function) and keep running unless explicitly canceled or when the application terminates.</li>
<li>It should be avoided in most cases because it doesn't allow proper lifecycle management, which could lead to memory leaks or unintended behavior.</li>
</ol>
</li>
<li>CoroutineScope: Custom scope for structured concurrency:
<ol>
<li>It is used to create structured concurrency. This means coroutines launched within this scope are bound to its lifecycle, and if the scope is canceled, all the coroutines within it are also canceled.</li>
<li>CoroutineScope can be manually created, or it can be inherited from a parent scope like in runBlocking.</li>
<li>It is preferred for organizing coroutines so they can be properly canceled, ensuring that resources are not leaked.</li>
</ol>
</li>
</ol>
<p>Example of GlobalScope:</p>
<pre class="crayon-plain-tag">GlobalScope.launch {
    println("Running in GlobalScope")
}</pre>
<p> In this example, the coroutine runs independently of any lifecycle and will only stop when canceled or the application terminates.</p>
<p>Example of CoroutineScope inherited from a parent scope:</p>
<pre class="crayon-plain-tag">fun main() = runBlocking {
    launch { // Inherits parent scope
        println("Coroutine in runBlocking scope")
    }
}</pre>
<p><pre class="crayon-plain-tag">runBlocking</pre> creates a special scope, used mainly in testing or main functions, where the code inside runBlocking is run synchronously.</p>
<p>Example of a custom scope: </p>
<pre class="crayon-plain-tag">class MyClass {
    private val scope = CoroutineScope(Dispatchers.Default)

    fun startTask() {
        scope.launch {
            println("Running in a custom CoroutineScope")
        }
    }

    fun stopTask() {
        scope.cancel()  // Cancels all coroutines in this scope
    }
}</pre>
<div class="blog_h3"><span class="graybg">Coroutine Context</span></div>
<p>A coroutine context contains information like the job, dispatcher, and exception handler.</p>
<p>A dispatcher defines which thread or thread pool a coroutine will be executed on. Dispatchers help distribute and manage tasks efficiently based on their requirements, such as computational intensity, input/output operations, or user interface tasks:</p>
<ol>
<li>Dispatchers.Default:
<ol>
<li>Used for CPU-intensive tasks (e.g., complex calculations, sorting, etc.).</li>
<li>It uses a shared pool of background threads optimized for CPU-bound operations.</li>
</ol>
</li>
<li>Dispatchers.IO:
<ol>
<li>Designed for I/O operations like reading from or writing to files, network requests, or database interactions.</li>
<li>It uses a pool of threads optimized for blocking I/O operations to prevent CPU starvation.</li>
</ol>
</li>
<li>Dispatchers.Main:
<ol>
<li>Used for UI-related work, typically on the main thread of an application (for example, in Android development).</li>
<li>It ensures that tasks interacting with the user interface are executed on the main thread to avoid UI lag or inconsistencies.</li>
</ol>
</li>
</ol>
<p>An example of using a dispatcher:</p>
<pre class="crayon-plain-tag">fun main() = runBlocking {
    launch(Dispatchers.IO) {
        val data = fetchData()
        println("Data: $data")
    }
}</pre>
<div class="blog_h2"><span class="graybg">Structured Concurrency</span></div>
<p>Structured concurrency is a design principle in Kotlin Coroutines that ensures that coroutines are executed within a structured scope, with clear parent-child relationships. This design makes it easier to manage their lifecycle, handle exceptions, and ensure that resources are properly released.</p>
<p>In simple terms, structured concurrency ensures that coroutines have a well-defined lifecycle. The parent coroutine will not complete until all of its child coroutines have finished, and if the parent coroutine is canceled, all of its child coroutines will also be automatically canceled. This structure helps avoid common pitfalls such as leaking coroutines or leaving background tasks running indefinitely.</p>
<div class="blog_h3"><span class="graybg">Key Benefits of Structured Concurrency</span></div>
<ol style="list-style-type: undefined;">
<li>Automatic Cancellation: <span style="background-color: #c0c0c0;">When a parent coroutine is canceled, all its child coroutines are canceled automatically.</span> This prevents coroutines from running longer than necessary and ensures that resources (like memory or network connections) are freed up.</li>
<li>Avoiding Leaked Coroutines: <span style="background-color: #c0c0c0;">Coroutines are tied to a scope, and the lifecycle of that scope dictates the lifetime of the coroutines</span>. This avoids situations where coroutines continue running even after their associated tasks or parent coroutine is no longer needed, preventing resource leaks.</li>
<li>Lifecycle Management:<span style="background-color: #c0c0c0;"> The parent coroutine waits for its child coroutines to finish</span>. This ensures a predictable and manageable lifecycle for the coroutines, avoiding orphaned coroutines that can be hard to trace and manage.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">suspend fun fetchData(): String = coroutineScope {
    // Launch two async coroutines to fetch data concurrently
    val data1 = async { /* Simulate fetching data 1 */ "Data1" }
    val data2 = async { /* Simulate fetching data 2 */ "Data2" }

    // Wait for both data to be fetched and concatenate the results
    data1.await() + data2.await()
}</pre>
<p>Explanation:</p>
<ol>
<li>coroutineScope: The coroutineScope function creates a scope that ensures all the coroutines launched within it (like async or launch) are completed before it returns. This ensures that fetchData() will only return when both data1 and data2 have finished fetching their data.</li>
<li>async: The async function launches a new coroutine concurrently to execute a block of code. It's similar to launch, but it returns a Deferred result, which you can await to get the result.</li>
<li>await: This suspends the parent coroutine until the result of the child coroutine (created by async) is available. In this case, the result is the fetched data.</li>
<li>Structured Concurrency in Action:
<ol>
<li>Both data1 and data2 are launched concurrently.</li>
<li>The coroutineScope ensures that the parent coroutine waits for both data1.await() and data2.await() to finish before proceeding.</li>
<li>If an exception occurs in one of the async blocks, or if the parent coroutine is canceled, both data1 and data2 coroutines will be automatically canceled.</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">How Structured Concurrency Works Under the Hood</span></div>
<ol style="list-style-type: undefined;">
<li>Parent-Child Relationship: Every coroutine has <span style="background-color: #c0c0c0;">a parent-child relationship when launched within a scope. The coroutineScope establishes this relationship</span>.</li>
<li>Exception Propagation: <span style="background-color: #c0c0c0;">If a child coroutine fails with an exception, that exception is propagated to its parent</span>, and the entire coroutine scope is canceled unless handled explicitly. This ensures that errors are not silently ignored.</li>
<li>Job Hierarchy: Coroutines form a hierarchy of jobs, with a parent job being responsible for managing the completion of its children. Cancellation of the parent job cascades down to its children.</li>
</ol>
<div class="blog_h2"><span class="graybg">Channels and Flow</span></div>
<p>Both Channels and Flows provide ways to deal with asynchronous streams of data in Kotlin coroutines, but they have different use cases, behavior, and underlying mechanisms. Here's a breakdown of each, followed by the key differences.</p>
<div class="blog_h3"><span class="graybg">Channels</span></div>
<ol>
<li>Channels are similar to queues and provide a way for coroutines to send and receive data.</li>
<li>Channels allow bi-directional communication between producer and consumer coroutines, where one coroutine can send data and another coroutine can receive it.</li>
<li>Channels can be hot, meaning the data is produced whether or not the consumer is actively receiving it.</li>
<li>When a channel is closed (using channel.close()), it signals that no further values will be sent, but the consumer can still receive the remaining values until the channel is empty.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel = Channel()

    // Launching a coroutine to send data to the channel
    launch {
        for (x in 1..5) channel.send(x * x)  // Sending values (1^2, 2^2, ..., 5^2)
        channel.close()  // Close the channel to indicate no more data
    }

    // Receiving data from the channel
    for (y in channel) {
        println(y)  // Prints 1, 4, 9, 16, 25
    }
}</pre>
<div class="blog_h3"><span class="graybg">Flow</span></div>
<ol>
<li>A Flow is a cold asynchronous data stream that emits values sequentially.</li>
<li>Flows are <span style="background-color: #c0c0c0;">unidirectional and typically represent a one-way stream of data from producer to consumer</span>.</li>
<li>Flows are <span style="background-color: #c0c0c0;">cold, meaning the data is only produced when a consumer starts collecting the flow</span>. <span style="background-color: #c0c0c0;">If no one is collecting the flow, the producer is inactive</span>.</li>
<li>Flows are much more declarative and follow a pattern similar to reactive streams. They emit data using the emit() function and are consumed using collect().</li>
<li>Flow APIs handle backpressure and cancellation transparently.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun simpleFlow(): Flow = flow {
    for (i in 1..3) {
        delay(100)  // Simulate asynchronous work
        emit(i)  // Emit values (1, 2, 3)
    }
}

fun main() = runBlocking {
    simpleFlow().collect { value -&gt;
        println(value)  // Prints 1, 2, 3
    }
}</pre>
<div class="blog_h2"><span class="graybg">Comparison with Java Threading</span></div>
<div class="blog_h3"><span class="graybg">Traditional Java Threading</span></div>
<ol>
<li>Threads: Heavyweight, managed by the OS.</li>
<li>Synchronization: Requires explicit handling of synchronization, locks, and potential deadlocks.</li>
<li>Asynchronous APIs: Use of Future, Callable, ExecutorService, and CompletableFuture. </li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">public class ThreadExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -&gt; {
            try {
                Thread.sleep(1000);
                System.out.println("World!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        thread.start();
        System.out.println("Hello,");
    }
}
// Output may vary due to thread scheduling:
// Hello,
// World!</pre>
<div class="blog_h3"><span class="graybg">Java's CompletableFuture (Java 8 and Later)</span></div>
<p>CompletableFuture is for building asynchronous computation stages.</p>
<pre class="crayon-plain-tag">import java.util.concurrent.*;

public class CompletableFutureExample {
    public static void main(String[] args) throws Exception {
        CompletableFuture future = CompletableFuture.supplyAsync(() -&gt; {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Result";
        });
        System.out.println("Waiting for result...");
        String result = future.get();
        System.out.println("Result: " + result);
    }
}
// Output:
// Waiting for result...
// Result: Result</pre>
<div class="blog_h2"><span class="graybg">Comparison with Java Virtual Threads</span></div>
<p>Java Virtual Threads are a feature introduced under Project Loom, available as a preview in Java 19 and beyond. Virtual Threads aim to provide lightweight, high-throughput threading by decoupling the notion of a Java thread from an operating system thread. Key features include:</p>
<ol>
<li>Lightweight Threads: Virtual Threads are managed by the JVM rather than the OS, allowing for millions of threads.</li>
<li>Familiar APIs: Use the same java.lang.Thread API, making it easier for developers to adopt.</li>
<li>Better Resource Utilization: Improves scalability and performance for applications with high concurrency needs.</li>
<li>Compatibility: Works with existing Java code and libraries.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">public class VirtualThreadExample {
    public static void main(String[] args) throws Exception {
        Thread.startVirtualThread(() -&gt; {
            try {
                Thread.sleep(1000);
                System.out.println("World!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        System.out.println("Hello,");
        Thread.sleep(1500); // Ensure the virtual thread has time to execute
    }
}</pre>
<p>&nbsp;</p>
<div class="blog_h1"><span class="graybg">Generics &amp; Variance<br /></span></div>
<div class="blog_h2"><span class="graybg">Generics </span></div>
<p>Generics are a powerful feature in Kotlin that allow you to write flexible and reusable code. By using generics, you can define classes, methods, and interfaces that work with any type while preserving type safety. Generics enable you to create algorithms or data structures that can handle multiple types without having to write the same logic multiple times for different types.</p>
<div class="blog_h3"><span class="graybg">Basic Syntax of Generics</span></div>
<p>A generic class or function in Kotlin can accept a type parameter, which allows the user to specify the actual type when using that class or function. The type parameter is usually denoted by a single capital letter, commonly T, but you can use any name you like.</p>
<pre class="crayon-plain-tag">// Defining a generic class Box with a type parameter T
class Box(val value: T)

fun main() {
    // Creating a Box that holds an Int
    val intBox: Box = Box(42)
    println(intBox.value)  // Output: 42

    // Creating a Box that holds a String
    val stringBox: Box = Box("Hello")
    println(stringBox.value)  // Output: Hello
}</pre>
<div class="blog_h3"><span class="graybg">Generic Functions</span></div>
<p>Just like classes, functions in Kotlin can also be made generic by introducing type parameters in the function definition. Here's how you can define and use a generic function: </p>
<pre class="crayon-plain-tag">// Defining a generic function that works with any type
fun  printBoxContent(box: Box) {
    println("Box contains: ${box.value}")
}

fun main() {
    val intBox = Box(42)
    val stringBox = Box("Hello")
    
    // Calling the generic function with different types
    printBoxContent(intBox)    // Output: Box contains: 42
    printBoxContent(stringBox)  // Output: Box contains: Hello
}</pre>
<div class="blog_h3"><span class="graybg">Type Constraints in Generics</span></div>
<p>Sometimes, you might want to restrict the types that can be used as generic arguments. Kotlin allows you to apply type constraints to limit the types that can be passed to a generic type parameter.</p>
<p>For example, you can limit a generic type to only accept subtypes of a particular class or implement a specific interface. This is done using the where keyword or inline directly after the type parameter with the <pre class="crayon-plain-tag">:</pre> symbol.</p>
<pre class="crayon-plain-tag">// Defining a generic class with a type constraint
class Container(val value: T)

fun main() {
    val intContainer = Container(42)   // Allowed because Int is a subtype of Number
    val doubleContainer = Container(3.14)  // Allowed because Double is a subtype of Number

    // val stringContainer = Container("Hello")  // Error: String is not a subtype of Number
}</pre>
<div class="blog_h3"><span class="graybg">Type Erasure in Generics</span></div>
<p>Like Java, Kotlin uses type erasure for generics, meaning that the generic type information is only available at compile time and is erased at runtime. This means you cannot directly check the type of a generic parameter at runtime.</p>
<p>For instance, trying to check the type of a generic parameter like this will not work:</p>
<pre class="crayon-plain-tag">fun  checkType(value: T) {
    if (value is List) {
        // Error: Cannot check for List at runtime due to type erasure
    }
}</pre>
<div class="blog_h3"><span class="graybg">Generics with Multiple Type Parameters </span></div>
<p>You can also define classes or functions with multiple generic type parameters, allowing even more flexibility.</p>
<pre class="crayon-plain-tag">// Defining a class with two generic type parameters
class PairBox&lt;T1, T2&gt;(val first: T1, val second: T2)

fun main() {
    val pair = PairBox(1, "One")
    println("First: ${pair.first}, Second: ${pair.second}")  // Output: First: 1, Second: One
}</pre>
<div class="blog_h2"><span class="graybg">Reified Keyword</span></div>
<p>In Kotlin, generics are usually erased at runtime, which means that type information is not available during runtime. However, in some cases, you may need to <span style="background-color: #ffffff;"><span style="background-color: #c0c0c0;">retain the generic type information</span> for certain operations</span>, such as casting or checking the type of an object. This is where the <pre class="crayon-plain-tag">reified</pre> keyword comes into play.</p>
<p>By using the <pre class="crayon-plain-tag">reified</pre> keyword in combination with an <pre class="crayon-plain-tag">inline</pre> function, Kotlin allows you to retain the type information at runtime. Let’s explore how this works:</p>
<div class="blog_h3"><span class="graybg">Usage of Reified Keyword</span></div>
<p>Normally, without <pre class="crayon-plain-tag">reified</pre>, you can’t access the type of generic parameters at runtime because of type erasure. However, using <pre class="crayon-plain-tag">reified</pre>, you can perform type checks and casts directly:</p>
<pre class="crayon-plain-tag">inline fun isTypeOf(value: Any): Boolean {
    return value is T
}

fun main() {
    val result = isTypeOf("Hello")
    println(result) // Output: true
}</pre>
<p>In this example, the <pre class="crayon-plain-tag">isTypeOf</pre> function is an <pre class="crayon-plain-tag">inline</pre> function with a <pre class="crayon-plain-tag">reified</pre> generic type <pre class="crayon-plain-tag">T</pre>. The reified type allows Kotlin to know what type <pre class="crayon-plain-tag">T</pre> is during runtime, which is not possible with regular generics.</p>
<div class="blog_h3"><span class="graybg">Practical Use of Reified</span></div>
<p>One practical use of <pre class="crayon-plain-tag">reified</pre> is to simplify code that involves type casting. Without <pre class="crayon-plain-tag">reified</pre>, you would need to pass the class type explicitly, which makes the code more verbose. Let’s look at the difference:</p>
<p>Without Reified:</p>
<pre class="crayon-plain-tag">fun  getClassName(clazz: Class): String {
    return clazz.simpleName
}

fun main() {
    val className = getClassName(String::class.java)
    println(className) // Output: String
}</pre>
<p>With Reified:</p>
<pre class="crayon-plain-tag">inline fun  getClassName(): String {
    return T::class.java.simpleName
}

fun main() {
    val className = getClassName()
    println(className) // Output: String
}</pre>
<p>As you can see, by using <pre class="crayon-plain-tag">reified</pre>, the function signature becomes simpler, and there is no need to explicitly pass the class type. Kotlin can infer it automatically at runtime.</p>
<div class="blog_h3"><span class="graybg">Limitations of Reified</span></div>
<p>It’s important to note that the <pre class="crayon-plain-tag">reified</pre> keyword can only be used in <pre class="crayon-plain-tag">inline</pre> functions. This is because the type information is only preserved during runtime if the function is inlined. Otherwise, Kotlin would still erase the type information as part of type erasure.</p>
<p>Here’s an attempt to use <pre class="crayon-plain-tag">reified</pre> in a non-inline function, which would cause a compilation error:</p>
<pre class="crayon-plain-tag">fun  getClassName(): String { // Error: Reified type parameter T is not allowed in non-inline functions
    return T::class.java.simpleName
}</pre>
<p>To summarize, <pre class="crayon-plain-tag">reified</pre> provides a powerful way to retain type information at runtime in Kotlin, particularly when dealing with generics. Its most useful applications involve type checks and casts, making the code more concise and easier to read.</p>
<div class="blog_h2"><span class="graybg">Variance</span></div>
<p>In the context of programming languages that support generics (such as Kotlin, Java, Scala, etc.), variance  is a concept that describes how subtyping between more complex types (like generics or parameterized types) relates to subtyping between their components (like their type parameters).</p>
<p>Specifically, variance determines whether one generic type can be considered a subtype or supertype of another generic type based on their type parameters. For example, if you have two types <pre class="crayon-plain-tag">A</pre> and <pre class="crayon-plain-tag">B</pre> where A is a subtype of B, variance answers the question: is <pre class="crayon-plain-tag">Box</pre> a subtype of <pre class="crayon-plain-tag">Box<b></b></pre>?</p>
<div class="blog_h3"><span class="graybg">Covariance</span></div>
<p>The <pre class="crayon-plain-tag">out</pre> keyword in Kotlin indicates covariance (协变). Covariance allows a generic type to preserve the subtype relationship of its type parameters. <span style="background-color: #c0c0c0;">If type A is a subtype of type B, then <pre class="crayon-plain-tag">Box</pre> will be a subtype of <pre class="crayon-plain-tag">Box<b></b></pre> if the generic type is covariant</span>. <span style="background-color: #c0c0c0;">Covariance allows you to read values from a generic type but restricts you from modifying it</span>.</p>
<pre class="crayon-plain-tag">// Define a covariant Box class
class Box(val value: T)

fun main() {
    val intBox: Box = Box(42)
    val anyBox: Box = intBox  // This is allowed due to covariance (Int is a subtype of Any)

    // You can safely read from anyBox
    println(anyBox.value)  // Output: 42

    // However, you cannot modify anyBox because it's covariant
    // anyBox.value = "Hello"  // Error: Val cannot be reassigned
}</pre>
<p>Here, Box is covariant in <pre class="crayon-plain-tag">T</pre>. If <pre class="crayon-plain-tag">Int</pre> is a subtype of <pre class="crayon-plain-tag">Any</pre>, then <pre class="crayon-plain-tag">Box</pre> is considered a subtype of <pre class="crayon-plain-tag">Box</pre>, because you can safely read an <pre class="crayon-plain-tag">Any</pre> from a <pre class="crayon-plain-tag">Box</pre>.</p>
<p>Think of covariant types as producers. For example, a <pre class="crayon-plain-tag">Box</pre> can produce Strings, and since <pre class="crayon-plain-tag">String</pre> is a subtype of <pre class="crayon-plain-tag">Any</pre>, a <pre class="crayon-plain-tag">Box</pre> can also be treated as a <pre class="crayon-plain-tag">Box</pre>(since you can read values of type <pre class="crayon-plain-tag">Any</pre> from it).</p>
<div class="blog_h3"><span class="graybg">Contravariance </span></div>
<p>Contravariance is the opposite of covariance. If type <pre class="crayon-plain-tag">A</pre> is a subtype of type <pre class="crayon-plain-tag">B</pre>, then <pre class="crayon-plain-tag">Box<b></b></pre> will be a subtype of <pre class="crayon-plain-tag">Box</pre>if the generic type is contravariant. Contravariance allows you to write values to a generic type but restricts you from reading them.</p>
<pre class="crayon-plain-tag">// Define a contravariant Box class
class Box {
    fun setValue(value: T) {
        println("Setting value: $value")
    }
}

fun main() {
    val anyBox: Box = Box()
    val stringBox: Box = anyBox  // This is allowed due to contravariance (String is a subtype of Any)

    // You can safely write a String to anyBox, because it's contravariant
    stringBox.setValue("Hello, World!")  // Output: Setting value: Hello, World!

    // However, you cannot read from stringBox because it's contravariant
    // val value: String = stringBox.value  // Error: Cannot read from a contravariant type
}</pre>
<p>In this example, <pre class="crayon-plain-tag">Box</pre> is contravariant. If <pre class="crayon-plain-tag">String</pre> is a subtype of <pre class="crayon-plain-tag">Any</pre>, then <pre class="crayon-plain-tag">Box</pre> is considered a subtype of <pre class="crayon-plain-tag">Box</pre>, because you can safely write a <pre class="crayon-plain-tag">String</pre> into a <pre class="crayon-plain-tag">Box</pre> , but you cannot safely read from it since you don’t know the exact type.</p>
<p>Think of contravariant types as consumers. A <pre class="crayon-plain-tag">Box</pre> can accept any type, so it can accept a <pre class="crayon-plain-tag">String</pre>. Hence, it can be treated as a <pre class="crayon-plain-tag">Box</pre>.</p>
<div class="blog_h3"><span class="graybg">Invariance </span></div>
<p>Invariance means that there is no relationship between the subtyping of the type parameters and the subtyping of the generic types themselves. In other words, if type <pre class="crayon-plain-tag">A</pre> is a subtype of type <pre class="crayon-plain-tag">B</pre>, there is no relationship between <pre class="crayon-plain-tag">Box</pre> and <pre class="crayon-plain-tag">Box<b></b></pre>. They are considered independent.Invariant types are neither covariant nor contravariant; they are strict about the type they accept.</p>
<pre class="crayon-plain-tag">// Define an invariant Box class
class Box(val value: T)

fun main() {
    val intBox: Box = Box(42)
    // val anyBox: Box = intBox  // Error: Type mismatch

    // You can both read and write values, but they must be of the exact type
    val anotherIntBox: Box = Box(100)
    println(anotherIntBox.value)  // Output: 100

    // If we had a setter (not shown here), it would only accept Int
    // anotherIntBox.setValue("Hello")  // Error: Type mismatch
}</pre>
<p>Here, <pre class="crayon-plain-tag">Box</pre> is invariant. You cannot pass a <pre class="crayon-plain-tag">Box</pre> to a function that expects a <pre class="crayon-plain-tag">Box</pre> , even though <pre class="crayon-plain-tag">Int</pre> is a subtype of <pre class="crayon-plain-tag">Any</pre>.</p>
<p>Invariant types are both producers and consumers of their exact type. You can both read and write values, but they must always be of the specific type <pre class="crayon-plain-tag">T</pre>. There is no flexibility in treating it as another type.</p>
<div class="blog_h1"><span class="graybg">Collections and Functional Operations </span></div>
<p>Collections are fundamental data structures that allow developers to store and manipulate groups of objects. Kotlin provides a rich set of collection APIs and functional operations that enhance productivity and code readability. In this section, we'll explore Kotlin's collections, compare them with Java's collection framework, and delve into functional operations that simplify common programming tasks.</p>
<div class="blog_h2"><span class="graybg">Overview of Collections in Kotlin</span></div>
<p> Kotlin collections are divided into two categories:</p>
<ol>
<li>Immutable Collections: Read-only collections that cannot be modified after creation.</li>
<li>Mutable Collections: Collections that can be modified, allowing addition, removal, and updating of elements.</li>
</ol>
<div class="blog_h3"><span class="graybg">Immutable Collections</span></div>
<p>Immutable collections are preferred in functional programming paradigms as they prevent accidental modification and promote thread safety. </p>
<p>Common Immutable Collection Types:</p>
<ol>
<li>List: Ordered collection of elements.</li>
<li>Set: Unordered collection of unique elements.</li>
<li>Map: Collection of key-value pairs.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">val numbers: List = listOf(1, 2, 3)</pre>
<div class="blog_h3"><span class="graybg">Mutable Collections </span></div>
<p>Mutable collections can be modified after creation.</p>
<p>Common Mutable Collection Types:</p>
<ol>
<li>MutableList</li>
<li>MutableSet</li>
<li>MutableMap</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">val mutableNumbers: MutableList = mutableListOf(1, 2, 3)
mutableNumbers.add(4)</pre>
<div class="blog_h3"><span class="graybg">Creating Collections</span></div>
<pre class="crayon-plain-tag">val fruits = listOf("Apple", "Banana", "Cherry")

val mutableFruits = mutableListOf("Apple", "Banana")
mutableFruits.add("Cherry")


val numbers = setOf(1, 2, 3, 2)
println(numbers) // Output: [1, 2, 3]

val mutableNumbers = mutableSetOf(1, 2, 3)
mutableNumbers.add(4)


val countryCodes = mapOf("US" to "United States", "CA" to "Canada")

val mutableCountryCodes = mutableMapOf("US" to "United States")
mutableCountryCodes["CA"] = "Canada"</pre>
<div class="blog_h2"><span class="graybg">Functional Operations on Collections</span></div>
<p>Kotlin provides a plethora of functional operations that make working with collections more expressive and concise. These operations are inspired by functional programming paradigms.</p>
<div class="blog_h3"><span class="graybg">Common Functional Operations</span></div>
<ol style="list-style-type: undefined;">
<li>map: Transforms each element.<br />
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3)
val squares = numbers.map { it * it }
println(squares) // Output: [1, 4, 9] </pre>
</li>
<li>filter: Filters elements based on a predicate.<br />
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3, 4, 5)
val evenNumbers = numbers.filter { it % 2 == 0 }
println(evenNumbers) // Output: [2, 4]</pre>
</li>
<li>reduce: Reduces the collection to a single value.<br />
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3, 4)
val sum = numbers.reduce { acc, num -&gt; acc + num }
println(sum) // Output: 10 </pre>
</li>
<li>fold: Similar to reduce but with an initial value.<br />
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3, 4)
val product = numbers.fold(1) { acc, num -&gt; acc * num }
println(product) // Output: 24</pre>
</li>
<li>forEach: Performs an action on each element.</li>
<li>groupBy: Groups elements by a key.<br />
<pre class="crayon-plain-tag">data class Person(val name: String, val city: String)

val people = listOf(
    Person("Alice", "New York"),
    Person("Bob", "Paris"),
    Person("Charlie", "New York")
)

val peopleByCity = people.groupBy { it.city }
println(peopleByCity)
// Output:
// {
//   New York=[Person(name=Alice, city=New York), Person(name=Charlie, city=New York)],
//   Paris=[Person(name=Bob, city=Paris)]
// }</pre>
</li>
<li>flatMap: Maps each element to a collection and flattens the results.<br />
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3)
val expandedNumbers = numbers.flatMap { listOf(it, it * 10) }
println(expandedNumbers) // Output: [1, 10, 2, 20, 3, 30]</pre>
</li>
<li>partition: Splits the collection into two based on a predicate.</li>
</ol>
<div class="blog_h2"><span class="graybg">Sequences</span></div>
<p>Sequences are lazily evaluated collections in Kotlin. They are useful when working with large datasets or when the operations are computationally intensive.</p>
<p>You can create a sequence from a collection:</p>
<pre class="crayon-plain-tag">val numbers = listOf(1, 2, 3, 4, 5)
val numberSequence = numbers.asSequence()</pre>
<p>Or generate one:</p>
<pre class="crayon-plain-tag">val naturalNumbers = generateSequence(1) { it + 1 }
val firstTenNumbers = naturalNumbers.take(10).toList()
println(firstTenNumbers) // Output: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]</pre>
<p>Example of using a sequence:</p>
<pre class="crayon-plain-tag">// Even though we start with a range of 1 to 1,000,000, only the necessary computations
// are performed due to lazy evaluation.
val numbers = (1..1_000_000).asSequence()
    .map { it * 2 }
    .filter { it % 3 == 0 }
    .take(10)
    .toList()
println(numbers) // Output: [6, 12, 18, 24, 30, 36, 42, 48, 54, 60]</pre>
<div class="blog_h3"><span class="graybg">Benefits of Sequences</span></div>
<ol>
<li>Lazy Evaluation: Operations are evaluated as needed, which can improve performance.</li>
<li>Avoids Intermediate Collections: Reduces memory overhead. </li>
</ol>
<div class="blog_h2"><span class="graybg">Collection Builders</span></div>
<p>Kotlin provides collection builders for creating collections in a functional style.</p>
<pre class="crayon-plain-tag">val numbers = buildList {
    add(1)
    add(2)
    addAll(listOf(3, 4, 5))
}
println(numbers) // Output: [1, 2, 3, 4, 5]</pre>
<div class="blog_h2"><span class="graybg">Parallel Processing</span></div>
<p>Kotlin sequences are not inherently parallel. However, Kotlin coroutines can be used for asynchronous and parallel operations. For example:</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers

fun main() = runBlocking {
    val numbers = (1..5).toList()
    val squares = numbers.map { number -&gt;
        async(Dispatchers.Default) {
            number * number
        }
    }.awaitAll()
    println(squares) // Output: [1, 4, 9, 16, 25]
}</pre>
<div class="blog_h2"><span class="graybg">Comparison with Java Collections</span></div>
<p>Java's collection framework is robust and has evolved over time, especially with the introduction of Streams in Java 8.</p>
<div class="blog_h3"><span class="graybg">Java Collections</span></div>
<ol>
<li>Immutable Collections: Introduced in Java 9 with List.of(), Set.of(), Map.of().</li>
<li>Mutable Collections: The standard ArrayList, HashSet, HashMap, etc.</li>
</ol>
<p>Example of Immutable List in Java:</p>
<pre class="crayon-plain-tag">List numbers = List.of(1, 2, 3);</pre>
<div class="blog_h3"><span class="graybg">Functional Operations in Java</span></div>
<p>Java 8 introduced Streams ( Also lazily loaded ) , which provide functional operations on collections.</p>
<pre class="crayon-plain-tag">List numbers = Arrays.asList(1, 2, 3, 4, 5);
List squares = numbers.stream()
    .map(n -&gt; n * n)
    .collect(Collectors.toList());
System.out.println(squares); // Output: [1, 4, 9, 16, 25]</pre>
<p>Sequences in Kotlin</p>
<ol style="list-style-type: undefined;">
<li>Similar to Java Streams but more integrated into the language.</li>
<li>No Need for Explicit Conversion: Collections can be seamlessly converted to sequences.</li>
</ol>
<p>Streams in Java</p>
<ol style="list-style-type: undefined;">
<li>Separate API: Requires conversion using stream() method.</li>
<li>Terminal Operations: Must perform a terminal operation to execute the stream pipeline.</li>
</ol>
<div class="blog_h1"><span class="graybg">Interop Between Kotlin and Java</span></div>
<p>One of the key strengths of Kotlin is its seamless interoperability with Java. This means you can:</p>
<ol>
<li>Call Kotlin code from Java.</li>
<li>Call Java code from Kotlin.</li>
<li>Use existing Java libraries and frameworks in Kotlin projects.</li>
<li>Migrate codebases incrementally from Java to Kotlin.</li>
</ol>
<p>In this section, we'll explore how Kotlin and Java interoperate, covering various aspects such as calling functions, handling nullability, working with classes, exceptions, and more.</p>
<div class="blog_h2"><span class="graybg">Calling Kotlin from Java </span></div>
<div class="blog_h3"><span class="graybg">Basic Function Calls</span></div>
<p>Kotlin functions can be called from Java code without any special effort. </p>
<p>Kotlin Function:</p>
<pre class="crayon-plain-tag">// File: Utils.kt
package cc.gmem.utils

fun greet(name: String): String {
    return "Hello, $name!"
}</pre>
<p>Calling from Java:</p>
<pre class="crayon-plain-tag">import cc.gmem.utils.UtilsKt;

public class Main {
    public static void main(String[] args) {
        String message = UtilsKt.greet("Alice");
        System.out.println(message); // Output: Hello, Alice!
    }
}</pre>
<p>Explanation:</p>
<ol>
<li>By default, top-level functions in Kotlin are compiled into a class named after the file with the suffix Kt.</li>
<li>In this case, the class is UtilsKt, and the function greet is a static method. </li>
</ol>
<div class="blog_h3"><span class="graybg">Using @JvmName Annotation </span></div>
<p>You can customize the generated class name using the @file:JvmName annotation.</p>
<pre class="crayon-plain-tag">@file:JvmName("UtilFunctions")
package cc.gmem.utils

fun greet(name: String): String {
    return "Hello, $name!"
}</pre>
<p> For the function above you can call it from Java:</p>
<pre class="crayon-plain-tag">import cc.gmem.utils.UtilFunctions;

public class Main {
    public static void main(String[] args) {
        String message = UtilFunctions.greet("Alice");
        System.out.println(message);
    }
}</pre>
<div class="blog_h3"><span class="graybg">Static Methods and Companion Objects</span></div>
<p>Kotlin's companion objects can be used to expose static members to Java.</p>
<pre class="crayon-plain-tag">class MathUtils {
    companion object {
        fun add(a: Int, b: Int): Int {
            return a + b
        }
    }
}</pre>
<p> For the function above you can call it from Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        int sum = MathUtils.Companion.add(5, 3);
        System.out.println(sum); // Output: 8
    }
}</pre>
<div class="blog_h3"><span class="graybg">Using @JvmStatic </span></div>
<p>To make the method appear as a static method in Java, use the @JvmStatic annotation.</p>
<pre class="crayon-plain-tag">class MathUtils {
    companion object {
        @JvmStatic
        fun add(a: Int, b: Int): Int {
            return a + b
        }
    }
}</pre>
<p>Calling from Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        int sum = MathUtils.add(5, 3);
        System.out.println(sum);
    }
}</pre>
<div class="blog_h3"><span class="graybg">Kotlin Properties</span></div>
<p>Kotlin properties are compiled to getter and setter methods in Java.</p>
<pre class="crayon-plain-tag">class Person(var name: String, val age: Int)</pre>
<p>Accessing from Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        Person person = new Person("Alice", 30);
        System.out.println(person.getName()); // Getter for 'name'
        person.setName("Bob"); // Setter for 'name'
        System.out.println(person.getAge()); // Getter for 'age' (no setter since it's 'val')
    }
}</pre>
<div class="blog_h3"><span class="graybg">Handling Nullability</span></div>
<p>Kotlin's null safety affects how types are represented in Java.</p>
<pre class="crayon-plain-tag">fun getName(): String? {
    return null
}</pre><br />
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        String name = UtilsKt.getName();
        if (name != null) {
            System.out.println(name.length());
        } else {
            System.out.println("Name is null");
        }
    }
}</pre>
<div class="blog_h2"><span class="graybg">Calling Java from Kotlin</span></div>
<p>Kotlin can seamlessly use existing Java classes and methods.</p>
<div class="blog_h3"><span class="graybg">Basic Class Usage</span></div>
<pre class="crayon-plain-tag">public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}</pre>
<p>Using in Kotlin:</p>
<pre class="crayon-plain-tag">fun main() {
    val calculator = Calculator()
    val sum = calculator.add(5, 3)
    println(sum) // Output: 8
}</pre>
<div class="blog_h3"><span class="graybg">Static Members </span></div>
<pre class="crayon-plain-tag">public class MathUtils {
    public static int multiply(int a, int b) {
        return a * b;
    }
}</pre>
<p>Using in Kotlin: </p>
<pre class="crayon-plain-tag">fun main() {
    val product = MathUtils.multiply(4, 5)
    println(product) // Output: 20
}</pre>
<div class="blog_h3"><span class="graybg">Nullability and Platform Types </span></div>
<p>When calling Java code from Kotlin, types are treated as platform types, which can be nullable or non-nullable.</p>
<pre class="crayon-plain-tag">public String getName() {
    return null;
}</pre>
<p>Using in Kotlin:</p>
<pre class="crayon-plain-tag">fun main() {
    val name = getName()
    println(name.length) // May throw NullPointerException
    val name1: String? = getName() // Safe: Allows null
}</pre>
<p>Since getName() is a platform type, Kotlin does not enforce null checks. It's up to the developer to handle potential nulls. </p>
<div class="blog_h3"><span class="graybg">Handling Checked Exceptions</span></div>
<p>Kotlin does not have checked exceptions. When calling Java methods that throw checked exceptions, you need to handle them manually.</p>
<pre class="crayon-plain-tag">public void readFile(String path) throws IOException {
    // ...
}</pre>
<p>Using in Kotlin:</p>
<pre class="crayon-plain-tag">fun main() {
    try {
        readFile("file.txt")
    } catch (e: IOException) {
        e.printStackTrace()
    }
}</pre>
<p>Please note: You need to catch the exception explicitly; the compiler does not enforce it.</p>
<div class="blog_h2"><span class="graybg">Java Annotations and Kotlin</span></div>
<div class="blog_h3"><span class="graybg">@NotNull and @Nullable</span></div>
<p>Kotlin recognizes Java's nullability annotations to improve null safety.</p>
<pre class="crayon-plain-tag">public @Nullable String getName() {
    return null;
}</pre>
<p>Using in Kotlin: </p>
<pre class="crayon-plain-tag">fun main() {
    val name = getName()
    if (name != null) {
        println(name.length)
    } else {
        println("Name is null")
    }
}</pre>
<p>Kotlin treats @Nullable types as nullable. </p>
<div class="blog_h3"><span class="graybg">@JvmOverloads</span></div>
<p>Kotlin functions with default parameters can generate overloads for Java using @JvmOverloads.</p>
<pre class="crayon-plain-tag">class Greeter {
    @JvmOverloads
    fun greet(name: String = "Guest") {
        println("Hello, $name!")
    }
}</pre>
<p>Calling from Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        Greeter greeter = new Greeter();
        greeter.greet();          // Calls greet() with default parameter
        greeter.greet("Alice");   // Calls greet(String)
    }
}</pre>
<div class="blog_h3"><span class="graybg">@JvmField</span></div>
<p>By default, Kotlin properties are accessed via getters and setters. To expose a public field directly to Java, use @JvmField.</p>
<pre class="crayon-plain-tag">class Constants {
    @JvmField
    val MAX_COUNT = 100
}</pre>
<p> Accessing from Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        int max = Constants.MAX_COUNT;
        System.out.println(max); // Output: 100
    }
}</pre>
<div class="blog_h2"><span class="graybg">Annotation Processing</span></div>
<p>Kotlin supports Java's annotation processing tools.</p>
<ol>
<li>Using Annotations: You can use annotations like @Entity, @Autowired, etc., in Kotlin classes.</li>
<li>Annotation Processors: Tools like Dagger, Hibernate, and Spring Data work with Kotlin code.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">@Entity
data class User(
    @Id val id: Long,
    val name: String
)</pre>
<div class="blog_h2"><span class="graybg">Differences in Language Features </span></div>
<div class="blog_h3"><span class="graybg">SAM Conversions</span></div>
<p>About Single Abstract Method (SAM) Interfaces:</p>
<ol>
<li>In Java, functional interfaces can be implemented using lambda expressions.</li>
<li>Kotlin supports SAM conversions for Java interfaces but not for Kotlin interfaces.</li>
</ol>
<pre class="crayon-plain-tag">public interface Runnable {
    void run();
}</pre>
<p>Using in Kotlin: </p>
<pre class="crayon-plain-tag">fun main() {
    // You can pass a lambda to a Java SAM interface in Kotlin.
    val runnable = Runnable { println("Running") }
    runnable.run()
}</pre>
<div class="blog_h3"><span class="graybg">Kotlin Data Classes in Java</span></div>
<p>Kotlin data classes generate equals(), hashCode(), toString(), and copy() methods.</p>
<p>Kotlin Data Class:</p>
<pre class="crayon-plain-tag">data class User(val name: String, val age: Int)</pre>
<p>Using in Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        User user = new User("Alice", 30);
        System.out.println(user.getName()); // Access properties via getters
        System.out.println(user); // Calls toString()
    }
}</pre>
<p>Plese note that the <pre class="crayon-plain-tag">copy()</pre> function is not directly accessible in Java.</p>
<div class="blog_h3"><span class="graybg">Type Aliases</span></div>
<p>Kotlin's typealias declarations are not visible in Java.</p>
<pre class="crayon-plain-tag">typealias StringMap = Map&lt;String, String&gt;

fun getMap(): StringMap {
    return mapOf("key" to "value")
}</pre>
<p>Java usage:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        Map&lt;String, String&gt; map = UtilsKt.getMap();
        System.out.println(map);
    }
}</pre>
<div class="blog_h3"><span class="graybg">Extension Functions</span></div>
<p>Kotlin extension functions are not directly accessible from Java.</p>
<p>Kotlin Extension Function:</p>
<pre class="crayon-plain-tag">fun String.isPalindrome(): Boolean {
    return this == this.reversed()
}</pre>
<p>Using in Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        String word = "level";
        // Extension functions are compiled into static methods with the receiver type as the first parameter
        boolean result = StringExtensionsKt.isPalindrome(word);
        System.out.println(result); // Output: true
    }
}</pre>
<div class="blog_h3"><span class="graybg">Coroutines Interoperability</span></div>
<p>Kotlin coroutines can be used from Java code, but it's more complex. </p>
<p>Kotlin Suspended Function:</p>
<pre class="crayon-plain-tag">suspend fun fetchData(): String {
    delay(1000L)
    return "Data"
}</pre>
<p>Using in Java:</p>
<pre class="crayon-plain-tag">public class Main {
    public static void main(String[] args) {
        // Calling suspend functions from Java requires handling Continuation, making it less straightforward.
        Continuation continuation = new Continuation() {
            @Override
            public CoroutineContext getContext() {
                return EmptyCoroutineContext.INSTANCE;
            }

            @Override
            public void resumeWith(Object result) {
                if (result instanceof Result) {
                    Result res = (Result) result;
                    System.out.println(res.getOrNull());
                }
            }
        };

        FetchDataKt.fetchData(continuation);
    }
}</pre>
<div class="blog_h1"><span class="graybg">Practical Applications</span></div>
<p>Having explored various advanced features of Kotlin, including coroutines, delegation, generics and variance, collections, and interoperability with Java, let's look at how these features are applied in real-world scenarios.</p>
<div class="blog_h2"><span class="graybg">Android Development with Kotlin</span></div>
<div class="blog_h3"><span class="graybg">Kotlin as the Preferred Language for Android</span></div>
<p>Kotlin is officially supported by Google as the preferred language for Android app development. It offers concise syntax, null safety, and powerful features that improve developer productivity.</p>
<div class="blog_h3"><span class="graybg">Using Coroutines for Asynchronous Tasks</span></div>
<p>Problem: Android apps often perform operations that can block the main thread, such as network requests or database operations. Blocking the main thread can lead to a poor user experience.</p>
<p>Solution with Coroutines: Use coroutines to perform asynchronous tasks without blocking the main thread.</p>
<p>Example:</p>
<pre class="crayon-plain-tag">class MainActivity : AppCompatActivity() {

    private val viewModel: DataViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Launches a coroutine tied to the lifecycle of the activity.
        lifecycleScope.launch {
            val data = viewModel.fetchData()
            updateUI(data)
        }
    }

    private fun updateUI(data: String) {
        // Update UI elements with the fetched data
    }
}

class DataViewModel : ViewModel() {
    // Switches the coroutine context to a background thread for I/O operations.
    suspend fun fetchData(): String = withContext(Dispatchers.IO) {
        // Simulate network call
        delay(1000)
        "Data from server"
    }
}</pre>
<div class="blog_h3"><span class="graybg">Leveraging Extensions and Delegation </span></div>
<p>Problem: Reducing boilerplate code and improving code organization in Android applications.</p>
<p>Solution with Extensions and Delegation</p>
<ol>
<li>Extensions: Add utility functions to existing classes without inheritance.</li>
<li>Delegation: Use property delegation for shared preferences.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">// Extension function for Toast messages
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
    Toast.makeText(this, message, duration).show()
}

// Usage in an Activity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        showToast("Welcome to the app!")
    }
}

// Delegated property for SharedPreferences
class UserPreferences(context: Context) {
    private val prefs: SharedPreferences by lazy {
        context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
    }

    var userName: String?
        get() = prefs.getString("user_name", null)
        set(value) = prefs.edit().putString("user_name", value).apply()
}</pre>
<div class="blog_h3"><span class="graybg">Handling Null Safety and Interoperability</span></div>
<p>Problem: Dealing with null references and integrating Java libraries in Android apps.</p>
<p>Solution:</p>
<ol>
<li>Utilize Kotlin's null safety features to prevent crashes.</li>
<li>Interoperate with Java libraries seamlessly.</li>
</ol>
<p>Example:</p>
<pre class="crayon-plain-tag">// Using null safety
val intentData: String? = intent.getStringExtra("data")
intentData?.let {
    // Use the data safely
}

// Interoperating with a Java library
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val date = dateFormat.parse("2023-10-01")</pre>
<div class="blog_h2"><span class="graybg">Server-Side Development</span></div>
<div class="blog_h3"><span class="graybg">Building RESTful APIs with Ktor</span></div>
<p>Ktor is an asynchronous framework for building microservices and web applications.</p>
<pre class="crayon-plain-tag">import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/hello") {
                call.respondText("Hello, World!", ContentType.Text.Plain)
            }
            post("/data") {
                val data = call.receive()
                call.respondText("Received: $data", ContentType.Text.Plain)
            }
        }
    }.start(wait = true)
}</pre>
<div class="blog_h3"><span class="graybg">Database Access with Exposed</span></div>
<pre class="crayon-plain-tag">// Define the table
object Users : Table() {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 50)
    val age = integer("age")
    override val primaryKey = PrimaryKey(id)
}

// Perform database operations
transaction {
    // Insert a new user
    Users.insert {
        it[name] = "Alice"
        it[age] = 30
    }

    // Query users
    val userList = Users.selectAll().map {
        it[Users.name] to it[Users.age]
    }
}</pre>
<div class="blog_h3"><span class="graybg">Concurrency with Coroutines </span></div>
<p>Leverage coroutines for high-throughput server applications. Example:</p>
<pre class="crayon-plain-tag">suspend fun handleRequest(request: Request): Response = coroutineScope {
    val data = async { fetchDataFromDatabase() }
    val computation = async { performComputation() }

    // Wait for both tasks to complete
    Response(data.await(), computation.await())
}</pre>
<div class="blog_h3"><span class="graybg">Testing and Mocking in Kotlin</span></div>
<p>mockk library can be used for mocking:</p>
<pre class="crayon-plain-tag">class UserServiceTest {

    private val repository = mockk()
    private val userService = UserService(repository)

    //  In Kotlin, you can use backticks (``)* to enclose function names, allowing you to include spaces, 
    //  special characters, or even keywords that are normally not allowed in regular function identifiers. 
    @Test
    fun `should return user when found`() = runBlocking {
        val user = User(1, "Alice")
        coEvery { repository.findUser(1) } returns user

        val result = userService.getUser(1)

        assertEquals(user, result)
        coVerify { repository.findUser(1) }
    }
}</pre>
<div class="blog_h2"><span class="graybg">Integrating Kotlin with Spring Framework</span></div>
<div class="blog_h3"><span class="graybg">Build Configuration</span></div>
<p>Gradle (build.gradle.kts)</p>
<pre class="crayon-plain-tag">plugins {
    id("org.springframework.boot") version "3.1.0"
    id("io.spring.dependency-management") version "1.1.0"
    kotlin("jvm") version "1.9.10"
    kotlin("plugin.spring") version "1.9.10"
    kotlin("plugin.jpa") version "1.9.10"
}

group = "cc.gmem"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    // Add other dependencies as needed
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.13.5")
}

tasks.withType {
    useJUnitPlatform()
}</pre>
<div class="blog_h3"><span class="graybg">Creating a RESTful Web Service</span></div>
<p>Application Entry Point:</p>
<pre class="crayon-plain-tag">package cc.gmem.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array) {
    runApplication(*args)
}</pre>
<div class="blog_h3"><span class="graybg">Creating a Controller</span></div>
<pre class="crayon-plain-tag">package cc.gmem.demo.controller

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController

data class Greeting(val id: Long, val content: String)

@RestController
class GreetingController {

    @GetMapping("/greeting/{name}")
    fun greeting(@PathVariable name: String): Greeting {
        return Greeting(id = 1, content = "Hello, $name!")
    }
}</pre>
<div class="blog_h3"><span class="graybg">Running the Application</span></div>
<pre class="crayon-plain-tag">./gradlew bootRun</pre>
<div class="blog_h3"><span class="graybg">Dependency Injection and Bean Configuration</span></div>
<p>Kotlin encourages constructor injection due to its concise syntax.</p>
<pre class="crayon-plain-tag">package cc.gmem.demo.service

import org.springframework.stereotype.Service

@Service
class UserService(private val userRepository: UserRepository) {

    fun findAllUsers(): List = userRepository.findAll()
}</pre>
<div class="blog_h3"><span class="graybg">Database Access with Spring Data JPA </span></div>
<p>Entity definition:</p>
<pre class="crayon-plain-tag">package cc.gmem.demo.model

import jakarta.persistence.*

@Entity
data class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val name: String,
    val email: String
)</pre>
<p>Repository interface:</p>
<pre class="crayon-plain-tag">package cc.gmem.demo.repository

import cc.gmem.demo.model.User
import org.springframework.data.jpa.repository.JpaRepository

interface UserRepository : JpaRepository&lt;User, Long&gt; {
    fun findByEmail(email: String): User?
}</pre>
<div class="blog_h3"><span class="graybg">Using the Repository in a Service</span></div>
<pre class="crayon-plain-tag">package cc.gmem.demo.service

import cc.gmem.demo.model.User
import cc.gmem.demo.repository.UserRepository
import org.springframework.stereotype.Service

@Service
class UserService(private val userRepository: UserRepository) {

    fun getUserByEmail(email: String): User? = userRepository.findByEmail(email)
}</pre>
<div class="blog_h3"><span class="graybg">Controller Endpoint</span></div>
<pre class="crayon-plain-tag">package cc.gmem.demo.controller

import cc.gmem.demo.model.User
import cc.gmem.demo.service.UserService
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/users")
class UserController(private val userService: UserService) {

    @GetMapping("/{email}")
    fun getUser(@PathVariable email: String): User? {
        return userService.getUserByEmail(email)
    }
}</pre>
<div class="blog_h3"><span class="graybg">Defining Beans with @Configuration</span></div>
<pre class="crayon-plain-tag">package cc.gmem.demo.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import cc.gmem.demo.service.CustomService

@Configuration
class AppConfig {

    @Bean
    fun customService(): CustomService = CustomService()
}</pre>
<div class="blog_h3"><span class="graybg">Asynchronous Request Handling </span></div>
<p>Spring Framework supports coroutines and reactive programming.</p>
<p>Add kotlinx-coroutines-reactor as a dependency:</p>
<pre class="crayon-plain-tag">implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")</pre>
<p>Using suspend Functions in Controllers:</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.delay
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class AsyncController {

    @GetMapping("/async")
    suspend fun getAsyncData(): String {
        delay(1000) // Simulate non-blocking delay
        return "Async Response"
    }
}</pre>
<div class="blog_h3"><span class="graybg">Testing Spring Applications in Kotlin</span></div>
<p>Writing Tests with JUnit 5:</p>
<pre class="crayon-plain-tag">package cc.gmem.demo.service

import cc.gmem.demo.model.User
import cc.gmem.demo.repository.UserRepository
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest

@SpringBootTest
class UserServiceTest {

    private val userRepository = mockk()
    private val userService = UserService(userRepository)

    @Test
    fun `should return user when email exists`() {
        val email = "test@example.com"
        val user = User(id = 1, name = "Test User", email = email)

        every { userRepository.findByEmail(email) } returns user

        val result = userService.getUserByEmail(email)

        assertEquals(user, result)
        verify { userRepository.findByEmail(email) }
    }
}</pre>
<div class="blog_h2"><span class="graybg">Spring Reactive in Kotlin</span></div>
<p>Spring WebFlux is the reactive web framework in the Spring ecosystem, designed for building non-blocking, event-driven applications. When combined with Kotlin's coroutines, Spring WebFlux provides an elegant, efficient way to handle asynchronous operations.</p>
<p>In this section, we'll explore how to set up reactive routes in Kotlin using Spring WebFlux and coroutines. We'll focus on declarative routing with <pre class="crayon-plain-tag">coRouter</pre>, handling asynchronous data flows, and writing clean, functional code in Kotlin.</p>
<div class="blog_h3"><span class="graybg">What is Reactive Programming?</span></div>
<p>Reactive programming is a programming paradigm focused on building asynchronous, non-blocking systems that are scalable and resilient. Unlike traditional blocking I/O, reactive programming allows your application to <span style="background-color: #c0c0c0;">handle a large number of requests efficiently by reacting to incoming data streams as they arrive, rather than waiting for blocking operations to complete</span>.</p>
<p>Spring WebFlux is the reactive counterpart of Spring MVC, and it is built on the Project Reactor library, which provides the core reactive API.</p>
<div class="blog_h3"><span class="graybg">Key Concepts in Spring WebFlux</span></div>
<p>Spring WebFlux introduces several new concepts for building reactive applications:</p>
<ul>
<li>Mono - Represents a single asynchronous value or an empty value.</li>
<li>Flux - Represents a stream of asynchronous values, zero or more.</li>
<li>Non-blocking I/O - WebFlux runs on top of a non-blocking I/O framework such as Netty or Undertow.</li>
<li>Coroutines - Kotlin’s coroutines allow us to write asynchronous, non-blocking code in a declarative style.</li>
</ul>
<p>In Kotlin, using coroutines makes reactive programming more intuitive by handling asynchronous tasks in a sequential, readable manner.</p>
<div class="blog_h3"><span class="graybg">Setting up Reactive Routes in Kotlin</span></div>
<p>One of the core features of Spring WebFlux is the ability to define reactive routes using Kotlin DSL with <pre class="crayon-plain-tag">coRouter</pre>. This allows for a clean, functional approach to route definition.</p>
<p>Here’s how you can define reactive routes:</p>
<pre class="crayon-plain-tag">import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.server.coRouter

@Configuration
class RoutesConfig {

    @Bean
    fun apiRouter(handler: ApiHandler) = coRouter {
        "/api".nest {
            GET("/items", handler::getAllItems)
            GET("/items/{id}", handler::getItemById)
            POST("/items", handler::createItem)
        }
    }
}</pre>
<p>In this example, the <pre class="crayon-plain-tag">coRouter</pre> function is used to define reactive routes in a Kotlin DSL style:</p>
<ul>
<li>The GET and POST methods define routes that map to specific paths, like <pre class="crayon-plain-tag">/items</pre> and <pre class="crayon-plain-tag">/items/{id}</pre>.</li>
<li><pre class="crayon-plain-tag">handler::getAllItems</pre> refers to handler functions that will process incoming requests asynchronously.</li>
<li><pre class="crayon-plain-tag">nest</pre> is used to group multiple routes under a common path, such as <pre class="crayon-plain-tag">/api</pre>.</li>
</ul>
<p>This approach replaces traditional annotation-based controllers with a more declarative and functional style, making the code more concise and readable.</p>
<div class="blog_h3"><span class="graybg">Kotlin Coroutines in WebFlux</span></div>
<p>Spring WebFlux, when used with Kotlin, leverages coroutines for asynchronous processing. This helps eliminate the complexity of callbacks and makes reactive code look more like traditional blocking code while retaining its non-blocking nature.</p>
<p>For instance, here’s a handler function written with coroutines:</p>
<pre class="crayon-plain-tag">import kotlinx.coroutines.reactor.awaitSingle
import org.springframework.web.reactive.function.server.ServerRequest
import org.springframework.web.reactive.function.server.ServerResponse

class ApiHandler(private val service: ItemService) {

    suspend fun getAllItems(request: ServerRequest): ServerResponse {
        val items = service.getAllItems().collectList().awaitSingle()
        return ServerResponse.ok().bodyValueAndAwait(items)
    }

    suspend fun getItemById(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id")
        val item = service.getItemById(id).awaitSingleOrNull()
        return item?.let { ServerResponse.ok().bodyValueAndAwait(it) }
            ?: ServerResponse.notFound().buildAndAwait()
    }

    suspend fun createItem(request: ServerRequest): ServerResponse {
        val newItem = request.awaitBody()
        val savedItem = service.saveItem(newItem).awaitSingle()
        return ServerResponse.ok().bodyValueAndAwait(savedItem)
    }
}

// Implementation of ItemService

import reactor.core.publisher.Flux
import reactor.core.publisher.Mono

data class Item(val id: String, val name: String, val price: Double)

interface ItemService {
    fun getAllItems(): Flux&lt;Item&gt;
    fun getItemById(id: String): Mono&lt;Item&gt;
    fun saveItem(item: Item): Mono&lt;Item&gt;
}

class ItemServiceImpl : ItemService {

    private val items = mutableListOf(
        Item("1", "Laptop", 999.99),
        Item("2", "Smartphone", 599.99),
        Item("3", "Tablet", 299.99)
    )

    override fun getAllItems(): Flux&lt;Item&gt; {
        return Flux.fromIterable(items)
    }

    override fun getItemById(id: String): Mono&lt;Item&gt; {
        val item = items.find { it.id == id }
        return if (item != null) {
            Mono.just(item)
        } else {
            Mono.empty()
        }
    }

    override fun saveItem(item: Item): Mono&lt;Item&gt; {
        items.add(item)
        return Mono.just(item)
    }
}</pre>
<p>In this code:</p>
<ul>
<li>awaitSingle() is used to await the result of a <pre class="crayon-plain-tag">Mono</pre> without blocking the thread.</li>
<li>suspend functions are used to declare that these functions will be executed asynchronously in a non-blocking manner using coroutines.</li>
<li>Handlers return a <pre class="crayon-plain-tag">ServerResponse</pre> object, which is a reactive response object in WebFlux.</li>
</ul>
<p>Kotlin’s coroutine support in WebFlux allows you to avoid callback hell and write non-blocking code in a sequential, easy-to-read style.</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/comprehensive-study-kotlin-java-developers">A Comprehensive Study of Kotlin for Java Developers</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/comprehensive-study-kotlin-java-developers/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>利用LangChain和语言模型交互</title>
		<link>https://blog.gmem.cc/langchain</link>
		<comments>https://blog.gmem.cc/langchain#comments</comments>
		<pubDate>Tue, 04 Jul 2023 02:53:33 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[AI]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=39335</guid>
		<description><![CDATA[<p>LangChain是什么 从名字上可以看出来，LangChain可以用来构建自然语言处理能力的链条。它是一个库，提供了统一的接口，来和各种语言模型进行交互。更重要的是，它支持使用插件（工具），让语言模型能够获取实时的知识。LangChain基于Python，如果你熟悉JavaScript/TypeScript，可以考虑使用LangChain.js。 具体来说，LangChain包括以下模块： Model I/O：提供针对语言模型的统一风格接口 数据连接（Data connection）：提供应用程序相关数据的接口 处理链（Chains）：用于构建针对模型、提示词、工具调用的流水线 代理（Agents）：自动选择适当工具解决问题 记忆（Memory）：跨越多次处理链调用，来保持程序状态信息 Callbacks：在处理链的中间步骤中进行回调 安装 [crayon-69d31392d6139997657859/] 快速参考 调用语言模型 以OpenAI为例，安装依赖： [crayon-69d31392d6141481749484/] 通过环境变量来设置API Key： [crayon-69d31392d6143781150829/] 如果需要通过代理来访问OpenAPI的API端点，参考下面的方式设置环境变量： [crayon-69d31392d6145784351836/] <a class="read-more" href="https://blog.gmem.cc/langchain">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/langchain">利用LangChain和语言模型交互</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">LangChain是什么</span></div>
<p>从名字上可以看出来，LangChain可以用来构建自然语言处理能力的链条。它是一个库，提供了统一的接口，来和各种语言模型进行交互。更重要的是，它支持使用插件（工具），让语言模型能够获取实时的知识。LangChain基于Python，如果你熟悉JavaScript/TypeScript，可以考虑使用LangChain.js。</p>
<p>具体来说，LangChain包括以下模块：</p>
<ol>
<li>Model I/O：提供针对语言模型的统一风格接口</li>
<li>数据连接（Data connection）：提供应用程序相关数据的接口</li>
<li>处理链（Chains）：用于构建针对模型、提示词、工具调用的流水线</li>
<li>代理（Agents）：自动选择适当工具解决问题</li>
<li>记忆（Memory）：跨越多次处理链调用，来保持程序状态信息</li>
<li>Callbacks：在处理链的中间步骤中进行回调</li>
</ol>
<div class="blog_h1"><span class="graybg">安装</span></div>
<pre class="crayon-plain-tag"># 最小化依赖
pip install langchain

# 支持主流大语言模型
pip install langchain[llms]

# 支持所有能力，注意，占用很大磁盘空间且较为缓慢
pip install langchain[all]</pre>
<div class="blog_h1"><span class="graybg">快速参考</span></div>
<div class="blog_h2"><span class="graybg">调用语言模型</span></div>
<p>以OpenAI为例，安装依赖：</p>
<pre class="crayon-plain-tag">pip install openai</pre>
<p>通过环境变量来设置API Key：</p>
<pre class="crayon-plain-tag">export OPENAI_API_KEY="..."</pre>
<p>如果需要通过代理来访问OpenAPI的API端点，参考下面的方式设置环境变量：</p>
<pre class="crayon-plain-tag">HTTP_PROXY=https://user:pswd@proxy.gmem.cc
HTTPS_PROXY=https://user:pswd@proxy.gmem.cc</pre>
<p>下面的代码，根据你提供的输入来预测输出，也就是进行问答：</p>
<pre class="crayon-plain-tag">from langchain.llms import OpenAI
# 温度用来控制随机性，温度越高输出越随机
llm = OpenAI(temperature=0.9)
print(llm.predict("Hey what's up?"))
# Nothing much, just trying to get some work done. How about you?</pre>
<div class="blog_h2"><span class="graybg">使用对话模型</span></div>
<p>对话模型（Chat Models）是语言模型的变体。语言模型的输入、输出是一段文本，<span style="background-color: #c0c0c0;">对话模型的输入则是一系列消息，这些消息构成了对话的上下文</span>。对话模型的输出，则是一个消息。</p>
<p>LangChain将消息分为不同的类型，它们有对应的Python类：</p>
<ol>
<li>AIMessage：表示从模型角度发出的消息（a message sent from the perspective of the AI）</li>
<li>HumanMessage：表示从最终用户角度发出的消息</li>
<li>SystemMessage：用于提出系统级别的指令，例如指示语言模型该如何格式化输出</li>
<li>ChatMessage：通用消息类型，可以指定一个role参数，从而等价于上述消息之一</li>
</ol>
<p>这个消息分类，也是源自OpenAI的ChatGPT，ChatGPT会根据消息类型的不同，使用不同的处理方式。</p>
<pre class="crayon-plain-tag">if __name__ == "__main__":
    from langchain.chat_models import ChatOpenAI
    from langchain.schema import (
        SystemMessage,
        HumanMessage
    )

    chat = ChatOpenAI(temperature=0)
    resp = chat.predict_messages([
        SystemMessage(content="Translate whatever I say into Japanese."),
        HumanMessage(content="I love coding"),
    ])
    print(resp.content) # 私はコーディングが大好きです。</pre>
<div class="blog_h2"><span class="graybg">提示词模板</span></div>
<p>语言模型具有很大的灵活性，我们将其集成到自己的应用程序中时，通常需要加以限制，不能让最终用户随意的提供输入。这种情况下，可以使用提示词模板。</p>
<p>具体来说，就是由应用程序作者提供提示词的主体部分，最终用户仅仅填充提示词中的占位符。假设我们开发一个翻译器，那么用户可能仅仅需要指定待翻译的文本、目标语言：</p>
<pre class="crayon-plain-tag">from langchain.chat_models import ChatOpenAI
from langchain.prompts import SystemMessagePromptTemplate, HumanMessagePromptTemplate, ChatPromptTemplate

if __name__ == "__main__":
    system_message_prompt = SystemMessagePromptTemplate.from_template(
        """
        Act as a sophisticated translator between multiple languages. You can
        detect the language of a text, and translate it into a different one that I specify.
        """
    )
    human_message_prompt = HumanMessagePromptTemplate.from_template(
        """
        translate this sentence into {language}: {text}
        """
    )
    chat_prompt = ChatPromptTemplate.from_messages(
        [system_message_prompt, human_message_prompt]
    )
    messages = chat_prompt.format_messages(language="Chinese", text="Hey what's up?")

    chat = ChatOpenAI(temperature=0.5, model_name="gpt-3.5-turbo")
    resp = chat.predict_messages(messages)
    print(resp.content) # 嗨，最近怎么样？</pre>
<div class="blog_h2"><span class="graybg">处理链</span></div>
<p>处理链可以连接模型、提示词，或者其它处理链。最简单的例子：连接语言模型和提示词：</p>
<pre class="crayon-plain-tag">from langchain import LLMChain

chain = LLMChain(llm=chat, prompt=chat_prompt)
resp: str = chain.run(language="Chinese", text="Hey what's up?")
print(resp)  # 嗨，最近怎么样？</pre>
<div class="blog_h2"><span class="graybg">代理</span></div>
<p>上面定义了一个非常简单的处理链：渲染提示模板、调用LLM的API。某些复杂的流程下，需要根据输入动态的选择操作。</p>
<p>代理（Agent）能够完成这种动态选择，它调用语言模型来分析输入，从而确定需要先做什么，再做什么。代理能够反复的选择、运行工具，直到获取最终答案。</p>
<p>加载一个代理需要以下要素：</p>
<ol>
<li>LLM或者Chat模型：这个模型提供自然语言理解能力，用于驱动代理</li>
<li>工具（集）：工具是执行特定任务的函数，例如进行Google搜索、数据库查找、执行Python REPL、调用其它处理链</li>
<li>代理的名称：这个名称表示使用的代理类。代理类使用参数化的提示词来调用模型，从而确定下一步该做什么</li>
</ol>
<p>例如serpapi这个工具，它能够发起搜索引擎检索：</p>
<pre class="crayon-plain-tag"># You should provide your serpapi key with environment variable SERPAPI_API_KEY

from langchain.agents import load_tools, initialize_agent, AgentType
from langchain.chat_models import ChatOpenAI

if __name__ == "__main__":
    llm = ChatOpenAI(temperature=0, model_name="gpt-3.5-turbo")
    tools = load_tools(["serpapi"], llm=llm)
    agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True)
    result = agent.run(
        """
        Who is the president of the United States? What did he do last week?
        """
    )
    print(result)</pre>
<p>Verbose设置为True能够输出处理链的整个决策过程：</p>
<p style="padding-left: 30px;">&gt; Entering new chain...<br />I need to find out who the current president is and what he did last week. I can use the search tool for this.<br />Action: Search<br />Action Input: "current president of the United States"<br />Observation: Joe Biden<br />Thought:I now know the current president of the United States. I can use the search tool again to find out what he did last week.<br />Action: Search<br />Action Input: "Joe Biden last week"<br />Observation: The Supreme Court on Friday struck down President Joe Biden's plan to cancel up to $20,000 of student debt for tens of millions of Americans, thwarting a major ...<br />Thought:I now know the final answer.<br />Final Answer: Joe Biden is the current president of the United States. Last week, the Supreme Court struck down his plan to cancel up to $20,000 of student debt.</p>
<p style="padding-left: 30px;">&gt; Finished chain.<br />Joe Biden is the current president of the United States. Last week, the Supreme Court struck down his plan to cancel up to $20,000 of student debt.</p>
<p>可以看到，代理把问题划分为两个子任务，并且通过搜索网络来解答问题。</p>
<div class="blog_h2"><span class="graybg">记忆</span></div>
<p>默认情况下处理链和代理是无状态的，很多情况下，我们需要依赖先前和模型的交互（作为上下文）。Memory模块提供了这种记住先前交互的机制，它支持基于最新的输入/输出来更新状态，或者基于已存储的状态，或者contextualize下一次输入。</p>
<p>Memory模块有多种实现，最简单的基于内存缓冲 —— 简单的将最近的几次输入/输出，作为当前输入的前缀：</p>
<pre class="crayon-plain-tag">from langchain.chains import ConversationChain
from langchain.chat_models import ChatOpenAI
from langchain.memory import ConversationBufferMemory
from langchain.prompts import SystemMessagePromptTemplate, ChatPromptTemplate, MessagesPlaceholder, \
    HumanMessagePromptTemplate

if __name__ == "__main__":
    llm = ChatOpenAI(temperature=0, model_name="gpt-3.5-turbo")
    prompt = ChatPromptTemplate.from_messages([
        SystemMessagePromptTemplate.from_template(
            """
            The following is a conversation between a human and an AI. The AI is talkative and 
            provides lots of specific details from its context. If the AI does not know the answer
            to a question, it truthfully says it doesn't know.
            """
        ),
        MessagesPlaceholder(variable_name="history"),
        HumanMessagePromptTemplate.from_template("{input}")
    ])
    memory = ConversationBufferMemory(return_messages=True)
    conversation = ConversationChain(memory=memory, prompt=prompt, llm=llm)
    print(conversation.predict(input="My name is Alex"))
    print(conversation.predict(input="What is my name?"))</pre>
<p>这种实现有个重要的缺点：可能随着对话历史的增长，占用空间越来越大，因而影响AI的性能（或者超过长度限制）。通常需要考虑pruning或者summarization以保证上下文完整的同时不影响性能。</p>
<div class="blog_h1"><span class="graybg">语言模型接口</span></div>
<div class="blog_h2"><span class="graybg">提示词</span></div>
<p>语言模型都是将文本作为输入的，这些文本就被称为提示词（Prompt）。</p>
<p>在语言模型应用程序中，提示词通常不是硬编码的，也不是让用户随意输入。一般情况下提示词由模板、样例、用户输入组成，LangChain提供了若干类用来构造提示词。</p>
<div class="blog_h3"><span class="graybg">提示词模板</span></div>
<p>模板用于可复用的构造提示词，它由静态文本和一系列占位符（输入变量）构成。模板内容可以包括：</p>
<ol>
<li>对语言模型的指令</li>
<li>帮助语言模型生成更好结果的若干示例</li>
<li>对语言模型的提问</li>
</ol>
<p>这里是一个最简单的例子：</p>
<pre class="crayon-plain-tag">from langchain import PromptTemplate


template = """/
You are a naming consultant for new companies.
What is a good name for a company that makes {product}?
"""

prompt = PromptTemplate.from_template(template)
prompt.format(product="colorful socks")</pre>
<p>上面的代码能够自动根据template中的占位符，来推断由哪些输入变量。手工声明变量的方式如下：</p>
<pre class="crayon-plain-tag">multiple_input_prompt = PromptTemplate(
    input_variables=["adjective", "content"], 
    template="Tell me a {adjective} joke about {content}."
)
multiple_input_prompt.format(adjective="funny", content="chickens")</pre>
<p>除了这种语言模型通用提示模板，还有针对对话模型的模板类：SystemMessagePromptTemplate、AIMessagePromptTemplate、HumanMessagePromptTemplate。它们的区别上文已经介绍过。一个或者多个这些模板，可以构成ChatPromptTemplate：</p>
<pre class="crayon-plain-tag">#             返回值是PromptValue
chat_prompt = ChatPromptTemplate.from_messages([system_message_prompt, human_message_prompt])

# 渲染模板为消息
chat_prompt.format_prompt(input_language="English", ... ).to_messages()</pre>
<div class="blog_h3"><span class="graybg">定制提示词模板</span></div>
<p>内置的提示词模板类（包括两种类型，一个是基于字符串的，一个是基于Chat的），都是根据输入变量，进行提示内容的格式化。</p>
<p>如果你需要以输入变量为键，从第三方（例如数据库，或者Feast这样的Feature Store）获取信息并生成提示词，可以选择自定义提示词模板。</p>
<p>这里以基于字符串的模板类为例来说明如何自定义提示词模板。我们常常会选择继承StringPromptTemplate，但是实际上，自定义提示词模板只需要满足两点：</p>
<ol>
<li>提供<pre class="crayon-plain-tag">input_variables</pre>属性，定义支持的输入变量的名字</li>
<li>提供<pre class="crayon-plain-tag">format</pre>方法，接收关键字参数，用于根据指定的输入变量来生成提示词</li>
</ol>
<p>下面这个模板的例子，以函数名为输入变量，生成让语言模型来解释函数用途的提示：</p>
<pre class="crayon-plain-tag">from langchain.prompts import StringPromptTemplate
from pydantic import BaseModel, validator


class FunctionExplainerPromptTemplate(StringPromptTemplate, BaseModel):
    @validator("input_variables")
    def validate_input_variables(cls, v):
        """Validate that the input variables are correct."""
        if len(v) != 1 or "function_name" not in v:
            raise ValueError("function_name must be the only input_variable.")
        return v

    def format(self, **kwargs) -&gt; str:
        # Get the source code of the function
        source_code = get_source_code(kwargs["function_name"])

        # Generate the prompt to be sent to the language model
        prompt = f"""
        Given the function name and source code, generate an English language explanation of the function.
        Function Name: {kwargs["function_name"].__name__}
        Source Code:
        {source_code}
        Explanation:
        """
        return prompt

    def _prompt_type(self):
        return "function-explainer"



fn_explainer = FunctionExplainerPromptTemplate(input_variables=["function_name"])
prompt = fn_explainer.format(function_name=get_source_code)
print(prompt)</pre>
<div class="blog_h3"><span class="graybg">少量样本提示词模板</span></div>
<p>所谓少量样本（Few-shot）是指给语言模型提供几个例子（shots），以使其能够更好的理解你的意图并生成更好的响应。</p>
<p>样本可以说明期望的输出格式、得到最终输出的途径等等，其形式是比较自由的，语言模型能够理解其中的意图。</p>
<p>下面是一个样本集的例子：</p>
<pre class="crayon-plain-tag">from langchain.prompts.few_shot import FewShotPromptTemplate
from langchain.prompts.prompt import PromptTemplate

examples = [
  {
    "question": "Who lived longer, Muhammad Ali or Alan Turing?",
    "answer": 
"""
Are follow up questions needed here: Yes.
Follow up: How old was Muhammad Ali when he died?
Intermediate answer: Muhammad Ali was 74 years old when he died.
Follow up: How old was Alan Turing when he died?
Intermediate answer: Alan Turing was 41 years old when he died.
So the final answer is: Muhammad Ali
"""
  },
  {
    "question": "When was the founder of craigslist born?",
    "answer": 
"""
Are follow up questions needed here: Yes.
Follow up: Who was the founder of craigslist?
Intermediate answer: Craigslist was founded by Craig Newmark.
Follow up: When was Craig Newmark born?
Intermediate answer: Craig Newmark was born on December 6, 1952.
So the final answer is: December 6, 1952
"""
  }
  ...
]</pre>
<p>基于上述样本集生成提示：</p>
<pre class="crayon-plain-tag">prompt = FewShotPromptTemplate(
    examples=examples, 
    example_prompt=example_prompt, 
    suffix="Question: {input}", 
    input_variables=["input"]
)

print(prompt.format(input="Who was the father of Mary Ball Washington?"))</pre>
<p>最终生成的提示是这样的：</p>
<p style="padding-left: 30px;">Question: Who lived longer, Muhammad Ali or Alan Turing?</p>
<p style="padding-left: 30px;">Are follow up questions needed here: Yes.<br /> Follow up: How old was Muhammad Ali when he died?<br /> Intermediate answer: Muhammad Ali was 74 years old when he died.<br /> Follow up: How old was Alan Turing when he died?<br /> Intermediate answer: Alan Turing was 41 years old when he died.<br /> So the final answer is: Muhammad Ali</p>
<p style="padding-left: 30px;">Question: When was the founder of craigslist born?</p>
<p style="padding-left: 30px;">Are follow up questions needed here: Yes.<br /> Follow up: Who was the founder of craigslist?<br /> Intermediate answer: Craigslist was founded by Craig Newmark.<br /> Follow up: When was Craig Newmark born?<br /> Intermediate answer: Craig Newmark was born on December 6, 1952.<br /> So the final answer is: December 6, 1952</p>
<p style="padding-left: 30px;">Question: Who was the father of Mary Ball Washington?</p>
<p>我们可能会根据用户输入的语义，选择一个最匹配的样本，而不是把所有样本一股脑的发给语言模型。这时候，可以使用样本选择器。</p>
<p>向量存储用于保存Embeddings，所谓Embeddings，是指语言元素（例如单词、短语）在向量空间的数学表示（也就是多维向量）。向量存储支持进行语义相似度的判断，样本选择器依赖于向量存储的能力，来选取和输入最匹配的样本：</p>
<pre class="crayon-plain-tag">from langchain.prompts.example_selector import SemanticSimilarityExampleSelector
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings


example_selector = SemanticSimilarityExampleSelector.from_examples(
    # 候选样本列表
    examples,
    # 这是一个embedding class，用于生成embeddings
    OpenAIEmbeddings(),
    # 这是一个向量存储，用于存储embeddings并进行相似度搜索
    Chroma,
    # This is the number of examples to produce.
    k=1
)

# 选择相似的样本
question = "Who was the father of Mary Ball Washington?"
selected_examples = example_selector.select_examples({"question": question})</pre>
<p>FewShotPromptTemplate这个提示模板，支持传入一个样本选择器，生成最终提示词：</p>
<pre class="crayon-plain-tag">prompt = FewShotPromptTemplate(
    example_selector=example_selector, 
    example_prompt=example_prompt, 
    suffix="Question: {input}", 
    input_variables=["input"]
)

print(prompt.format(input="Who was the father of Mary Ball Washington?"))</pre>
<div class="blog_h3"><span class="graybg">对话模型和少量样本</span></div>
<p>目前LangChains没有为对话模型设计特别的抽象。在对话模型中，你只需要在输入消息中，提供交替的（Alternating）人类/AI消息，即可产生少量样本的效果：</p>
<pre class="crayon-plain-tag">template = "You are a helpful assistant that translates english to pirate."
system_message_prompt = SystemMessagePromptTemplate.from_template(template)
example_human = HumanMessagePromptTemplate.from_template("Hi")
example_ai = AIMessagePromptTemplate.from_template("Argh me mateys")
human_template = "{text}"
human_message_prompt = HumanMessagePromptTemplate.from_template(human_template)

chat_prompt = ChatPromptTemplate.from_messages(
    #                       交替的消息作为样本
    [system_message_prompt, example_human, example_ai, human_message_prompt]
)</pre>
<p>对于OpenAI来说，可以使用系统消息，并用可选的name属性来“标记”样本：</p>
<pre class="crayon-plain-tag">template = "You are a helpful assistant that translates english to pirate."
system_message_prompt = SystemMessagePromptTemplate.from_template(template)
example_human = SystemMessagePromptTemplate.from_template(
    "Hi", additional_kwargs={"name": "example_user"}  # 样本的人类消息
)
example_ai = SystemMessagePromptTemplate.from_template(
    "Argh me mateys", additional_kwargs={"name": "example_assistant"} # 样本的AI消息
)
human_template = "{text}"
human_message_prompt = HumanMessagePromptTemplate.from_template(human_template)</pre>
<div class="blog_h3"><span class="graybg">模板格式和输出</span></div>
<p>默认情况下，PromptTemplate的字符串，被看作是Python的f-string，可以通过template_format指定其它格式，目前仅仅支持jinja2模板：</p>
<pre class="crayon-plain-tag">jinja2_template = "Tell me a {{ adjective }} joke about {{ content }}"
prompt_template = PromptTemplate.from_template(template=jinja2_template, template_format="jinja2")</pre>
<p>调用提示词模板的format*方法，可以得到字符串、消息列表或者ChatPromptValue：</p>
<pre class="crayon-plain-tag"># 输出为str
output = chat_prompt.format(input_language="English", output_language="French", text="I love programming.")
output = chat_prompt.format_prompt(input_language="English", output_language="French", text="I love programming.").to_string()

# 输出为ChatPromptValue
chat_prompt.format_prompt(input_language="English", output_language="French", text="I love programming.")

# 输出为List[BaseMessage]
chat_prompt.format_prompt(input_language="English", output_language="French", text="I love programming.").to_messages()</pre>
<div class="blog_h3"><span class="graybg">部分渲染模板</span></div>
<p>LangChain允许仅部分渲染提示词模板 —— 传入输入变量的子集，生成一个新模板，新模板仅仅使用那些尚未渲染的变量。</p>
<pre class="crayon-plain-tag">from langchain.prompts import PromptTemplate

prompt = PromptTemplate(template="{foo}{bar}", input_variables=["foo", "bar"])
partial_prompt = prompt.partial(foo="foo");
print(partial_prompt.format(bar="baz"))


prompt = PromptTemplate(template="{foo}{bar}", input_variables=["bar"], partial_variables={"foo": "foo"})
print(prompt.format(bar="baz"))</pre>
<p>除了将直接量赋予输入变量，还可以使用函数，这种方式可以让每次进行部分渲染，得到不同结果：</p>
<pre class="crayon-plain-tag">from datetime import datetime

def _get_datetime():
    now = datetime.now()
    return now.strftime("%m/%d/%Y, %H:%M:%S")


prompt = PromptTemplate(
    template="Tell me a {adjective} joke about the day {date}", 
    input_variables=["adjective", "date"]
);
partial_prompt = prompt.partial(date=_get_datetime)



prompt = PromptTemplate(
    template="Tell me a {adjective} joke about the day {date}", 
    input_variables=["adjective"],
    partial_variables={"date": _get_datetime}
);</pre>
<div class="blog_h3"><span class="graybg">从文件加载提示词模板</span></div>
<p>LangChain支持从JSON或者YAML文件中加载提示词。你可以将模板、样本放在一个文件中，也可以放在多个文件中并相互引用。</p>
<p>考虑下面这个YAML形式的提示词模板：</p>
<pre class="crayon-plain-tag">_type: prompt
input_variables:
  ["adjective", "content"]
template:
  Tell me a {adjective} joke about {content}.</pre>
<p>我们可以这样加载它：</p>
<pre class="crayon-plain-tag">from langchain.prompts import load_prompt
prompt = load_prompt("simple_prompt.yaml")
print(prompt.format(adjective="funny", content="chickens"))</pre>
<p>注意，不管是YAML还是JSON，都用上述方式加载，没有区别。</p>
<p>考虑下面这个提示词模板，它引用了在外部文件中定义的模板文本：</p>
<pre class="crayon-plain-tag">{
  "_type": "prompt",
  "input_variables": ["adjective", "content"],
  "template_path": "simple_template.txt"
}</pre><br />
<pre class="crayon-plain-tag">Tell me a {adjective} joke about {content}.</pre>
<p>LangChain会自动到相同目录下寻找txt文件。加载方式和上文一致。</p>
<p>除了PromptTemplate，我们也可用相同的方式加载FewShotPromptTemplate。下面这个例子，将少量样本放在独立文件中：</p>
<pre class="crayon-plain-tag">[
  {"input": "happy", "output": "sad"},
  {"input": "tall", "output": "short"}
]</pre><br />
<pre class="crayon-plain-tag"># 这个标注模板类型
_type: few_shot
input_variables:
  ["adjective"]
# 提示词前缀
prefix:
  Write antonyms for the following words.
# 样本模板
example_prompt:
  _type: prompt
  input_variables:
    ["input", "output"]
  template:
    "Input: {input}\nOutput: {output}"
# 指定样本文件
examples:
  examples.json
# 提示词后缀
suffix:
  "Input: {adjective}\nOutput:"</pre>
<p>加载模板：</p>
<pre class="crayon-plain-tag">prompt = load_prompt("few_shot_prompt.yaml")
print(prompt.format(adjective="funny"))</pre>
<p>渲染得到的结果如下：</p>
<p style="padding-left: 30px;">Write antonyms for the following words.</p>
<p style="padding-left: 30px;">Input: happy<br /> Output: sad</p>
<p style="padding-left: 30px;">Input: tall<br /> Output: short</p>
<p style="padding-left: 30px;">Input: funny<br /> Output:</p>
<div class="blog_h2"><span class="graybg">样本选择器 </span></div>
<p>当有大量样本时， 可能需要选择一些最匹配的放入到提示中，这是需要用到样本选择器。样本选择器的基类如下：</p>
<pre class="crayon-plain-tag">class BaseExampleSelector(ABC):
    """Interface for selecting examples to include in prompts."""

    @abstractmethod
    def select_examples(self, input_variables: Dict[str, str]) -&gt; List[dict]:
        """Select which examples to use based on the inputs."""</pre>
<p>唯一需要实现的方法，入参是输入变量，结果是选中样本的列表。</p>
<div class="blog_h3"><span class="graybg">基于长度选择</span></div>
<p>LengthBasedExampleSelector基于长度即字符数量进行选择，当你担心提示词超过上下文窗口大小（Token限制）时有用。如果用户输入太长，该选择器会选取较小的样本：</p>
<pre class="crayon-plain-tag">from langchain.prompts import PromptTemplate
from langchain.prompts import FewShotPromptTemplate
from langchain.prompts.example_selector import LengthBasedExampleSelector

examples = [
    {"input": "happy", "output": "sad"},
    {"input": "tall", "output": "short"},
    {"input": "energetic", "output": "lethargic"},
    {"input": "sunny", "output": "gloomy"},
    {"input": "windy", "output": "calm"},

example_prompt = PromptTemplate(
    input_variables=["input", "output"],
    template="Input: {input}\nOutput: {output}",
)
example_selector = LengthBasedExampleSelector(
    # 可选择样本集
    examples=examples, 
    # 提示模板
    example_prompt=example_prompt, 
    # 格式化后样本的长度，长度使用get_text_length 函数计算
    max_length=25,
    get_text_length: Callable[[str], int] = lambda x: len(re.split("\n| ", x))
)
dynamic_prompt = FewShotPromptTemplate(
    example_selector=example_selector,
    example_prompt=example_prompt,
    prefix="Give the antonym of every input",
    suffix="Input: {adjective}\nOutput:", 
    input_variables=["adjective"],
)</pre>
<div class="blog_h3"><span class="graybg">基于MMR选择</span></div>
<p>所谓最大边际相关性 maximal marginal relevance (MMR)是一种信息检索和文本摘要的技术，用于在<span style="background-color: #c0c0c0;">保持查询相关性的同时，最大限度地减少结果集中的冗余信息</span>。MMR的目的是在结果集中提供尽可能多的新信息，以增加用户获取有价值信息的机会。</p>
<p>MMR算法通过在查询相关性和结果间的新颖性之间进行权衡来实现这一目标。对于每个候选结果，MMR会计算与查询的相关性以及与已选择结果的相似性。然后，根据一个权衡参数（通常表示为λ），MMR会选择具有最大边际相关性的结果。这种方法有助于<span style="background-color: #c0c0c0;">在结果集中提供多样性，避免重复内容，同时确保结果与用户的查询相关</span>。</p>
<p>进行相似性判断时，MMR使用最大余弦相似性（greatest cosine similarity）。它计算样本、输入的Embeddings在高维空间的向量的夹角，这个夹角越小（也就是余弦越大）意味着相似度越高。</p>
<p>与此同时，MMR尽量保证这些样本之间的差异性。</p>
<pre class="crayon-plain-tag">example_selector = MaxMarginalRelevanceExampleSelector.from_examples(
    examples,
    # 产生Embeddings
    OpenAIEmbeddings(),
    # 选择一个向量存储类
    FAISS,
    # 选择的样本数量
    k=2,
)</pre>
<div class="blog_h3"><span class="graybg">基于n-gram重叠选择</span></div>
<p>n-gram overlap是另外一种衡量文本相似性的方法，用于比较两个文本序列中的n-gram序列。<span style="background-color: #c0c0c0;">n-gram是指在给定文本中连续出现的n个元素（通常是字符或单词）</span>。n-gram重叠计算的是两个文本序列中共享的n-gram数量，以评估它们之间的相似性。假设我们比较以下两个句子的单词级别的2-gram（bi-gram）：</p>
<ol style="list-style-type: undefined;">
<li>我喜欢吃苹果</li>
<li>我喜欢吃香蕉</li>
</ol>
<p>句子1的bi-grams：(我喜欢)，(喜欢吃)，(吃苹果) 句子2的bi-grams：(我喜欢)，(喜欢吃)，(吃香蕉)。这两个句子共享了2个bi-grams：(我喜欢)和(喜欢吃)。因此，它们的bi-gram重叠数量为2。</p>
<p>n-gram重叠通常用于自然语言处理任务，如文本分类、文本摘要、机器翻译评估和文档聚类等。通过计算n-gram重叠，可以评估文本之间的结构和内容相似性。n-gram重叠可能受到n的选择和文本长度的影响。</p>
<pre class="crayon-plain-tag">example_selector = NGramOverlapExampleSelector(
    examples=examples,
    example_prompt=example_prompt,
    # This is the threshold, at which selector stops.
    # It is set to -1.0 by default.
    threshold=-1.0,
    # For negative threshold:
    # Selector sorts examples by ngram overlap score, and excludes none.
    # For threshold greater than 1.0:
    # Selector excludes all examples, and returns an empty list.
    # For threshold equal to 0.0:
    # Selector sorts examples by ngram overlap score,
    # and excludes those with no ngram overlap with input.
)</pre>
<div class="blog_h3"><span class="graybg">基于相似度选择</span></div>
<p>这种方式仅仅用最大余弦相似性（greatest cosine similarity）来计算输入和样本的相似度，不考虑样本之间的差异性。</p>
<pre class="crayon-plain-tag">example_selector = SemanticSimilarityExampleSelector.from_examples(
    examples, 
    OpenAIEmbeddings(), 
    # 向量存储实现
    Chroma, 
    # 选择的样本数量
    k=1
)</pre>
<div class="blog_h2"><span class="graybg">关于语言模型</span></div>
<p>LangChain提供针对两类模型的接口：</p>
<ol>
<li>大语言模型（LLMs）：如上文所述，这类接口以文本为输入，文本为输出</li>
<li>对话模型：其是基于语言模型的封装，以消息列表为输入，消息为输出</li>
</ol>
<div class="blog_h2"><span class="graybg">LLM模型</span></div>
<p>LangChains不提供任何模型，它知识提供了操控各种语言模型的标准接口，支持的LLM提供者包括：OpenAI、Hugging Face、Cohere等。</p>
<div class="blog_h3"><span class="graybg">异步API</span></div>
<p>LangChain基于asyncio库实现了异步AI的支持：</p>
<pre class="crayon-plain-tag">async def async_generate(llm):
    resp = await llm.agenerate(["Hello, how are you?"])
    print(resp.generations[0][0].text)

llm = OpenAI(temperature=0.9)
tasks = [async_generate(llm) for _ in range(10)]
await asyncio.gather(*tasks)</pre>
<div class="blog_h3"><span class="graybg">封装新模型</span></div>
<p>要封装一个LangChain不支持的语言模型，你仅仅需要继承抽象类LLM即可，需要实现的方法包括：</p>
<ol>
<li>属性 _llm_type ，给这个模型一个类型名</li>
<li>_call 调用模型</li>
<li>_acall 异步调用模型</li>
<li>属性 _identifying_params，返回一个字典，用于打印模型实例的关键参数</li>
</ol>
<p>示例：</p>
<pre class="crayon-plain-tag">from typing import Optional, List, Mapping, Any

from langchain.callbacks.manager import CallbackManagerForLLMRun, AsyncCallbackManagerForLLMRun
from langchain.llms.base import LLM


class CustomLLM(LLM):
    n: int

    @property
    def _llm_type(self) -&gt; str:
        return "custom"

    def _call(
            self,
            prompt: str,
            stop: Optional[List[str]] = None,
            run_manager: Optional[CallbackManagerForLLMRun] = None,
    ) -&gt; str:
        if stop is not None:
            raise ValueError("stop kwargs are not permitted.")
        return prompt[: self.n]

    async def _acall(self, prompt: str, stop: Optional[List[str]] = None,
                     run_manager: Optional[AsyncCallbackManagerForLLMRun] = None, **kwargs: Any) -&gt; str:
        return self._call(prompt, stop, run_manager)

    @property
    def _identifying_params(self) -&gt; Mapping[str, Any]:
        """Get the identifying parameters."""
        return {"n": self.n}


if __name__ == "__main__":
    # 构造函数已经由基类定义
    llm = CustomLLM(n=10)

    print(llm("Hello, world!"))</pre>
<div class="blog_h3"><span class="graybg">FakeLLM</span></div>
<p>这个模型用于测试，它根据静态的仿冒来响应请求：</p>
<pre class="crayon-plain-tag">responses = ["Action: Python REPL\nAction Input: print(2 + 2)", "Final Answer: 4"]
llm = FakeListLLM(responses=responses)</pre>
<div class="blog_h3"><span class="graybg">HumanInputLLM</span></div>
<p>这个模型也用于测试或调试目的，它让人类去扮演LLM，从而可以测试Agent的行为：</p>
<pre class="crayon-plain-tag">from langchain.llms.human import HumanInputLLM

from langchain.agents import load_tools
from langchain.agents import initialize_agent
from langchain.agents import AgentType

if __name__ == "__main__":
    tools = load_tools(["wikipedia"])
    llm = HumanInputLLM(
        # 这里用来格式化提示词
        # 使用Agent和工具时，喂给语言模型的提示词会自动生成
        # 你要模仿语言模型的行为（理解自然语言的能力、遵从提示词的要求）
        prompt_func=lambda prompt: print(
            f"\n===PROMPT START====\n{prompt}\n=====PROMPT END======"
        )
    )
    agent = initialize_agent(
        tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True
    )

    agent.run("Who is Joe Biden?")</pre>
<p> 运行后，显示提示词：</p>
<p style="padding-left: 30px;">===PROMPT START====<br />真正的语言模型会理解这里的提示词，测试时人类要模仿语言模型的行为<br />Answer the following questions as best you can. You have access to the following tools:</p>
<p style="padding-left: 30px;">这里告诉语言模型，工具及其用途<br />Wikipedia: A wrapper around Wikipedia. Useful for when you need to answer general questions about people, places, companies, facts, historical events, or other subjects. Input should be a search query.</p>
<p style="padding-left: 30px;">这里告诉语言模型如何做，因为它的语言理解能力，应答中会给出足够的信息，让代理知道用什么输入调用什么工具<br />Use the following format:</p>
<p style="padding-left: 30px;">Question: the input question you must answer<br />Thought: you should always think about what to do<br />Action: the action to take, should be one of [Wikipedia]<br />Action Input: the input to the action<br />Observation: the result of the action<br />... (this Thought/Action/Action Input/Observation can repeat N times)<br />Thought: I now know the final answer<br />Final Answer: the final answer to the original input question</p>
<p style="padding-left: 30px;">Begin!</p>
<p style="padding-left: 30px;">Question: Who is Joe Biden?<br />Thought:<br />=====PROMPT END======</p>
<p>现在人类可以输入，这个输入就是作为语言模型的响应：</p>
<p style="padding-left: 30px;">I need to use a tool   注意这个是 Thought<br />Action: Wikipedia<br />Action Input: Joe Biden<br />Observation: Page: Joe Biden</p>
<p>由于上述响应，代理就知道需要用关键字Joe Biden来搜索维基百科了。工具会生成页面的摘要文本，代理则会基于这些文本，继续和语言模型交互。</p>
<p>人类扮演的模型可以继续进行上述Thought/Action/Action Input/Observation交互，并且在最终给出输入：</p>
<p style="padding-left: 30px;">Final Answer: Joe Biden is an old man</p>
<p>当看到 Final Answer后，代理会终止处理链。</p>
<div class="blog_h3"><span class="graybg">缓存</span></div>
<p>LangChain提供了针对LLM的缓存层，用于：</p>
<ol>
<li>减少对LLM API的调用，节省费用</li>
<li>针对相同问题，提升响应速度</li>
</ol>
<pre class="crayon-plain-tag">import langchain

from langchain import OpenAI

if __name__ == "__main__":
    llm = OpenAI(model_name="text-davinci-002", n=2, best_of=2)
    # 内存缓存
    from langchain.cache import InMemoryCache

    langchain.llm_cache = InMemoryCache()
    print(llm.predict("Tell me a joke"))  # 慢
    print(llm.predict("Tell me a joke"))  # 快



# SQLite缓存
from langchain.cache import SQLiteCache
langchain.llm_cache = SQLiteCache(database_path=".langchain.db")</pre>
<p>在处理链中，可以选择为特定节点关闭缓存：</p>
<pre class="crayon-plain-tag">import requests
from langchain import OpenAI
from langchain.text_splitter import CharacterTextSplitter
from langchain.docstore.document import Document
from langchain.chains.summarize import load_summarize_chain

if __name__ == "__main__":
    url = 'https://raw.githubusercontent.com/hwchase17/chat-your-data/master/state_of_the_union.txt'
    state_of_the_union = requests.get(url).text
    llm = OpenAI(model_name="text-davinci-002")
    # disable caching
    no_cache_llm = OpenAI(model_name="text-davinci-002", cache=False)

    text_splitter = CharacterTextSplitter()
    texts = text_splitter.split_text(state_of_the_union)

    # select the first 3 texts and build a document for each
    docs = [Document(page_content=t) for t in texts[:3]]
    # produce a summarization of the documents with map_reduce chain, the map step
    # will be cached ( using non-cahced llm). The reduce( combine ) step won't be frozen
    # because we are using a llm with cache disabled
    chain = load_summarize_chain(llm, chain_type="map_reduce", reduce_llm=no_cache_llm)

    # first run will be slow, but the map step will get cached
    print(chain.run(docs))

    # here we run it again, it will be substantially faster while producing a different answer
    # cause the combine step is not cached
    print(chain.run(docs))</pre>
<div class="blog_h3"><span class="graybg">从文件加载LLM配置</span></div>
<p>LangChain支持从文件实例化LLM所需的配置：</p>
<pre class="crayon-plain-tag">from langchain.llms import OpenAI
from langchain.llms.loading import load_llm

# load from JSON, notice that YAML is also acceptable
llm = load_llm("llm.json")

# persist the llm config to file
llm.save("llm.json")</pre>
<div class="blog_h3"><span class="graybg">生成流式响应</span></div>
<p>某些LLM模型支持提供流式响应，也就是不需要等待所有响应生成后再返回给客户端，我们使用的ChatGPT之类的聊天应用，都会使用这种响应模式。</p>
<p>LangChain支持OpenAI、ChatOpenAI、ChatAnthropic的流式响应。你需要编写一个<pre class="crayon-plain-tag">CallbackHandler</pre>回调，实现<pre class="crayon-plain-tag">on_llm_new_token</pre>方法。StreamingStdOutCallbackHandler是LangChain自带的一个实现：</p>
<pre class="crayon-plain-tag">class StreamingStdOutCallbackHandler(BaseCallbackHandler):
    """Callback handler for streaming. Only works with LLMs that support streaming."""

    def on_llm_new_token(self, token: str, **kwargs: Any) -&gt; None:
        """Run on new LLM token. Only available when streaming is enabled."""
        sys.stdout.write(token)
        sys.stdout.flush()</pre>
<p> 它简单的将新产生的Token写入标准输出：</p>
<pre class="crayon-plain-tag">if __name__ == "__main__":
    from langchain.llms import OpenAI
    from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler

    llm = OpenAI(streaming=True, callbacks=[StreamingStdOutCallbackHandler()], temperature=0)
    resp = llm("Write me a song about sparkling water.")</pre>
<div class="blog_h3"><span class="graybg">跟踪Token用量</span></div>
<p>使用一个上下文管理器来包围你的代码，可以统计这些代码的Token用量，从而估算成本。目前仅仅支持OpenAI。</p>
<pre class="crayon-plain-tag">from langchain.llms import OpenAI
from langchain.callbacks import get_openai_callback

llm = OpenAI(model_name="text-davinci-002", n=2, best_of=2)

with get_openai_callback() as cb:
    result = llm("Tell me a joke")
    print(cb)


with get_openai_callback() as cb:
    response = agent.run(
        "Who is Olivia Wilde's boyfriend? What is his current age raised to the 0.23 power?"
    )
    print(f"Total Tokens: {cb.total_tokens}")
    print(f"Prompt Tokens: {cb.prompt_tokens}")
    print(f"Completion Tokens: {cb.completion_tokens}")
    print(f"Total Cost (USD): ${cb.total_cost}")</pre>
<div class="blog_h3"><span class="graybg">和其它LLM集成</span></div>
<p>详细的支持列表可以看<a href="https://python.langchain.com/docs/modules/model_io/models/llms/integrations/openai">官方文档</a>。</p>
<p>Hugging Face Hub是模型托管平台，它提供开源的120K模型、20K数据集、50K演示应用。LangChain支持直接使用Hugging Face上的模型。首先需要安装包：</p>
<pre class="crayon-plain-tag">pip install huggingface_hub</pre>
<p>设置你的API Token：</p>
<pre class="crayon-plain-tag">os.environ["HUGGINGFACEHUB_API_TOKEN"] = HUGGINGFACEHUB_API_TOKEN</pre>
<p>使用模型：</p>
<pre class="crayon-plain-tag">repo_id = "google/flan-t5-xxl
llm = HuggingFaceHub(repo_id=repo_id, model_kwargs={"temperature": 0.5, "max_length": 64})
llm_chain = LLMChain(prompt=prompt, llm=llm)</pre>
<p>Hugging Face上的模型，也可以被下载到你本地来运行。你需要先安装transformers包：</p>
<pre class="crayon-plain-tag">pip install transformers</pre>
<p>然后使用HuggingFacePipeline类来加载模型：</p>
<pre class="crayon-plain-tag">from langchain import HuggingFacePipeline

llm = HuggingFacePipeline.from_model_id(
    model_id="bigscience/bloom-1b7",
    task="text-generation",
    model_kwargs={"temperature": 0, "max_length": 64},
)</pre>
<p>注意，取决于你使用的模型的规模，下载可能消耗很长时间。当然，你本地机器也得有模型所需的硬件资源和软件。</p>
<p><a href="https://github.com/huggingface/text-generation-inference">Text Generation Inference</a>是一个开源服务软件，利用它，你可以在本地运行HuggingFace模型，并且暴露HTTP端点。LangChain可以连接到这种端点：</p>
<pre class="crayon-plain-tag">llm = HuggingFaceTextGenInference(
    inference_server_url="http://localhost:8010/",
    max_new_tokens=512,
    top_k=10,
    top_p=0.95,
    typical_p=0.95,
    temperature=0.01,
    repetition_penalty=1.03,
)
llm("What did foo say about bar?")</pre>
<div class="blog_h2"><span class="graybg">对话模型 </span></div>
<div class="blog_h3"><span class="graybg">缓存</span></div>
<p>用法和LLM模型一样：</p>
<pre class="crayon-plain-tag">import langchain
from langchain.chat_models import ChatOpenAI

llm = ChatOpenAI()

from langchain.cache import InMemoryCache
langchain.llm_cache = InMemoryCache()</pre>
<div class="blog_h3"><span class="graybg">HumanInputChatModel</span></div>
<p>用法和LLM版本的HumanInput模型类似：</p>
<pre class="crayon-plain-tag">from langchain.chat_models.human import HumanInputChatModel
from langchain.agents import load_tools
from langchain.agents import initialize_agent
from langchain.agents import AgentType

tools = load_tools(["wikipedia"])
llm = HumanInputChatModel()
agent = initialize_agent(
    tools, llm, agent=AgentType.CHAT_ZERO_SHOT_REACT_DESCRIPTION, verbose=True
)</pre>
<div class="blog_h3"><span class="graybg">生成流式响应</span></div>
<pre class="crayon-plain-tag">from langchain.chat_models import ChatOpenAI
from langchain.schema import (
    HumanMessage,
)


from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
chat = ChatOpenAI(streaming=True, callbacks=[StreamingStdOutCallbackHandler()], temperature=0)
resp = chat([HumanMessage(content="Write me a song about sparkling water.")])</pre>
<div class="blog_h2"><span class="graybg">输出解析</span></div>
<p>默认情况下，语言模型输出自然语言文本。有时候你希望获得结构化的数据，便于在后续业务流程中继续处理。</p>
<p>LangChain提供了一系列的输出解析器（OutputParser），它们实现两类关键方法：</p>
<ol>
<li>获取格式指令：返回一个字符串，包含给语言模型的指令，用于指示语言模型去格式化自己的输出</li>
<li>解析：接受一段文本（来自语言模型的输出），解析为目标格式</li>
</ol>
<p>以及一个可选方法：</p>
<ol>
<li>使用提示词来解析：同时提供一个字符串（相当于模型响应）+ 提示词（相当于生成前述响应的提示词）。这个方法在输出解析器需要通过某种方式重试、修复输出的时候调用</li>
</ol>
<div class="blog_h3"><span class="graybg">JSON解析器</span></div>
<p>这个输出解析器，允许用户提供任意的JSON Schema，同时要求LLM的输出遵从该Schema。注意：能否输出格式正确的JSON，取决于模型，在OpenAI模型家族中DaVinci能可靠的生成JSON而Curie则不靠谱。</p>
<p>你需要定义一个Pydantic模型，来声明Schema：</p>
<pre class="crayon-plain-tag">from pydantic import BaseModel, Field, validator

# Define your desired data structure.
class Joke(BaseModel):
    setup: str = Field(description="question to set up a joke")
    punchline: str = Field(description="answer to resolve the joke")

    # You can add custom validation logic easily with Pydantic.
    @validator("setup")
    def question_ends_with_question_mark(cls, field):
        if field[-1] != "?":
            raise ValueError("Badly formed question!")
        return field</pre>
<p>使用输出解析器，来生成提示词的格式指令部分：</p>
<pre class="crayon-plain-tag">parser = PydanticOutputParser(pydantic_object=Joke)
instructions = parser.get_format_instructions()</pre>
<p>格式指令用自然语言提示LLM，该如何生成符合Schema的响应：</p>
<p style="padding-left: 30px;">The output should be formatted as a JSON instance that conforms to the JSON schema below.</p>
<p style="padding-left: 30px;">As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}}<br />the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.</p>
<p style="padding-left: 30px;">Here is the output schema:<br />```<br />{"properties": {"setup": {"title": "Setup", "description": "question to set up a joke", "type": "string"}, "punchline": {"title": "Punchline", "description": "answer to resolve the joke", "type": "string"}}, "required": ["setup", "punchline"]}<br />```</p>
<p> 使用上述格式指令，构建一个提示词模板：</p>
<pre class="crayon-plain-tag">prompt = PromptTemplate(
    template="Answer the user query.\n{format_instructions}\n{query}\n",
    input_variables=["query"],
    partial_variables={"format_instructions": instructions},
)

_input = prompt.format_prompt(query="Tell me a joke.")</pre>
<p> 完整的提示词：</p>
<p style="padding-left: 30px;">Answer the user query.<br />The output should be formatted as a JSON instance that conforms to the JSON schema below.</p>
<p style="padding-left: 30px;">As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}}<br />the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.</p>
<p style="padding-left: 30px;">Here is the output schema:<br />```<br />{"properties": {"setup": {"title": "Setup", "description": "question to set up a joke", "type": "string"}, "punchline": {"title": "Punchline", "description": "answer to resolve the joke", "type": "string"}}, "required": ["setup", "punchline"]}<br />```<br />Tell me a joke.</p>
<p> 生成响应：</p>
<pre class="crayon-plain-tag">output = model(_input.to_string())

joke: Joke = parser.parse(output)
print(joke.punchline)</pre>
<p>output即原始响应，为JSON形式：</p>
<p style="padding-left: 30px;">{"setup": "Why did the chicken cross the road?", "punchline": "To get to the other side!"}'</p>
<p>parse方法将其反序列化为Joke对象。</p>
<div class="blog_h3"><span class="graybg">自动修复解析器</span></div>
<p>这个解析器包装另外一个解析器，当后者解析失败，自动修复解析器会调用另外一个LLM来修复失败。该解析器会将格式错误的原始应答 + 格式指令一同发送给新的LLM：</p>
<pre class="crayon-plain-tag">class Actor(BaseModel):
    name: str = Field(description="name of an actor")
    film_names: List[str] = Field(description="list of names of films they starred in")
        
actor_query = "Generate the filmography for a random actor."

parser = PydanticOutputParser(pydantic_object=Actor)


# This is a mock of misformatted output, JSON properties must be double quoted.
misformatted = "{'name': 'Tom Hanks', 'film_names': ['Forrest Gump']}"


# parsing will fail with a JSONDecodeError
parser.parse(misformatted)


from langchain.output_parsers import OutputFixingParser
# here we wrap the original parser with OutputFixingParser, which will attempt to fix the output
new_parser = OutputFixingParser.from_llm(parser=parser, llm=ChatOpenAI())
new_parser.parse(misformatted)</pre>
<div class="blog_h3"><span class="graybg">重试解析器</span></div>
<p>某些情况下，模型给出的输出是不完整，而不是格式错误的。这种情况下可以尝试RetryOutputParser，它会将提示词 + 原始输出一起传递给LLM以尝试获得更好的应答：</p>
<pre class="crayon-plain-tag">class Action(BaseModel):
    action: str = Field(description="action to take")
    action_input: str = Field(description="input to the action")


parser = PydanticOutputParser(pydantic_object=Action)

template = """Based on the user question, provide an Action and Action Input for what step should be taken.
{format_instructions}
Question: {query}
Response:"""

prompt = PromptTemplate(
    template="Answer the user query.\n{format_instructions}\n{query}\n",
    input_variables=["query"],
    partial_variables={"format_instructions": parser.get_format_instructions()},
)

# this is an incomplete output
bad_response = '{"action": "search"}'


from langchain.output_parsers import RetryWithErrorOutputParser
retry_parser = RetryWithErrorOutputParser.from_llm(
    parser=parser, llm=OpenAI(temperature=0)
)
retry_parser.parse_with_prompt(bad_response, prompt_value)
# Action(action='search', action_input='who is leo di caprios gf?'</pre>
<div class="blog_h3"><span class="graybg">其它解析器</span></div>
<p>CommaSeparatedListOutputParser用于生成一个列表，条目以逗号分隔：</p>
<pre class="crayon-plain-tag">output_parser = CommaSeparatedListOutputParser()

format_instructions = output_parser.get_format_instructions()
prompt = PromptTemplate(
    template="List five {subject}.\n{format_instructions}",
    input_variables=["subject"],
    partial_variables={"format_instructions": format_instructions}
)

_input = prompt.format(subject="ice cream flavors")</pre>
<p>DatetimeOutputParser用于将输出解析为日期/时间：</p>
<pre class="crayon-plain-tag">output_parser = DatetimeOutputParser()
template = """Answer the users question:

{question}

{format_instructions}"""
prompt = PromptTemplate.from_template(
    template,
    partial_variables={"format_instructions": output_parser.get_format_instructions()},
)

output = chain.run("around when was bitcoin founded?")
output_parser.parse(output)</pre>
<div class="blog_h1"><span class="graybg">数据连接</span></div>
<p>LLM一次完整的训练需要高昂的成本，这导致模型不能学到实时的知识，另外，一些私有领域的知识，也不能出现在训练集中。LLM具有In-Context学习的能力，允许用户在输入中注入知识，然后由LLM组织这些知识，生成符合用户预期的应答。</p>
<p>很多基于LLM的应用，都需要利用这种In-Context学习的能力，典型的是基于知识库的问答机器人。将所有知识，都以提示词的形式注入给LLM是不现实的，目前的模型都有Token数的限制。并且Token越多，成本越高，模型性能也越差。</p>
<p>LangChain的数据连接（Data Connection）给出了解决方案，整体上来说，需要在本地离线的对知识库进行处理、索引。在使用LLM时，在本地检索出和用户问题相关的知识片段，然后将这些知识片段作为提示词的一部分发送。</p>
<p>和数据连接有关的模块包括：</p>
<ol>
<li>文档加载器（Document loaders）：从各种源加载文档</li>
<li>文档转换器（Document transformers）：分割文档、剔除重复文档内容</li>
<li>文本嵌入模型（Text embedding models）：将非结构化的文本转换为Embeddings，即高维空间的向量（形式上为浮点数的列表）</li>
<li>向量存储库（Vector stores）：用于对上述Embeddings进行存储、检索</li>
<li>检索器（Retrievers）：用于检索数据，向量存储库可以作为检索器的一种后端</li>
</ol>
<div class="blog_h2"><span class="graybg">文档加载器</span></div>
<p>文档加载器负责将特定来源的数据加载为Document对象。Document是关联了元数据的文本。</p>
<div class="blog_h3"><span class="graybg">加载CSV</span></div>
<pre class="crayon-plain-tag">from langchain.document_loaders.csv_loader import CSVLoader

loader = CSVLoader(file_path='./example_data/mlb_teams_2012.csv')

loader = CSVLoader(file_path='./example_data/mlb_teams_2012.csv', csv_args={
    'delimiter': ',',
    'quotechar': '"',
    'fieldnames': ['MLB Team', 'Payroll in millions', 'Wins']
})

data = loader.load()</pre>
<div class="blog_h3"><span class="graybg">加载HTML</span></div>
<pre class="crayon-plain-tag">from langchain.document_loaders import UnstructuredHTMLLoader

loader = UnstructuredHTMLLoader("example_data/fake-content.html")
data = loader.load()</pre>
<p>可以使用BeautifulSoup4加载器，自动解析HTML中的文本到page_content，将页面的标题解析到metadata.title：</p>
<pre class="crayon-plain-tag">from langchain.document_loaders import BSHTMLLoader

loader = BSHTMLLoader("example_data/fake-content.html")
data = loader.load()
data</pre>
<div class="blog_h3"><span class="graybg">加载JSON</span></div>
<pre class="crayon-plain-tag">from langchain.document_loaders import JSONLoader

loader = JSONLoader(
    file_path='./example_data/facebook_chat.json',
    # 使用jq语法指定从JSON的什么字段抽取Document.page_content
    jq_schema='.messages[].content')

data = loader.load()</pre>
<p>如果源文档是JSON Lines格式（每行均为一个JSON），则需要指定 json_lines 为True：</p>
<pre class="crayon-plain-tag">loader = JSONLoader(
    file_path='./example_data/facebook_chat_messages.jsonl',
    jq_schema='.content',
    json_lines=True)

data = loader.load()</pre>
<p>除了page_content字段，还可以抽取任意元数据：</p>
<pre class="crayon-plain-tag">def metadata_func(record: dict, metadata: dict) -&gt; dict:

    metadata["sender_name"] = record.get("sender_name")
    metadata["timestamp_ms"] = record.get("timestamp_ms")

    return metadata


loader = JSONLoader(
    # {
    #   'messages': [
    #     {'content': 'Bye!', 'sender_name': 'User 2', 'timestamp_ms': 1675597571851}
    #   ]
    # }
    file_path='./example_data/facebook_chat.json',
    jq_schema='.messages[]',
    content_key="content",
    metadata_func=metadata_func
)

data = loader.load()</pre>
<div class="blog_h3"><span class="graybg">其它</span></div>
<p>LangChains支持从大量在线服务读取数据，具体参考<a href="https://python.langchain.com/docs/modules/data_connection/document_loaders">官方文档</a>。</p>
<div class="blog_h2"><span class="graybg">文档转换器</span></div>
<p>加载完文档后，通常需要对其进行转换以适合应用程序需要。其中最常见的例子是，将文档切割为较小的块（chunk）以适合模型的上下文大小窗口。</p>
<div class="blog_h3"><span class="graybg">如何分割文本</span></div>
<p>将长文档分割为小块时，需要处理很多潜在的复杂性。理想情况下，语义相关的片段，应该被分割在同一个块中。大体上来说，文本分割的工作方式如下：</p>
<ol>
<li>将文本分割为语义相关的小块，通常以句子为边界</li>
<li>将这些小块合并为更大的块，直到到达特定的大小</li>
<li>基于上面的块，外加前后的一些重叠，构建最终的块。这个操作的目的是保留块之间的上下文</li>
</ol>
<div class="blog_h3"><span class="graybg">RecursiveCharacterTextSplitter</span></div>
<p>默认推荐的文本分割器是RecursiveCharacterTextSplitter。该分割器接受一个分隔符列表（默认<pre class="crayon-plain-tag">["\n\n", "\n", " ", ""]</pre>），在确保块不过大的情况下，优先使用列表中前面的分隔符来分割块。该分割器提供了一些辅助的配置项：</p>
<ol>
<li><pre class="crayon-plain-tag">length_function</pre>：控制如何计算chunk的长度，默认是计算字符的个数，实际更常用的是计算Token的个数</li>
<li><pre class="crayon-plain-tag">chunk_size</pre>：允许的chunk的最大长度</li>
<li><pre class="crayon-plain-tag">chunk_overlap</pre>：两个相邻的chunk重叠部分的最大长度</li>
<li><pre class="crayon-plain-tag">add_start_index</pre>：是否包含chunk所在原始文档中的起始位置</li>
</ol>
<p>示例：</p>
<pre class="crayon-plain-tag">with open('../../state_of_the_union.txt') as f:
    state_of_the_union = f.read()

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    # Set a really small chunk size, just to show.
    chunk_size = 100,
    chunk_overlap  = 20,
    length_function = len,
    add_start_index = True,
)

texts = text_splitter.create_documents([state_of_the_union])
print(texts[0])</pre>
<div class="blog_h3"><span class="graybg">分割代码</span></div>
<pre class="crayon-plain-tag">from langchain.text_splitter import (
    RecursiveCharacterTextSplitter,
    Language,
)

# 获取特定语言的分隔符列表
python_splitter = RecursiveCharacterTextSplitter.get_separators_for_language(Language.PYTHON)
python_docs = python_splitter.create_documents([PYTHON_CODE])</pre>
<div class="blog_h3"><span class="graybg">MarkdownHeaderTextSplitter</span></div>
<p>嵌入处理过程需要同时考虑整体上下文、文本中句子和短语的相互关系，以保证生成质量更高的向量表示，精确的捕获文档的主题和含义。这要求具有相同上下文的文本，应该被分割在相同的块中。</p>
<p>类似于Markdown这样的格式，本身就通过标题级别划分了上下文，因此我们可以考虑基于这些标题级别进行分割，LangChains提供了MarkdownHeaderTextSplitter来处理Markdown：</p>
<pre class="crayon-plain-tag">markdown_document = "# Foo\n\n    ## Bar\n\nHi this is Jim\n\nHi this is Joe\n\n ### Boo \n\n Hi this is Lance \n\n ## Baz\n\n Hi this is Molly"

headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]

markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_header_splits = markdown_splitter.split_text(markdown_document)
#  [Document(page_content='Hi this is Jim \nHi this is Joe', metadata={'Header 1': 'Foo', 'Header 2': 'Bar'}),
#    Document(page_content='Hi this is Lance', metadata={'Header 1': 'Foo', 'Header 2': 'Bar', 'Header 3': 'Boo'}),
#    Document(page_content='Hi this is Molly', metadata={'Header 1': 'Foo', 'Header 2': 'Baz'})]</pre>
<p>在上述基于标题级别分割的基础上，可以进一步分割：</p>
<pre class="crayon-plain-tag">from langchain.text_splitter import RecursiveCharacterTextSplitter
chunk_size = 250
chunk_overlap = 30
text_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)

splits = text_splitter.split_documents(md_header_splits)</pre>
<div class="blog_h3"><span class="graybg">基于Token分割</span></div>
<p>LLM通常有Token数限制，我们分割的chunk不应该超过这个限制。 分词器（tokenizer）种类很多，计算chunk中有多少token时，应当使用和LLM一致的分词器。</p>
<p>OpenAI提供了基于BPE的分词器tiktoken，下面是其用法示例：</p>
<pre class="crayon-plain-tag">from langchain.text_splitter import CharacterTextSplitter

text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    chunk_size=100, chunk_overlap=0
)
texts = text_splitter.split_text(state_of_the_union)


# 或者

from langchain.text_splitter import TokenTextSplitter

text_splitter = TokenTextSplitter(chunk_size=10, chunk_overlap=0)

texts = text_splitter.split_text(state_of_the_union)</pre>
<p>spaCy是一个用于自然语言处理的库，LangChain也支持基于spaCy的分词器：</p>
<pre class="crayon-plain-tag"># pip install spacy

from langchain.text_splitter import SpacyTextSplitter

text_splitter = SpacyTextSplitter(chunk_size=1000)</pre>
<p>Hugging Face也提供了很多分词器，示例：</p>
<pre class="crayon-plain-tag">from transformers import GPT2TokenizerFast

tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")
text_splitter = CharacterTextSplitter.from_huggingface_tokenizer(
    tokenizer, chunk_size=100, chunk_overlap=0
)</pre>
<div class="blog_h2"><span class="graybg">Embedding</span></div>
<p>LangChain提供了和各种Embedding模型进行交互的标准化接口。上文我们提到过，Embedding是将文本转换为向量表示的过程，这样就可以将其存储在向量空间，并进行两个文本的相似度检索。</p>
<p>标准化接口提供了两个方法，其中之一用于嵌入文档：</p>
<pre class="crayon-plain-tag">from langchain.embeddings import OpenAIEmbeddings
embeddings_model = OpenAIEmbeddings(openai_api_key="...")

embeddings = embeddings_model.embed_documents(
    [
        "Hi there!",
        "Oh, hello!",
        "What's your name?",
        "My friends call me World",
        "Hello World!"
    ]
)</pre>
<p>另外一个用于嵌入查询：</p>
<pre class="crayon-plain-tag">embedded_query = embeddings_model.embed_query("What was the name mentioned in the conversation?")</pre>
<p>除了针对OpenAI的<a href="https://python.langchain.com/docs/modules/data_connection/text_embedding/integrations/openai">OpenAIEmbeddings</a>类，还有针对各种其它模型的封装，具体参考官方文档。</p>
<div class="blog_h2"><span class="graybg">向量存储</span></div>
<p>有了针对文档、查询的Embeddings，就可以进行相似度计算了 —— 找出和查询相似度最高的文档。向量存储提供了这种查询能力，同时它也负责存储文档的Embeddings。</p>
<p>向量存储的实现有很多，例如FAISS（Facebook AI Similarity Search)库，下面是初始化基于FAISS的向量存储的方法：</p>
<pre class="crayon-plain-tag"># pip install faiss-cpu

from langchain.document_loaders import TextLoader
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.text_splitter import CharacterTextSplitter
from langchain.vectorstores import FAISS


raw_documents = TextLoader('...').load()
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
documents = text_splitter.split_documents(raw_documents)

embeddings = OpenAIEmbeddings()
db = FAISS.from_documents(documents, embeddings)</pre>
<p>下面是执行相似度搜索的方法：</p>
<pre class="crayon-plain-tag"># 搜索文档
query = "What did the president say about Ketanji Brown Jackson"
docs = db.similarity_search(query)
print(docs[0].page_content)

# 基于Embedding搜索
embedding_vector = embeddings.embed_query(query)
docs = db.similarity_search_by_vector(embedding_vector)</pre>
<p>除了<a href="https://python.langchain.com/docs/modules/data_connection/vectorstores/integrations/faiss">FAISS</a>，LangChain提供了多种向量存储的封装，具体参考官方文档。</p>
<div class="blog_h2"><span class="graybg"> 检索器</span></div>
<p>检索器可以根据给定的非结构化查询，返回匹配的文档。检索器可以将向量存储作为后端，但是并非必须。检索器的基类很简单：</p>
<pre class="crayon-plain-tag">from abc import ABC, abstractmethod
from typing import Any, List
from langchain.schema import Document
from langchain.callbacks.manager import Callbacks

class BaseRetriever(ABC):
    ...
    def get_relevant_documents(
        self, query: str, *, callbacks: Callbacks = None, **kwargs: Any
    ) -&gt; List[Document]:
        """Retrieve documents relevant to a query.
        Args:
            query: string to find relevant documents for
            callbacks: Callback manager or list of callbacks
        Returns:
            List of relevant documents
        """
        ...

    async def aget_relevant_documents(
        self, query: str, *, callbacks: Callbacks = None, **kwargs: Any
    ) -&gt; List[Document]:
        """Asynchronously get documents relevant to a query.
        Args:
            query: string to find relevant documents for
            callbacks: Callback manager or list of callbacks
        Returns:
            List of relevant documents
        """
        ...</pre>
<p>也就是说检索器可以返回和查询相关的文档列表，相关性的定义取决于具体的检索器。</p>
<p>主要类型的检索器，还是基于向量存储的。LangChain默认情况下使用Chroma作为向量存储实现。下面的代码从文档加载器创建索引：</p>
<pre class="crayon-plain-tag">from langchain.chains import RetrievalQA
from langchain.llms import OpenAI

from langchain.document_loaders import TextLoader
loader = TextLoader('...', encoding='utf8')

from langchain.indexes import VectorstoreIndexCreator
index = VectorstoreIndexCreator().from_loaders([loader])</pre>
<p>可以基于此索引来进行检索：</p>
<pre class="crayon-plain-tag">query = "What did the president say about Ketanji Brown Jackson"
index.query(query)</pre>
<p>下面的代码展示了如何从索引得到检索器接口：</p>
<pre class="crayon-plain-tag">index.vectorstore.as_retriever()</pre>
<p>VectorstoreIndexCreator内部，做了以下工作：</p>
<ol>
<li>将文档分割为块<br />
<pre class="crayon-plain-tag">documents = loader.load()

from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
texts = text_splitter.split_documents(documents)</pre>
</li>
<li>为每个文档创建Embeddings、存储文档和Embeddings到向量存储<br />
<pre class="crayon-plain-tag">from langchain.embeddings import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()

from langchain.vectorstores import Chroma
db = Chroma.from_documents(texts, embeddings)</pre>
</li>
<li>从向量存储得到检索器接口，并构造处理链：<br />
<pre class="crayon-plain-tag">retriever = db.as_retriever()
qa = RetrievalQA.from_chain_type(llm=OpenAI(), chain_type="stuff", retriever=retriever)</pre>
</li>
<li>基于索引的查询，其实就是调用处理链：<br />
<pre class="crayon-plain-tag">query = "What did the president say about Ketanji Brown Jackson"
qa.run(query)</pre>
</li>
</ol>
<p>你可以对VectorstoreIndexCreator进行定制化：</p>
<pre class="crayon-plain-tag">index_creator = VectorstoreIndexCreator(
    vectorstore_cls=Chroma,
    embedding=OpenAIEmbeddings(),
    text_splitter=CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
)</pre>
<div class="blog_h3"><span class="graybg">MultiQueryRetriever</span></div>
<p>如果Embedding没有很好的捕获数据的语义，那么查询文本中的细微变化，可能导致相似度检索结果的很大不同。提示词工程和微调有时候用来解决这种检索不准确的问题，但是处理起来可能比较繁琐。</p>
<p>MultiQueryRetriever可以自动化这个微调过程，它会调用LLM，从不同视角，从用户输入生成多个查询。然后，基于这些查询，分别获取相关文档，取并集作为最终结果。通过这种处理，某些情况下能够克服向量存储的那种基于距离的相似度判断的缺点，获取更加丰富的检索结果。</p>
<pre class="crayon-plain-tag"># Build a sample vectorDB
from langchain.vectorstores import Chroma
from langchain.document_loaders import WebBaseLoader
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter

# Load blog post
loader = WebBaseLoader("https://lilianweng.github.io/posts/2023-06-23-agent/")
data = loader.load()
    
# Split
text_splitter = RecursiveCharacterTextSplitter(chunk_size = 500, chunk_overlap = 0)
splits = text_splitter.split_documents(data)

# VectorDB
embedding = OpenAIEmbeddings()
vectordb = Chroma.from_documents(documents=splits,embedding=embedding)


from langchain.chat_models import ChatOpenAI
from langchain.retrievers.multi_query import MultiQueryRetriever
question="What are the approaches to Task Decomposition?"
llm = ChatOpenAI(temperature=0)
retriever_from_llm = MultiQueryRetriever.from_llm(retriever=vectordb.as_retriever(),llm=llm)
# Set logging for the queries
import logging
logging.basicConfig()
logging.getLogger('langchain.retrievers.multi_query').setLevel(logging.INFO)
unique_docs = retriever_from_llm.get_relevant_documents(query=question)
len(unique_docs)</pre>
<div class="blog_h3"><span class="graybg">上下文压缩 </span></div>
<p>你不知道用户会提供什么样的查询，真正和查询意图最相关的信息，可能会淹没在大量无关的文本中。将无关的文本返回给程序，进而对LLM发起调用，可能导致更高的成本或者生成较差的应答。</p>
<p>上下文压缩用于解决这个问题，它的思路很简单：不是把检索得到的文档直接返回，而是基于查询给出的上下文信息，进行压缩（去掉无关信息）。</p>
<p>下面的代码：</p>
<pre class="crayon-plain-tag">def pretty_print_docs(docs):
    print(f"\n{'-' * 100}\n".join([f"Document {i + 1}:\n\n" + d.page_content for i, d in enumerate(docs)]))


if __name__ == "__main__":
    from langchain.text_splitter import CharacterTextSplitter
    from langchain.embeddings import OpenAIEmbeddings
    from langchain.document_loaders import UnstructuredURLLoader
    from langchain.vectorstores import FAISS

    url = 'https://raw.githubusercontent.com/hwchase17/chat-your-data/master/state_of_the_union.txt'
    documents = UnstructuredURLLoader(urls=[url]).load()
    text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
    texts = text_splitter.split_documents(documents)
    retriever = FAISS.from_documents(texts, OpenAIEmbeddings()).as_retriever()

    docs = retriever.get_relevant_documents("What did the president say about Ketanji Brown Jackson")
    pretty_print_docs(docs)

    from langchain.llms import OpenAI
    from langchain.retrievers import ContextualCompressionRetriever
    from langchain.retrievers.document_compressors import LLMChainExtractor</pre>
<p>检索到的第一个结果是：</p>
<p style="padding-left: 30px;">Document 1:</p>
<p style="padding-left: 30px;">In state after state, new laws have been passed, not only to suppress the vote, but to subvert entire elections.</p>
<p style="padding-left: 30px;">We cannot let this happen.</p>
<p style="padding-left: 30px;">Tonight. I call on the Senate to: Pass the Freedom to Vote Act. Pass the John Lewis Voting Rights Act. And while you’re at it, pass the Disclose Act so Americans can know who is funding our elections.</p>
<p style="padding-left: 30px;">Tonight, I’d like to honor someone who has dedicated his life to serve this country: Justice Stephen Breyer—an Army veteran, Constitutional scholar, and retiring Justice of the United States Supreme Court. Justice Breyer, thank you for your service.</p>
<p style="padding-left: 30px;">One of the most serious constitutional responsibilities a President has is nominating someone to serve on the United States Supreme Court.</p>
<p style="padding-left: 30px;">And I did that 4 days ago, when I nominated Circuit Court of Appeals Judge Ketanji Brown Jackson. One of our nation’s top legal minds, who will continue Justice Breyer’s legacy of excellence.</p>
<p>现在进行上下文压缩：</p>
<pre class="crayon-plain-tag">from langchain.llms import OpenAI
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor

llm = OpenAI(temperature=0)
compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(base_compressor=compressor, base_retriever=retriever)

compressed_docs = compression_retriever.get_relevant_documents("What did the president say about Ketanji Jackson Brown")
pretty_print_docs(compressed_docs)</pre>
<p> 结果长度变短：</p>
<p style="padding-left: 30px;">Document 1:</p>
<p style="padding-left: 30px;">"Tonight, I'd like to honor someone who has dedicated his life to serve this country: Justice Stephen Breyer—an Army veteran, Constitutional scholar, and retiring Justice of the United States Supreme Court. Justice Breyer, thank you for your service.</p>
<p style="padding-left: 30px;">One of the most serious constitutional responsibilities a President has is nominating someone to serve on the United States Supreme Court.</p>
<p style="padding-left: 30px;">And I did that 4 days ago, when I nominated Circuit Court of Appeals Judge Ketanji Brown Jackson. One of our nation's top legal minds, who will continue Justice Breyer's legacy of excellence."</p>
<p>上面代码中的LLMChainExtractor，仅仅负责检查检索器返回的原始结果集，决定哪些应该丢弃。它不会修改结果的内容。</p>
<p>针对每个结果，都调用一次LLM，成本较高且速度慢。EmbeddingsFilter提供一个更廉价和迅速的方案，它对文档和查询进行嵌入并仅仅返回和查询相似度足够高的那些文档：</p>
<pre class="crayon-plain-tag">from langchain.embeddings import OpenAIEmbeddings
from langchain.retrievers.document_compressors import EmbeddingsFilter

embeddings = OpenAIEmbeddings()
embeddings_filter = EmbeddingsFilter(embeddings=embeddings, similarity_threshold=0.76)
compression_retriever = ContextualCompressionRetriever(base_compressor=embeddings_filter, base_retriever=retriever)

compressed_docs = compression_retriever.get_relevant_documents("What did the president say about Ketanji Jackson Brown")</pre>
<div class="blog_h3"><span class="graybg">联用压缩和文档转换</span></div>
<p>利用DocumentCompressorPipeline类，可以构造一个流水线，编排多个压缩器以及BaseDocumentTransformers。后者不进行上下文压缩，仅仅负责简单的转换，例如可以用TextSplitters将文档分割为更小的片段，利用EmbeddingsRedundantFilter基于Embedding相似度剔除冗余的文档：</p>
<pre class="crayon-plain-tag">from langchain.document_transformers import EmbeddingsRedundantFilter
from langchain.retrievers.document_compressors import DocumentCompressorPipeline
from langchain.text_splitter import CharacterTextSplitter

# 首先分割为更小的块
splitter = CharacterTextSplitter(chunk_size=300, chunk_overlap=0, separator=". ")
# 然后剔除重复文档（块）
redundant_filter = EmbeddingsRedundantFilter(embeddings=embeddings)
# 最后根据相似度进行过滤
relevant_filter = EmbeddingsFilter(embeddings=embeddings, similarity_threshold=0.76)
pipeline_compressor = DocumentCompressorPipeline(
    transformers=[splitter, redundant_filter, relevant_filter]
)

compression_retriever = ContextualCompressionRetriever(base_compressor=pipeline_compressor, base_retriever=retriever)

compressed_docs = compression_retriever.get_relevant_documents("What did the president say about Ketanji Jackson Brown")</pre>
<div class="blog_h3"><span class="graybg">自查询</span></div>
<p>文档不但具有内容，还可以具有任意附加的元数据。元数据是一个字典，上文提到的文档加载器，可以在加载文档时抽取元数据。</p>
<p>所谓自查询的（Self-querying）检索器，能够对自身（通常是背后的向量存储）进行查询。具体来说，它能够从用户的查询输入中，抽取针对已存储的文档的元数据的过滤器，并应用这些过滤器。</p>
<p>打个比方，对于一个查询：What did bar say about foo。而文档具有元数据author，那么自查询检索器就能去匹配auther为bar的、内容为foo is a charming chap的文档。</p>
<p>代码示例：</p>
<pre class="crayon-plain-tag">if __name__ == "__main__":
    # pip install lark chromadb
    from langchain.schema import Document
    from langchain.embeddings.openai import OpenAIEmbeddings
    from langchain.vectorstores import Chroma

    embeddings = OpenAIEmbeddings()
    docs = [
        Document(
            page_content="A bunch of scientists bring back dinosaurs and mayhem breaks loose",
            metadata={"year": 1993, "rating": 7.7, "genre": "science fiction"},
        ),
        Document(
            page_content="Leo DiCaprio gets lost in a dream within a dream within a dream within a ...",
            metadata={"year": 2010, "director": "Christopher Nolan", "rating": 8.2},
        ),
        Document(
            page_content="A psychologist / detective gets lost in a series of dreams within dreams within dreams and Inception reused the idea",
            metadata={"year": 2006, "director": "Satoshi Kon", "rating": 8.6},
        ),
        Document(
            page_content="A bunch of normal-sized women are supremely wholesome and some men pine after them",
            metadata={"year": 2019, "director": "Greta Gerwig", "rating": 8.3},
        ),
        Document(
            page_content="Toys come alive and have a blast doing so",
            metadata={"year": 1995, "genre": "animated"},
        ),
        Document(
            page_content="Three men walk into the Zone, three men walk out of the Zone",
            metadata={
                "year": 1979,
                "rating": 9.9,
                "director": "Andrei Tarkovsky",
                "genre": "science fiction",
                "rating": 9.9,
            },
        ),
    ]
    vectorstore = Chroma.from_documents(docs, embeddings)

    # instantiate the self-query retriever
    from langchain.llms import OpenAI
    from langchain.retrievers.self_query.base import SelfQueryRetriever
    from langchain.chains.query_constructor.base import AttributeInfo

    metadata_field_info = [
        AttributeInfo(
            name="genre",
            description="The genre of the movie",
            type="string or list[string]",
        ),
        AttributeInfo(
            name="year",
            description="The year the movie was released",
            type="integer",
        ),
        AttributeInfo(
            name="director",
            description="The name of the movie director",
            type="string",
        ),
        AttributeInfo(
            name="rating", description="A 1-10 rating for the movie", type="float"
        ),
    ]
    document_content_description = "Brief summary of a movie"
    llm = OpenAI(temperature=0)
    retriever = SelfQueryRetriever.from_llm(
        llm, vectorstore, document_content_description, metadata_field_info, verbose=True
    )

    # only specifies a relevant query
    docs = retriever.get_relevant_documents("What are some movies about dreams?")

    # only specifies a filter
    docs = retriever.get_relevant_documents("I want to watch a movie rated higher than 8.5")
    # A psychologist / detective gets lost in a series of dreams within dreams within dreams and Inception reused the idea

    # specifies both a query and a filter
    docs = retriever.get_relevant_documents("Has Greta Gerwig directed any movies about women")
    for doc in docs:
        print(doc.page_content)</pre>
<p>自查询检索器会自动分析用户输入和元数据的关联关系，并生成过滤器：</p>
<p style="padding-left: 30px;">query='dreams' filter=None limit=None<br />query=' ' filter=Comparison(comparator=&lt;Comparator.GT: 'gt'&gt;, attribute='rating', value=8.5) limit=None<br />query='women' filter=Comparison(comparator=&lt;Comparator.EQ: 'eq'&gt;, attribute='director', value='Greta Gerwig') limit=None</p>
<p>自查询检索器仅仅能应对比较简单的查询。比如：I'm looking for Greta Gerwig directed any movies about women, or anyone directed movies about dreams，就无法生成恰当的查询和过滤器。</p>
<div class="blog_h3"><span class="graybg">时间加权检索</span></div>
<p>这种检索器，在相似度检索的基础上，让最近被检索器命中过的文档，获得优先级。算法如下：</p>
<p style="padding-left: 30px;">semantic_similarity + (1.0 - decay_rate) ^ hours_passed</p>
<p>decay_rate为衰退率，控制时间因子的影响大小。hours_passed表示上一次该文档被检索器访问以来已经过去的时间。衰退率的范围为0-1，可以看到，过低或者过高的衰退率，都会快速、很大程度上削弱时间的影响，从而等价于简单的相似度检索。</p>
<pre class="crayon-plain-tag">import faiss

from datetime import datetime, timedelta
from langchain.docstore import InMemoryDocstore
from langchain.embeddings import OpenAIEmbeddings
from langchain.retrievers import TimeWeightedVectorStoreRetriever
from langchain.schema import Document
from langchain.vectorstores import FAISS

# Define your embedding model
embeddings_model = OpenAIEmbeddings()
# Initialize the vectorstore as empty
embedding_size = 1536
index = faiss.IndexFlatL2(embedding_size)
vectorstore = FAISS(embeddings_model.embed_query, index, InMemoryDocstore({}), {})
retriever = TimeWeightedVectorStoreRetriever(vectorstore=vectorstore, decay_rate=.0000000000000000000000001, k=1)</pre>
<div class="blog_h3"><span class="graybg">基于向量存储 </span></div>
<p>从向量存储对象，都可以得到一个检索器：</p>
<pre class="crayon-plain-tag">retriever = db.as_retriever()</pre>
<p>使用这些检索器时，有一些通用的请求参数。</p>
<p>默认情况下，使用基于相似度的检索，可以改为基于最大边际相关性（MMR）的检索，如前文提到过的一样，MMR有利于去除重复的内容：</p>
<pre class="crayon-plain-tag">retriever = db.as_retriever(search_type="mmr")</pre>
<p>检索的时候，可以指定相似度分数的阈值：</p>
<pre class="crayon-plain-tag">retriever = db.as_retriever(search_type="similarity_score_threshold", search_kwargs={"score_threshold": .5})</pre>
<p>上述例子中，只有相似度评分高于0.5的文档被返回。</p>
<p>此外，你可以限定仅仅返回相似度最高的K个文档：</p>
<pre class="crayon-plain-tag">retriever = db.as_retriever(search_kwargs={"k": 1})</pre>
<div class="blog_h1"><span class="graybg">处理链 </span></div>
<p>对于简单的应用程序，仅仅使用一个语言模型（可能配合向量存储做信息检索）就足够了。更复杂的应用，可能需要联合使用多个语言模型或者调用任意的其它工具。</p>
<p>LangChain提供了Chain接口，用来编排对一系列工具的顺序化调用。这些工具可能包括LLM和其它Chain。这个接口非常简单：</p>
<pre class="crayon-plain-tag">class Chain(BaseModel, ABC):
    """Base interface that all chains should implement."""

    memory: BaseMemory
    callbacks: Callbacks

    def __call__(
        self,
        inputs: Any,
        return_only_outputs: bool = False,
        callbacks: Callbacks = None,
    ) -&gt; Dict[str, Any]:
        ...</pre>
<p>最简单的处理链的例子，我们在快速参考一章中介绍过，它将提示词模板和LLM调用链在一起。</p>
<p>&nbsp;</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/langchain">利用LangChain和语言模型交互</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/langchain/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>K8S集群跨云迁移</title>
		<link>https://blog.gmem.cc/k8s-migration</link>
		<comments>https://blog.gmem.cc/k8s-migration#comments</comments>
		<pubDate>Tue, 27 Dec 2022 11:37:50 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=39115</guid>
		<description><![CDATA[<p>要将K8S集群从一个云服务商迁移到另外一个，需要解决以下问题： 各种K8S资源的迁移 工作负载所挂载的数据卷的迁移 工作负载所使用的镜像的迁移 K8S资源、数据卷的迁移，可以考虑基于Velero项目。镜像仓库的迁移较为简单，可供选择的开源工具包括阿里云的image-syncer、腾讯云的image-transfer。 Velero 简介 Velero是一个专注于K8S集群备份与恢复的开源项目，通过备份源集群，并在目标集群进行恢复，即可完成集群的跨云迁移。 Velero由一组运行在被备份/恢复的K8S集群中的服务，外加一个CLI客户端组成。服务端包含若干控制器，这些控制器监听备份、恢复相关的自定义资源（CRD）并进行处理。CLI主要是提供了创建、修改、删除自定义资源的快捷方式，让用户不必编写复杂的YAML。 主要新特性 在Kubernetes故障检测和自愈一文中，我们对Velero做过调研，这三年多时间里，Velero新增加的特性包括： 多读写的持久卷不会被重复备份 云服务商插件从Velero代码库独立出 基于Restic的持久卷备份总是增量的，即使Pod被调度走 克隆命名空间（将备份恢复到另外一个命名空间）时自动克隆相关的持久卷 支持处理基于CSI驱动创建的持久卷，目前支持的厂商包括AWS、Azure、GCP 支持报告备份、恢复的进度 支持备份资源的所有API版本 支持自动基于Restic进行卷备份（--default-volumes-to-restic） 选项restoreStatus，用于定制那些资源的状态会被恢复 --existing-resource-policy用于修改默认的恢复策略。默认策略时当资源存在时不覆盖（除了ServiceAccount），该选项设置为update，则会更新已存在的资源 从1.10开始，支持Kopia，作为Restic的备选。Kopia在备份时消耗的时间较少、在备份数据量很大或者文件数量很多时，Kopia常常有更好的性能 <a class="read-more" href="https://blog.gmem.cc/k8s-migration">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/k8s-migration">K8S集群跨云迁移</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><p>要将K8S集群从一个云服务商迁移到另外一个，需要解决以下问题：</p>
<ol style="list-style-type: undefined;">
<li>各种K8S资源的迁移</li>
<li>工作负载所挂载的数据卷的迁移</li>
<li>工作负载所使用的镜像的迁移</li>
</ol>
<p>K8S资源、数据卷的迁移，可以考虑基于<a href="https://velero.io/">Velero项目</a>。镜像仓库的迁移较为简单，可供选择的开源工具包括阿里云的<a href="https://github.com/AliyunContainerService/image-syncer">image-syncer</a>、腾讯云的<a href="https://github.com/tkestack/image-transfer">image-transfer</a>。</p>
<div class="blog_h1"><span class="graybg">Velero</span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>Velero是一个专注于K8S集群备份与恢复的开源项目，通过备份源集群，并在目标集群进行恢复，即可完成集群的跨云迁移。</p>
<p>Velero由一组运行在被备份/恢复的K8S集群中的服务，外加一个CLI客户端组成。服务端包含若干控制器，这些控制器监听备份、恢复相关的自定义资源（CRD）并进行处理。CLI主要是提供了创建、修改、删除自定义资源的快捷方式，让用户不必编写复杂的YAML。</p>
<div class="blog_h3"><span class="graybg">主要新特性</span></div>
<p>在<a href="/problem-detection-and-auto-repairing-in-k8s#velero">Kubernetes故障检测和自愈</a>一文中，我们对Velero做过调研，这三年多时间里，Velero新增加的特性包括：</p>
<ol style="list-style-type: undefined;">
<li>多读写的持久卷不会被重复备份</li>
<li>云服务商插件从Velero代码库独立出</li>
<li>基于Restic的持久卷备份总是增量的，即使Pod被调度走</li>
<li>克隆命名空间（将备份恢复到另外一个命名空间）时自动克隆相关的持久卷</li>
<li>支持处理基于CSI驱动创建的持久卷，目前支持的厂商包括AWS、Azure、GCP</li>
<li>支持报告备份、恢复的进度</li>
<li>支持备份资源的所有API版本</li>
<li>支持自动基于Restic进行卷备份（--default-volumes-to-restic）</li>
<li>选项restoreStatus，用于定制那些资源的状态会被恢复</li>
<li>--existing-resource-policy用于修改默认的恢复策略。默认策略时当资源存在时不覆盖（除了ServiceAccount），该选项设置为update，则会更新已存在的资源</li>
<li>从1.10开始，支持Kopia，作为Restic的备选。Kopia在备份时消耗的时间较少、在备份数据量很大或者文件数量很多时，Kopia常常有更好的性能</li>
</ol>
<div class="blog_h3"><span class="graybg">备份流程</span></div>
<p>备份分为两种方式：按需备份、周期性备份。它们都是收集（支持过滤）K8S资源并打包上传到存储后端（例如云服务商的对象存储）。</p>
<p>备份的典型流程如下：</p>
<ol>
<li>用户通过命令<pre class="crayon-plain-tag">velero backup create</pre>创建<pre class="crayon-plain-tag">Backup</pre>资源</li>
<li>控制器<pre class="crayon-plain-tag">BackupController</pre>监听到新创建的Backup资源，并进行校验</li>
<li>校验成功后BackupController开始执行备份操作。默认情况下会为所有持久卷创建快照，使用命令行选项<pre class="crayon-plain-tag">--snapshot-volumes=false</pre>改变此默认行为</li>
<li>BackupController调用对象存储，上传备份文件</li>
</ol>
<p>备份资源的时候，Velero使用<span style="background-color: #c0c0c0;">首选API版本（preferred version）获取和保存资源</span>。举例来说，源API Server中teleport组具有v1alpha1、v1两个版本，其中v1是首选版本，那么Velero备份的时候将使用v1格式存储资源。在恢复的时候，目标集群必须支持teleport/v1版本（但不必是首选版本），这意味着，<span style="background-color: #c0c0c0;">恢复到和源的版本不同的K8S集群可能会出错，因为API版本会有新增或删除</span>。</p>
<p>备份的时候，可以通过--ttl来指定备份的存留时间。当存留时间到达后，备份中的K8S资源、备份文件、快照、关联的Restore，均被删除。如果删除失败Velero会给Backup对象添加velero.io/gc-failure=REASON标签。</p>
<p>需要注意：<span style="background-color: #c0c0c0;">Velero基于快照的卷备份，在跨云迁移这一场景下，是没有意义的，不可能把云A生成的快照拿到云T上恢复</span>。</p>
<div class="blog_h3"><span class="graybg">恢复流程</span></div>
<p>恢复是将先前生成的备份（包括K8S资源 + 数据卷），同步到目标K8S集群的过程。<span style="background-color: #c0c0c0;">目标集群可以是源集群自身</span>，可以<span style="background-color: #c0c0c0;">对备份中的资源进行过滤</span>，仅恢复一部分数据。</p>
<p>恢复产生的K8S资源，具有velero.io/restore-name=RESTORE_NAME标签。RESTORE_NAME即Restore资源的名字，默认形式为BACKUP_NAME-TIMESTAMP，TIMESTAMP的默认格式为YYYYMMDDhhmmss。</p>
<p>恢复的典型流程如下：</p>
<ol>
<li>用户通过命令<pre class="crayon-plain-tag">velero restore create</pre>创建<pre class="crayon-plain-tag">Restore</pre>资源</li>
<li>控制器<pre class="crayon-plain-tag">RestoreController</pre>监听到新创建的Restore资源，并进行校验</li>
<li>校验成功后，RestoreController从对象存储中获取备份的信息，对备份的资源进行一些预处理，例如API版本检查，以保证它们能够在新集群中运行</li>
<li>开始逐个资源的进行恢复</li>
</ol>
<p><span style="background-color: #c0c0c0;">默认情况下，Velero不会对目标集群进行任何修改、删除操作</span>，如果某个资源已经存在，它会简单的跳过。设置 --existing-resource-policy=update则Velero会尝试修改已经存在的资源，使其和备份中的同名资源内容匹配。</p>
<div class="blog_h3"><span class="graybg">关于对象存储</span></div>
<p>保存备份信息的<span style="background-color: #c0c0c0;">对象存储，是Velero的单一数据源</span>（source of truth），也就是说：</p>
<ol>
<li>如果对象存储中具有备份信息，但是K8S API资源中没有对应的Backup对象，那么会自动创建这些对象</li>
<li>如果K8S中有Backup对象，但是在对象存储中没有对应信息，那么此Backup会被删除</li>
</ol>
<p>这一特性也让跨云迁移场景获益 —— 源、目标集群不需要任何直接的关联，云服务商的对象存储服务将作为唯一的媒介。</p>
<p>定义对象存储位置的CRD是<pre class="crayon-plain-tag">BackupStorageLocation</pre>，它可以指向一个桶，或者桶中的某个前缀，<span style="background-color: #c0c0c0;">Velero备份的元数据数据存放在其中</span>。<span style="background-color: #c0c0c0;">通过文件系统方式（Restic/Kopia）备份的卷，也存放在桶中</span>。通过快照方式备份的卷的数据，其存储机制是云服务商私有的，不会存放到桶中。</p>
<p>每个Backup可以使用一个BackupStorageLocation。</p>
<div class="blog_h3"><span class="graybg">关于快照</span></div>
<p>存储卷快照相关的信息存储存储在<pre class="crayon-plain-tag">VolumeSnapshotLocation</pre>中，由于快照实现技术完全取决于云服务商，因此该CRD的包含的字段取决于对应插件。</p>
<p>每个Backup，对于任何一个Volume Provider，可以使用一个VolumeSnapshotLocation。</p>
<div class="blog_h3"><span class="graybg">关于提供者</span></div>
<p>Velero提供了一种插件机制，将存储提供者从Velero核心中独立出来。</p>
<div class="blog_h3"><span class="graybg">钩子机制</span></div>
<p>Velero提供了若干钩子来扩展标准的备份/恢复流程。</p>
<p>备份钩子在备份流程中执行。例如你可以利用备份钩子，在创建快照前，通知数据库刷空内存缓冲。</p>
<p>恢复钩子在恢复流程中执行。例如你可以在应用启动前，通过钩子进行某种初始化操作。</p>
<div class="blog_h2"><span class="graybg">安装</span></div>
<div class="blog_h3"><span class="graybg">安装CLI</span></div>
<p>目前最新稳定版本是<a href="https://github.com/vmware-tanzu/velero/releases/tag/v1.9.5">1.9.5</a>，点击此连接，选择匹配操作系统的压缩包，下载解压，存放velero到$PATH即可。执行下面的命令配置自动提示：</p>
<pre class="crayon-plain-tag">echo 'source &lt;(velero completion bash)' &gt;&gt;~/.bashrc </pre>
<p>下面的命令实例，展示了如何进行客户端设置：</p>
<pre class="crayon-plain-tag"># 启用客户端特性
velero client config set features=EnableCSI
# 关闭彩色字符输出
velero client config set colorized=false</pre>
<div class="blog_h3"><span class="graybg">安装服务端</span></div>
<p>使用CLI安装：</p>
<pre class="crayon-plain-tag">velero install 
    # 指定命名空间
    --namespace=teleport-system
    # 支持基于文件系统的卷备份（FSB）
    --use-node-agent
    # 默认启用基于文件系统的卷备份，来备份所有Pod卷。缺省行为是必须指定注解才备份
    # backup.velero.io/backup-volumes=YOUR_VOLUME_NAME_1,YOUR_VOLUME_NAME_2,...
    # 如果准备使用FSB作为唯一的卷备份机制，可以开启
    # 启用该命令后，如果还想在某次备份时，使用快照备份，使用命令 backup create --snapshot-volumes
    # 也可以在备份时使用backup create --default-volumes-to-fs-backup，而不是在安装阶段全局性设置
    --default-volumes-to-fs-backup
    # 特性开关，这些开关也会被传递给node-agent
    --features=EnableCSI,EnableAPIGroupVersions
    # 设置资源请求/限制。默认值对于1000-资源、100GB-卷的场景足够。如果使用FSB，则可能需要增加资源请求/限制
    --velero-pod-cpu-request
    --velero-pod-mem-request
    --velero-pod-cpu-limit
    --velero-pod-mem-limit
    --node-agent-pod-cpu-request
    --node-agent-pod-mem-request
    --node-agent-pod-cpu-limit
    --node-agent-pod-mem-limit
    # 在安装阶段，你可以指定一个BackupLocation和SnapshotLocation
    --provider aws
    --bucket backups 
    --secret-file ./aws-iam-creds 
    --backup-location-config region=us-east-2 
    --snapshot-location-config region=us-east-2
    # 如果在安装阶段，不配置BackupLocation，可以不指定--bucket和--provider，同时：
    --no-default-backup-location
    # 仅仅生成YAML
    --dry-run -o yaml</pre>
<p>在安装完毕后，你可以设置默认的 BackupLocation、SnapshotLocation：</p>
<pre class="crayon-plain-tag">velero backup-location create backups-primary \
    --provider aws \
    --bucket velero-backups \
    --config region=us-east-1 \
    --default

# SnapshotLocation需要为每一个volume snapshot provider设置
velero server --default-volume-snapshot-locations="PROVIDER-NAME:LOCATION-NAME,PROVIDER2-NAME:LOCATION2-NAME"</pre>
<p>在安装完毕后，可以添加额外的volume snapshot provider：</p>
<pre class="crayon-plain-tag">velero plugin add registry/image:version
# 为此volume snapshot provider配置SnapshotLocation
velero snapshot-location create NAME -provider PROVIDER-NAME [--config PROVIDER-CONFIG]</pre>
<div class="blog_h2"><span class="graybg">测试跨云迁移</span></div>
<p>我们分别在阿里云、腾讯云上创建一个K8S集群，分别作为源、目标集群，并尝试利用Velero将工作负载从源迁移到目标。</p>
<div class="blog_h3"><span class="graybg">创建集群</span></div>
<p>到云服务商的控制台创建，这里不赘述具体步骤。</p>
<div class="blog_h3"><span class="graybg">无状态工作负载迁移</span></div>
<p>目前K8S集群的主流用法是运行无状态的工作负载。数据库等有状态的基础服务，大多数用户选择使用云平台提供的PaaS产品。这一现状实质上让K8S的迁移变得简单，因为基本不需要考虑数据卷的问题。</p>
<p>这里以一个Nginx的Deployment + Service为例，测试Velero的备份、恢复特性。首先在源集群创建相应K8S资源：</p>
<pre class="crayon-plain-tag">apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - image: nginx:1.7.9
        name: nginx
        ports:
        - containerPort: 80

---

apiVersion: v1
kind: Service
metadata:
  labels:
    app: nginx
  name: nginx
spec:
  ports:
  - port: 80
    targetPort: 80
  selector:
    app: nginx
  type: LoadBalancer</pre>
<p>&nbsp;</p>
<p>&nbsp;</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/k8s-migration">K8S集群跨云迁移</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/k8s-migration/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Terraform快速参考</title>
		<link>https://blog.gmem.cc/terraform</link>
		<comments>https://blog.gmem.cc/terraform#comments</comments>
		<pubDate>Wed, 20 Oct 2021 02:15:51 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[IaaS]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=38551</guid>
		<description><![CDATA[<p>简介 Terraform用于实现基础设施即代码（infrastructure as code）—— 通过代码（配置文件）来描述基础设施的拓扑结构，并确保云上资源和此结构完全对应。Terraform有三个版本，我们主要关注Terraform CLI。 Terraform CLI主要包含以下组件： 命令行前端 Terraform Language（以下简称TL，衍生自HashiCorp配置语言HCL）编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”（Configuration）来表示一整套描述基础设施的Terraform配置文件 针对各种云服务商的驱动（Provider），实现云资源的创建、更新和删除 云上资源不单单包括基础的IaaS资源，还可以是DNS条目、SaaS资源。事实上，通过开发Provider，你可以用Terraform管理任何资源。 Terraform会检查配置文件，并生成执行计划。计划描述了那些资源需要被创建、修改或删除，以及这些资源之间的依赖关系。Terraform会尽可能并行的对资源进行变更。当你更新了配置文件后，Terraform会生成增量的执行计划。 命令行 安装命令行 直接到https://www.terraform.io/downloads.html下载，存放到$PATH下即可。 基本特性 切换工作目录 使用选项 [crayon-69d31392d8efa562090938-i/] Shell自动完成 使用[crayon-69d31392d8eff748643268-i/]安装自动完成脚本，使用[crayon-69d31392d8f02083927643-i/]删除自动完成脚本。 <a class="read-more" href="https://blog.gmem.cc/terraform">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/terraform">Terraform快速参考</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1" style="padding-left: 30px;"><span class="graybg">简介</span></div>
<p>Terraform用于实现基础设施即代码（infrastructure as code）—— <span style="background-color: #c0c0c0;">通过代码（配置文件）来描述基础设施的拓扑结构</span>，并确保云上资源和此结构完全对应。Terraform有三个版本，我们主要关注Terraform CLI。</p>
<p>Terraform CLI主要包含以下组件：</p>
<ol>
<li>命令行前端</li>
<li>Terraform Language（以下简称TL，衍生自HashiCorp配置语言HCL）编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”（Configuration）来表示一整套描述基础设施的Terraform配置文件</li>
<li>针对各种云服务商的驱动（Provider），实现云资源的创建、更新和删除</li>
</ol>
<p>云上资源不单单包括基础的IaaS资源，还可以是DNS条目、SaaS资源。事实上，<span style="background-color: #c0c0c0;">通过开发Provider，你可以用Terraform管理任何资源</span>。</p>
<p>Terraform会检查配置文件，并生成执行计划。<span style="background-color: #c0c0c0;">计划描述了那些资源需要被创建、修改或删除，以及这些资源之间的依赖关系</span>。Terraform会<span style="background-color: #c0c0c0;">尽可能并行</span>的对资源进行变更。当你更新了配置文件后，Terraform会生成增量的执行计划。</p>
<div class="blog_h1"><span class="graybg">命令行</span></div>
<div class="blog_h2"><span class="graybg">安装命令行</span></div>
<p>直接到<a href="https://www.terraform.io/downloads.html">https://www.terraform.io/downloads.html</a>下载，存放到$PATH下即可。</p>
<div class="blog_h2"><span class="graybg">基本特性</span></div>
<div class="blog_h3"><span class="graybg">切换工作目录</span></div>
<p>使用选项 <pre class="crayon-plain-tag">-chdir=DIR</pre></p>
<div class="blog_h3"><span class="graybg">Shell自动完成</span></div>
<p>使用<pre class="crayon-plain-tag">terraform -install-autocomplete</pre>安装自动完成脚本，使用<pre class="crayon-plain-tag">terraform -uninstall-autocomplete</pre>删除自动完成脚本。</p>
<div class="blog_h2"><span class="graybg">资源地址</span></div>
<p>很多子命令接受资源地址参数，下面是一些例子：</p>
<pre class="crayon-plain-tag"># 资源类型.资源名
aws_instance.foo
# 资源类型.资源列表名[索引]
aws_instance.bar[1]
# 子模块foo的子模块bar中的
module.foo.module.bar.aws_instance.baz</pre>
<div class="blog_h2"><span class="graybg">配置文件</span></div>
<p>配置文件的路径可以通过环境变量<pre class="crayon-plain-tag">TF_CLI_CONFIG_FILE</pre>设置。非Windows系统中，<pre class="crayon-plain-tag">$HOME/.terraformrc</pre>为默认配置文件路径。配置文件语法类似于TF文件：</p>
<pre class="crayon-plain-tag"># provider缓存目录
plugin_cache_dir   = "$HOME/.terraform.d/plugin-cache"
# 
disable_checkpoint = true

# 存放凭证信息，包括模块仓库、支持远程操作的系统的凭证
credentials "app.terraform.io" {
  token = "xxxxxx.atlasv1.zzzzzzzzzzzzz"
}

# 改变默认安装逻辑
provider_installation {
  # 为example.com提供本地文件系统镜像，这样安装example.com/*/*的provider时就不会去网络上请求
  # 默认路径是：
  # ~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target}
  # 例如：
  # ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1
  filesystem_mirror {
    path    = "/usr/share/terraform/providers"
    include = ["example.com/*/*"]
  }
  direct {
    exclude = ["example.com/*/*"]
  }
  
  # Terraform会在terraform init的时候，校验Provider的版本和checksum。Provider从Registry或者本地
  # 目录下载Provider。当我们开发Provider的时候，常常需要方便的测试临时Provider版本，这种Provider还
  # 没有关联版本号，也没有在Registry中注册Chencksum
  # 为了简化开发，可以配置dev_overrides，它能覆盖所有配置的安装方法
  dev_overrides {
    "hashicorp.com/edu/hashicups-pf" = "$(go env GOBIN)"
  }
}</pre>
<div class="blog_h2"><span class="graybg">init</span></div>
<p>配置工作目录，为使用其它命令做好准备。</p>
<p>Terraform命令需要在一个编写了Terraform配置文件的目录（配置根目录）下执行，它会在此目录下存储设置、缓存插件/模块，以及（默认使用Local后端时）存储状态数据。此目录必须进行初始化。</p>
<p>初始化后，会生成以下额外目录/文件：</p>
<p style="padding-left: 30px;">.terraform目录，用于缓存provider和模块<br />如果使用Local后端，保存状态的terraform.tfstate文件。如果使用多工作区，则是terraform.tfstate.d目录。</p>
<p>对配置的某些变更，需要<span style="background-color: #c0c0c0;">重新运行初始化，包括provider需求的变更、模块源/版本约束的变更、后端配置的变更</span>。需要重新初始化时，其它命令可能会无法执行并提示你进行初始化。</p>
<p>命令<pre class="crayon-plain-tag">terraform get</pre>可以仅仅下载依赖的模块，而不执行其它init子任务。</p>
<p>运行 <pre class="crayon-plain-tag">terraform init -upgrade</pre>会强制拉取最新的、匹配约束的版本并更新依赖锁文件。</p>
<div class="blog_h2"><span class="graybg">validate</span></div>
<p>校验配置是否合法。</p>
<div class="blog_h2"><span class="graybg">plan</span></div>
<p>显示执行计划，即当前配置将请求（结合state）哪些变更。Terraform的核心功能时创建、修改、删除基础设施对象，使基础设施的状态和当前配置匹配。当我们说运行Terraform时，主要是指plan/apply/destroy这几个命令。</p>
<p>terraform plan命令评估当前配置，确定其声明的所有资源的期望状态。然后比较此期望状态和真实基础设施的当前状态。它<span style="background-color: #c0c0c0;">使用state来确定哪些真实基础设施对象和声明资源的对应关系，并且使用provider的API查询每个资源的当前状态</span>。当确定到达期望状态需要执行哪些变更后，Terraform将其打印到控制台，它并不会执行任何实际的变更操作。</p>
<div class="blog_h3"><span class="graybg">保存计划</span></div>
<p>terraform plan命令得到的计划可以保存起来，并被后续的terraform apply使用：</p>
<pre class="crayon-plain-tag">terraform plan -out=FILE </pre>
<div class="blog_h3"><span class="graybg">计划模式</span></div>
<p>plan命令支持两种备选的工作模式：</p>
<ol>
<li>销毁模式：创建一个计划，其目标是销毁所有当前存在于配置中的远程对象，留下一个空白的state。对应选项<pre class="crayon-plain-tag">-destroy</pre></li>
<li>仅刷新模式：创建一个计划，其目标仅仅是<span style="background-color: #c0c0c0;">更新state和根模块的输出值</span>，以便<span style="background-color: #c0c0c0;">和从Terraform之外对基础设施对象的变更匹配</span>。对应选项<pre class="crayon-plain-tag">-refresh-only</pre></li>
</ol>
<div class="blog_h3"><span class="graybg">指定输入变量</span></div>
<p>使用选项<pre class="crayon-plain-tag">-var 'NAME=VALUE'</pre>可以指定输入变量，该选项可以使用多次。</p>
<p>使用选项<pre class="crayon-plain-tag">-var-file=FILENAME</pre>可以从文件读取输入变量，某些文件会自动读取，参考<a href="#input-vars">输入变量</a>一节。</p>
<div class="blog_h3"><span class="graybg">并发度</span></div>
<p>选项<pre class="crayon-plain-tag">-parallelism=n</pre>限制操作最大并行度，默认10。</p>
<div class="blog_h3"><span class="graybg">其它选项</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">选项</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>-refresh=false</td>
<td>
<p>默认情况下，Terraform在检查配置变更之前，会将state和远程基础设施进行同步。此选项禁用此行为</p>
<p>该选项可能会提升性能，因为减少了远程API请求数量。但是可能会无法识别某些Terraform外部对基础设施资源的变更</p>
</td>
</tr>
<tr>
<td>-replace=ADDRESS</td>
<td>
<p>提示Terraform去计划替换掉具有指定ADDRESS的单个资源。对于0.15.2+可用，老版本可以使用terraform taint命令代替</p>
<p>ADDRESS就是针对某个资源实例的引用表达式，例如<pre class="crayon-plain-tag">aws_instance.example[0]</pre></p>
</td>
</tr>
<tr>
<td>-target=ADDRESS</td>
<td>提示Terraform仅仅针对指定ADDRESS的资源（以及它依赖的资源）指定执行计划</td>
</tr>
<tr>
<td>-input=false</td>
<td>禁止Terraform交互式的提示用户提供根模块的输入变量，对于批处理方式运行Terraform很重要</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">apply</span></div>
<p>应用执行计划，创建、更新设施对象。</p>
<p>apply会做plan的任何事情，并在其基础上，直接执行变更操作。默认情况下，apply即席的执行一次plan，你也可以直接使用已保存的plan</p>
<p>命令格式： <pre class="crayon-plain-tag">terraform apply [options] [plan file]</pre></p>
<div class="blog_h3"><span class="graybg">自动确认</span></div>
<p>选项<pre class="crayon-plain-tag">-auto-approve</pre>可以自动确认并执行所需操作，不需要人工确认。</p>
<div class="blog_h3"><span class="graybg">使用已有计划</span></div>
<p>如果指定plan file参数，则读取先前保存的计划并执行。</p>
<div class="blog_h3"><span class="graybg">计划模式</span></div>
<p>支持plan命令中关于计划模式的选项。</p>
<div class="blog_h3"><span class="graybg">其它选项</span></div>
<p>-input=false、-parallelism=n等选项含义和plan命令相同。特有选项：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">选项</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>-lock-timeout=DURATION</td>
<td>对状态加锁的最大时间</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">destroy</span></div>
<p>删除先前创建的基础设施对象。</p>
<p>当前配置（+工作区）管理的所有资源都会被删除，destroy会使用状态数据确定哪些资源需要删除。</p>
<div class="blog_h2"><span class="graybg">console</span></div>
<p>在交互式命令中估算Terraform表达式。</p>
<div class="blog_h2"><span class="graybg">fmt</span></div>
<p>格式化配置文件</p>
<div class="blog_h2"><span class="graybg">force-unlock</span></div>
<p>强制解除当前工作区的状态锁。当其它terraform进程锁定状态后，没有正常解锁时使用。如果其它进程仍然在运作，可能导致状态不一致。</p>
<div class="blog_h2"><span class="graybg">get</span></div>
<p>安装或升级远程Terraform模块。格式：<pre class="crayon-plain-tag">terraform get [options] PATH</pre>。</p>
<p>选项：</p>
<p style="padding-left: 30px;">-update 检查已经下载的模块的新版本，如果存在匹配约束的新版本则更新<br />-no-color 禁用彩色输出</p>
<div class="blog_h2"><span class="graybg">graph</span></div>
<p>生成操作中包含步骤的图形化表示。</p>
<div class="blog_h2"><span class="graybg">import</span></div>
<p>导入现有的基础设施对象，让其关联到配置中的资源定义。</p>
<div class="blog_h2"><span class="graybg">login</span></div>
<p>获取并保存远程服务（例如模块私服）的登录凭证。</p>
<div class="blog_h2"><span class="graybg">logout</span></div>
<p>删除远程服务（例如模块私服）的登录凭证。</p>
<div class="blog_h2"><span class="graybg">output</span></div>
<p>显示根模块的输出值。格式：<pre class="crayon-plain-tag">terraform output [options] [NAME]</pre></p>
<div class="blog_h2"><span class="graybg">providers</span></div>
<p>显示此模块依赖的providers</p>
<div class="blog_h2"><span class="graybg">refresh</span></div>
<p>更新状态，使其和远程基础设施匹配。</p>
<div class="blog_h2"><span class="graybg">show</span></div>
<p>显示当前状态或保存的执行计划。</p>
<div class="blog_h2"><span class="graybg">taint</span></div>
<p>将资源实例标记为“非功能完备（fully functional）”的。</p>
<p>所谓<span style="background-color: #c0c0c0;">非功能完备，通常意味着资源创建过程出现问题，存在部分失败</span>。此外taint子命令也可以强制将资源标记为非功能完备。</p>
<p>因为上述两种途径，进入tainted状态的资源，<span style="background-color: #c0c0c0;">不会立即影响基础设施对象。但是在下一次的plan中，会销毁并重新创建对应基础设施对象</span>。</p>
<p>命令格式：<pre class="crayon-plain-tag">terraform [global options] taint [options] &lt;address&gt;</pre></p>
<div class="blog_h2"><span class="graybg">untaint</span></div>
<p>解除资源的tainted状态。</p>
<div class="blog_h2"><span class="graybg">workspace</span></div>
<p>管理和切换工作区。</p>
<div class="blog_h1"><span class="graybg">TL语言</span></div>
<div class="blog_h2"><span class="graybg">块</span></div>
<p>配置文件由若干块（Block）组成，块的语法如下：</p>
<pre class="crayon-plain-tag"># Block header, which identifies a block
&lt;BLOCK TYPE&gt; "&lt;BLOCK LABEL&gt;" "&lt;BLOCK LABEL&gt;" "..." {
  # Block body
  &lt;IDENTIFIER&gt; = &lt;EXPRESSION&gt; # Argument
}</pre>
<p>块是一个容器，它的作用取决于块的类型。块常常用来描述某个资源的配置。</p>
<p>取决于块的类型，<span style="background-color: #c0c0c0;">标签的数量可以是0-N个</span>。对于resource块，标签数量为两个。某些特殊的块，可能支持任意数量的标签。某些内嵌的块，例如<span style="color: #1d1e23;">network_interface，则不支持标签。</span></p>
<p>块体中可以<span style="background-color: #c0c0c0;">包含若干参数（Argument），或者其它内嵌的块</span>。参数用于将一个表达式分配到一个标识符，常常对应某个资源的一条属性。表达式可以是字面值，或者引用其它的值，正是这种引用让Terraform能够识别资源依赖关系。</p>
<p>直接位于配置文件最外层的块，叫做顶级块（Top-level Block），<span style="background-color: #c0c0c0;">Terraform支持有限种类的顶级块</span>。大部分Terraform特性，例如resource，基于顶级块实现。</p>
<p>下面是一个例子：</p>
<pre class="crayon-plain-tag">resource "aws_vpc" "main" {
  cidr_block = var.base_cidr_block
}</pre>
<div class="blog_h2"><span class="graybg">参数</span></div>
<p>在HCL语言中，Argument被称作Attribute。但是在TL中，Attribute术语另有它用。例如<span style="background-color: #c0c0c0;">各种resource都具有一个名为id的属性，它可以被参数表达式饮用，但是不能被赋值（因而不是参数）</span>。</p>
<p>参数其实一个赋值表达式，它将一个值分配给一个名称。</p>
<p>上下文决定了哪些参数可用，其类型是什么。不同的资源（由resource标签识别）支持不同的参数集。</p>
<div class="blog_h2"><span class="graybg">标识符</span></div>
<p>参数名、块类型名、大部分Terraform特有结构的名字（例如resource名，即其第二标签），都是标识符。</p>
<p>标识符由字母、数字、-、_组成，第一个字符不能是数字。</p>
<div class="blog_h2"><span class="graybg">注释</span></div>
<p><pre class="crayon-plain-tag">#</pre>或者<pre class="crayon-plain-tag">//</pre>开头的是单行注释。<pre class="crayon-plain-tag">/**/</pre>作为多行注释的边界。 </p>
<div class="blog_h2"><span class="graybg">数据类型</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 100px; text-align: center;">类型</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td class=" blog_h3">string</td>
<td>
<p style="text-align: left;">Unicode字符序列，基本形式 <pre class="crayon-plain-tag">"hello"</pre></p>
</td>
</tr>
<tr>
<td class=" blog_h3">number</td>
<td>数字，形式<pre class="crayon-plain-tag">6.02</pre></td>
</tr>
<tr>
<td class=" blog_h3">bool</td>
<td><pre class="crayon-plain-tag">true</pre>或<pre class="crayon-plain-tag">false</pre></td>
</tr>
<tr>
<td class=" blog_h3">list/tuple</td>
<td style="text-align: left;">一系列的值，形式<span style="color: #1d1e23;"><pre class="crayon-plain-tag">["us-west-1a", "us-west-1c"]</pre></span></td>
</tr>
<tr>
<td class=" blog_h3">map/object</td>
<td style="text-align: left;">键值对，形式<pre class="crayon-plain-tag">{name = "Mabel", age = 52}</pre></td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">空值</span></div>
<p style="text-align: left;">空值使用<pre class="crayon-plain-tag">null</pre>表示。</p>
<div class="blog_h2"><span class="graybg">字符串和模板</span></div>
<div class="blog_h3"><span class="graybg">转义字符</span></div>
<p>转义字符：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">\n</pre> 换行<br /><pre class="crayon-plain-tag">\r</pre> 回车<br /><pre class="crayon-plain-tag">\t</pre> 制表<br /><pre class="crayon-plain-tag">\"</pre> 引号<br /><pre class="crayon-plain-tag">\\</pre> 反斜杠<br /><pre class="crayon-plain-tag">\uNNNN</pre> Unicode字符<br /><pre class="crayon-plain-tag">\UNNNNNNNN</pre> Unicode字符</p>
<p>注意，在Heredoc中反斜杠不用于转义，可以使用：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">$${</pre> 字符串插值标记${<br /><pre class="crayon-plain-tag">%%{</pre> 模板指令标记%{</p>
<div class="blog_h3"><span class="graybg">Heredoc</span></div>
<p>支持Unix的Heredoc风格的字符串：</p>
<pre class="crayon-plain-tag">block {
  value = &lt;&lt;EOT
hello
world
EOT
}</pre>
<p>Heredoc还支持缩进编写：</p>
<pre class="crayon-plain-tag">block {
  value = &lt;&lt;-EOT
  hello
    world
  EOT
}</pre>
<div class="blog_h3"><span class="graybg">JSON和YAML</span></div>
<p>要将对象转换为JSON或YAML，可以调用函数：</p>
<pre class="crayon-plain-tag">example = jsonencode({
    a = 1
    b = "hello"
})</pre>
<div class="blog_h3"><span class="graybg">字符串模板</span></div>
<p>不管在普通字符串格式，还是Heredoc中，都可以使用字符串模板。模板需要包围在<pre class="crayon-plain-tag">${</pre> 或<pre class="crayon-plain-tag">%{}</pre>中：</p>
<pre class="crayon-plain-tag"># ${ ... } 中包含的是表达式
"Hello, ${var.name}!"


# %{ ... } 则定义了一条模板指令，可以用于实现条件分支或循环

# %{if &lt;BOOL&gt;}/%{else}/%{endif} 条件分支
"Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!"

# %{for &lt;NAME&gt; in &lt;COLLECTION&gt;} / %{endfor} 循环
&lt;&lt;EOT
%{ for ip in aws_instance.example.*.private_ip }
server ${ip}
%{ endfor }
EOT</pre>
<div class="blog_h3"><span class="graybg">空白符去除 </span></div>
<p style="text-align: left;">可以在模板指令开始或结尾添加<pre class="crayon-plain-tag">~</pre>，用于去除前导或后缀的空白符：</p>
<pre class="crayon-plain-tag">&lt;&lt;EOT
%{ for ip in aws_instance.example.*.private_ip ~} # 这后面的换行符被~去除
server ${ip}  # 这后面的换行符被保留
%{ endfor ~}
EOT</pre>
<div class="blog_h2"><span class="graybg">引用</span></div>
<div class="blog_h3"><span class="graybg">资源</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">&lt;RESOURCE TYPE&gt;.&lt;NAME&gt;</pre>引用指定类型、名称的资源。 其值可能是：</p>
<ol>
<li>如果资源没有count/for_each参数，那么值是一个object，可以访问资源的属性</li>
<li>如果资源使用count参数，那么值是list</li>
<li>如果资源使用for_each参数，那么值是map</li>
</ol>
<div class="blog_h3"><span class="graybg">输入变量</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">var.&lt;NAME&gt;</pre>引用指定名称的输入变量。如果变量使用type参数限定其类型，对此引用进行赋值时，Terraform会自动进行必要的类型转换。</p>
<div class="blog_h3"><span class="graybg">本地值</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">local.&lt;NAME&gt;</pre>引用指定名称的本地值。本地值可以引用其它本地值，<span style="background-color: #c0c0c0;">甚至是在同一个local块中，唯一的限制就是不得出现循环依赖</span>。</p>
<div class="blog_h3"><span class="graybg">子模块输出</span></div>
<p><pre class="crayon-plain-tag">module.&lt;MODULE NAME&gt;</pre>引用指定子模块声明的结果输出。如果module块（表示对目标模块的调用）没有count/for_each参数，则其值是一个object，可以直接访问某个输出值：<pre class="crayon-plain-tag">module.&lt;MODULE NAME&gt;.&lt;OUTPUT NAME&gt;</pre></p>
<p style="text-align: left;">如果module块使用了for_each，则引用的值是一个map，其key是for_each中的每个key，其值是子模块输出。</p>
<p style="text-align: left;">如果module块使用了count，则引用的值是一个list，其元素是子模块输出。</p>
<div class="blog_h3"><span class="graybg">数据源</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">data.&lt;DATA TYPE&gt;.&lt;NAME&gt;</pre>引用指定类型的数据资源，取决于数据资源是否使用count/foreach，其值可能是object/list/map。</p>
<div class="blog_h3"><span class="graybg">文件系统信息</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">path.module</pre> 表达式所在模块的路径。</p>
<p style="text-align: left;"><pre class="crayon-plain-tag">path.root</pre>表示当前配置根模块的路径。</p>
<p style="text-align: left;"><pre class="crayon-plain-tag">path.cwd</pre> 表示当前工作模块的路径，默认情况下和path.root一样。但是Terraform CLI可以指定不同的工作目录。</p>
<div class="blog_h3"><span class="graybg">工作区信息</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">terraform.workspace</pre>是当前选择的工作区的名称。</p>
<div class="blog_h3"><span class="graybg">块内特殊引用</span></div>
<p style="text-align: left;">在特定的块内部，在特定的上下文下（例如使用count/for_each的情况下），可以引用一些特殊值：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">count.index</pre> 使用count原参数时，表示当前索引<br /><pre class="crayon-plain-tag">each.key</pre> / <pre class="crayon-plain-tag">each.value</pre> 使用for_each原参数时，表示当前迭代的键值<br /><pre class="crayon-plain-tag">self</pre>可以在provisioner和connection块中使用，表示当前上下文资源</p>
<div class="blog_h2"><span class="graybg">操作符</span></div>
<p style="text-align: left;">逻辑操作符：<pre class="crayon-plain-tag">!</pre> <pre class="crayon-plain-tag">&amp;&amp;</pre> <pre class="crayon-plain-tag">||</pre><br />算数操作符：<pre class="crayon-plain-tag">*</pre> <pre class="crayon-plain-tag">/</pre> <pre class="crayon-plain-tag">%</pre> <pre class="crayon-plain-tag">+</pre> <pre class="crayon-plain-tag">-</pre><br />比较操作符：<pre class="crayon-plain-tag">&gt;, &gt;=, &lt;, &lt;= ==, !=</pre></p>
<p style="color: #000000;">函数调用：</p>
<pre class="crayon-plain-tag">&lt;FUNCTION NAME&gt;(&lt;ARGUMENT 1&gt;, &lt;ARGUMENT 2&gt;)

# 支持参数展开
min([55, 2453, 2]...)</pre>
<div class="blog_h2"><span class="graybg">条件表达式</span></div>
<pre class="crayon-plain-tag">condition ? true_val : false_val

var.a != "" ? var.a : "default-a"</pre>
<div class="blog_h2"><span class="graybg">for表达式</span></div>
<p>使用for表达式可以通过转换一种复杂类型输出，生成另一个复杂类型结果。<span style="background-color: #c0c0c0;">输入中的每个元素，可以对应结果的0-1个元素</span>。任何表达式可以用于转换，下面是使用upper函数将列表转换为大写：</p>
<pre class="crayon-plain-tag">[for s in var.list : upper(s)]</pre>
<div class="blog_h3"><span class="graybg">输入类型</span></div>
<p>作为for表达式的输入的类型可以是list / set / tuple / map / object。可以为for声明两个临时符号，前一个表示index或key：</p>
<pre class="crayon-plain-tag">[for k, v in var.map : length(k) + length(v)]</pre>
<div class="blog_h3"><span class="graybg">结果类型 </span></div>
<p>结果的类型取决于包围for表达式的定界符：</p>
<p style="padding-left: 30px;">[] 表示生成的结果是元组<br />{} 表示生成的结果是object，你必须使用<pre class="crayon-plain-tag">=&gt;</pre>符号：<pre class="crayon-plain-tag">{for s in var.list : s =&gt; upper(s)}</pre></p>
<div class="blog_h3"><span class="graybg">输入过滤</span></div>
<p style="text-align: left;">包含一个可选的if子句可以对输入元素进行过滤：<pre class="crayon-plain-tag">[for s in var.list : upper(s) if s != ""]</pre></p>
<p>示例：</p>
<pre class="crayon-plain-tag">variable "users" {
  type = map(object({
    is_admin = boolean
  }))
}

locals {
  admin_users = {
    for name, user in var.users : name =&gt; user
    if user.is_admin
  }
}</pre>
<div class="blog_h3"><span class="graybg">元素顺序</span></div>
<p>for表达式可能将无序类型（map/object/set）转换为有序类型（list/tuple）：</p>
<p style="padding-left: 30px;">对于map/object，键/属性名的字典序，决定结果的顺序<br />对于set，如果值是字符串，则使用其字典序。如果值是其它类型，则结果的顺序可能随着Terraform的版本改变</p>
<div class="blog_h3"><span class="graybg">结果分组</span></div>
<p style="text-align: left;">如果输出是对象，通常要求键的唯一性。Terraform支持分组模式，允许键重复。要激活此模式，在表达式结尾添加<pre class="crayon-plain-tag">...</pre>： </p>
<pre class="crayon-plain-tag">variable "users" {
  type = map(object({
    role = string
  }))
}

locals {
  users_by_role = {
    for name, user in var.users : user.role =&gt; name...
  }
}</pre>
<div class="blog_h2"><span class="graybg">dynamic块</span></div>
<p>对于顶级块，表达式仅能在给参数赋值的时候，用在右侧。某些情况下，我们需要在特定条件下，<span style="background-color: #c0c0c0;">重复、启用/禁用某个子块</span>，表达式就没法实现了，此时可以利用dynamic块。</p>
<p>dynamic块可以遍历一个列表，为每个元素生成一个块：</p>
<pre class="crayon-plain-tag">resource "aws_elastic_beanstalk_environment" "tfenvtest" {
  name                = "tf-test-name"
  application         = "${aws_elastic_beanstalk_application.tftest.name}"
  solution_stack_name = "64bit Amazon Linux 2018.03 v2.11.4 running Go 1.12.6"

  # 对于每个设置，生成一个setting子块。dynamic块的标签，对应生成的块类型
  dynamic "setting" {
    # 迭代对象
    for_each = var.settings
    # 子块的标签，可选
    labels: []
    # 子块的体（参数）
    content {
      namespace = setting.value["namespace"] # 块类型.key对应映射的键或者列表的索引，.value对应当前迭代的值
      name = setting.value["name"]
      value = setting.value["value"]
    }
  }
}</pre>
<p>注意dynamic块仅可能为resource、data、provider、provisioner块生成参数（子块），不能用于生成源参数块，例如lifecycle。</p>
<div class="blog_h3"><span class="graybg">多级嵌套</span></div>
<p>允许dynamic块的多级嵌套。</p>
<div class="blog_h2"><span class="graybg">splat表达式</span></div>
<p>splat表达式提供了更简单语法，在某些情况下代替for表达式：</p>
<pre class="crayon-plain-tag">[for o in var.list : o.id]
# 等价于
var.list[*].id

[for o in var.list : o.interfaces[0].name]
# 等价于
var.list[*].interfaces[0].name</pre>
<p>注意splat表达式仅仅用于列表，不能用于map/object</p>
<p>splat表达式还有个特殊用途，将单值转换为列表：</p>
<pre class="crayon-plain-tag">for_each = var.website[*]</pre>
<div class="blog_h2"><span class="graybg">类型约束</span></div>
<p>module/provider的作者可以利用类型约束来校验用户输入。Terraform的类型系统比较强大，比如他不但可以限制类型为map，还可以规定其中有哪些键，每个键的值是什么类型。</p>
<div class="blog_h3"><span class="graybg">复杂类型</span></div>
<p>复杂类型可以将多个值组合为单个值，复杂类型由类型构造器（<span style="color: #1d1e23;">type constructor）定义，复杂类型分为两类：</span></p>
<ol>
<li>集合类型：组合相似（类型）的值</li>
<li>结构类型：组合可能不同的值</li>
</ol>
<div class="blog_h3"><span class="graybg">集合类型</span></div>
<p>集合类型包括map/list/set等，我们可以限制集合的成员类型：</p>
<pre class="crayon-plain-tag"># 字符串列表
list(string)
# 数字列表
list(number)

# 任意元素列表，等价于list
list(any)</pre>
<div class="blog_h3"><span class="graybg">结构类型</span></div>
<p>结构类型包括object和tuple。</p>
<p>object定义了多个命名的属性，以及属性的类型：</p>
<pre class="crayon-plain-tag">object( { &lt;KEY&gt; = &lt;TYPE&gt;, &lt;KEY&gt; = &lt;TYPE&gt;, ... } )

# 示例
object({ name=string, age=number })

# 下面是符合此类型的实例
{
  name = "John"
  age  = 52
}</pre>
<p>元组则是定义了限定元素个数、每个元素类型的列表： </p>
<pre class="crayon-plain-tag">tuple([string, number, bool])
# 下面是符合此类型的实例
["a", 15, true]</pre>
<div class="blog_h3"><span class="graybg">复杂类型转换 </span></div>
<p>大部分情况下，<span style="background-color: #c0c0c0;">相似的集合类型和结构类型的行为类似</span>。Terraform文档某些时候也不去区分，这是由于以下类型转换行为：</p>
<ol>
<li>可能的情况下，Terraform会自动在相似复杂类型之间进行转换：
<ol>
<li>object和map是相似的，只要map包含object的schema所要求的键集合，即可自动转换。多余的键在转换过程中被丢弃，这意味着map - object - mapl两重转换可能丢失信息</li>
<li>tuple和list是相似的，但是转换仅仅在list元素数量恰好满足tuple的schema时发生</li>
<li>set和tuple/list是相似的：
<ol>
<li>当list/tuple转换为set，重复的值会被丢弃，元素顺序消失</li>
<li>当set转换为list/tuple，元素的顺序是任意的，一个例外是set(string)，将会按照元素字典序生成list/tuple</li>
</ol>
</li>
</ol>
</li>
<li>可能的情况下，Terraform会自动转换复杂类型的元素的类型，如果元素是复杂类型，则递归的处理</li>
</ol>
<p>每当提供的值，和要求的值类型不一致时，自动转换都会发生。</p>
<p>module/provider的作者应该注意不同类型的差别，特别是在限制输入方面能力的不同。</p>
<div class="blog_h3"><span class="graybg">动态类型any</span></div>
<p>特殊关键字any用做尚未决定的类型的占位符，其本身并非一个类型。当解释类型约束的时候，Terraform会尝试寻找单个的实际类型，替换any关键字，并满足约束。</p>
<p>例如，对于list(any)这一类型约束，对于给定的值["a", "b", "c"]其实际类型是tuple([string, string, string])，当将该值赋值给list(any)变量时，Terraform分析过程如下：</p>
<ol>
<li>tuple和list是相似类型，因此应用上问的tuple-list转换规则</li>
<li>tuple的元素类型是string，满足any约束，因此将其替换，结果类型是list(string)</li>
</ol>
<div class="blog_h3"><span class="graybg">可选object属性</span></div>
<p>从0.14开始，支持这一实验特性。可以在object类型约束中，标注某个属性为可选的： </p>
<pre class="crayon-plain-tag">variable "with_optional_attribute" {
  type = object({
    a = string           # 必须属性
    b = optional(string) # 可选属性
  })
}</pre>
<p>默认情况下，对于必须属性来说，如果类型转换时的源（例如map）不具备对应的键，会导致报错。</p>
<div class="blog_h2"><span class="graybg">版本约束</span></div>
<p>版本约束是一个特殊的字符串值，在引用module、使用provider时，或者通过terraform块的required_version时，需要用到版本约束：</p>
<pre class="crayon-plain-tag"># 版本范围区间
version = "&gt;= 1.2.0, &lt; 2.0.0"

# 操作符
=   等价于无操作符，限定特定版本
!=  排除特定版本
&gt; &gt;= &lt; &lt;= 限制版本范围
~&gt;  允许最右侧的版本号片段的变化</pre>
<div class="blog_h1"><span class="graybg">函数</span></div>
<p>所有可用函数：<a href="https://www.terraform.io/docs/language/functions/index.html">https://www.terraform.io/docs/language/functions/index.html</a></p>
<div class="blog_h1"><span class="graybg">资源(resource)</span></div>
<p>资源是TL语言中最重要的元素，由resource块定义。正如其名字所示，资源声明了某种云上基础设施的规格，这些基础设施可以是虚拟机、虚拟网络、DNS记录，等等。</p>
<p>数据源是一种特殊的资源，由data块定义。</p>
<div class="blog_h2"><span class="graybg">行为</span></div>
<p>当你第一次为某个资源编写配置时，它值存在于配置文件中，尚未代表云上的某个真实基础设施对象。通过应用Terraform配置，触发创建/更新/销毁等操作，实现云上环境和配置文件的匹配。</p>
<div class="blog_h3"><span class="graybg">生命周期</span></div>
<p>当一个新的资源被创建后，对应真实基础设施对象的标识符被保存到Terraform的State中。这个标识符作为后续更新/删除的依据。对于State中已经存在关联的标识符的那些Resource块，Terraform会比较真实基础设施对象和Resource参数的区别，并在必要的时候更新对象。</p>
<p>概括起来说，当Terraform配置被应用时：</p>
<ol>
<li>创建存在于配置文件中，但是在State中没有关联真实基础设施对象的资源</li>
<li>销毁存在于State中，但是不存在于配置文件的资源</li>
<li>更新参数发生变化的资源</li>
<li><span style="background-color: #c0c0c0;">删除、重新创建参数发生变化，但是不能原地（in-palce）更新的资源</span>。不能更新通常是因为云API的限制，例如腾讯云VPC的CIDR不支持修改</li>
</ol>
<p>以上4点，适用于所有资源类型。但是需要注意，底层发生的事情，取决于Provider，Terraform只是去调用Provider的相应接口。</p>
<div class="blog_h3"><span class="graybg">访问资源属性</span></div>
<p>在相同模块中，你可以在表达式中访问某个资源的属性，语法<pre class="crayon-plain-tag">&lt;RESOURCE TYPE&gt;.&lt;NAME&gt;.&lt;ATTRIBUTE&gt;</pre></p>
<p>除了配置文件中列出的资源参数之外，资源还提供一些<span style="background-color: #c0c0c0;">只读的属性</span>。属性表示了一些提供云API拉取到的信息。这些信息通畅需要在资源创建后才可获知，例如随机生成的资源ID vpc-d8o3c8vq</p>
<p>很多Provider还<span style="background-color: #c0c0c0;">提供特殊的数据源（data）资源，这种特殊的资源仅仅用来查询信息</span>。</p>
<div class="blog_h3"><span class="graybg">资源依赖关系</span></div>
<p>某些资源必须在另外一些资源之后创建，例如CVM必须在其所属Subnet创建后才能创建，这意味着某些资源存在依赖关系。</p>
<p>Terraform能够自动分析资源依赖关系，其分析依据就是资源的参数的值表达式。<span style="background-color: #c0c0c0;">如果表达式中引用了另一个资源，则当前资源依赖于该资源</span>。</p>
<p>对于无法通过配置文件分析的隐含依赖，需要你手工配置<pre class="crayon-plain-tag">depends_on</pre>元参数。</p>
<p>Terraform会自动并行处理没有依赖关系的资源。</p>
<div class="blog_h3"><span class="graybg">Local-Only资源</span></div>
<p>这类特殊的资源不会对应某个云上基础设施对象，而是仅<span style="background-color: #c0c0c0;">仅存在于Terraform本地State中</span>。Local-Only资源用于一些中间计算过程，包括生成随机ID、创建私钥等。</p>
<p>Local-Only资源的<span style="background-color: #c0c0c0;">行为和普通资源一致</span>，只是其结果数据仅仅存在于State中，删除时也仅仅是从State中移除对应数据。</p>
<div class="blog_h2"><span class="graybg">语法</span></div>
<pre class="crayon-plain-tag">resource "resource_type" "local_name" {
  # arguments...
}</pre>
<p>两个标签分别代表资源的类型和本地名称。</p>
<p>资源类型提示正在描述的是那种云上基础设施，资源类型决定可用的参数集。<span style="background-color: #c0c0c0;">本地名称用于在模块的其它地方饮用该资源，此外没有意义</span>。资源类型+本地名称是资源的唯一标识，必须在模块范围内唯一。</p>
<div class="blog_h2"><span class="graybg">Provider</span></div>
<p>每一种资源都由一个Provider来实现。<span style="background-color: #c0c0c0;">Provider是Terraform的插件，它提供若干资源类型</span>。通常一个云服务商提供提供一个Provider。初始化工作目录时Terraform能够自动从Terraform仓库下载大部分所需的Provider。</p>
<p>模块需要知道，利用哪些Provider才能管理所有的资源。此外Provider还需要经过配置才能工作，例如设置访问云API的凭证。这些配置由根模块负责。</p>
<div class="blog_h2"><span class="graybg">元参数</span></div>
<p>元参数可以用于任何资源类型。</p>
<div class="blog_h3"><span class="graybg">depends_on</span></div>
<p>该元参数用于处理隐含的资源/模块依赖，这些依赖无法通过分析Terraform配置文件得到。从0.13版本开始，该元参数可用于模块。之前的版本仅仅用于资源。</p>
<p>depends_on的值是一个列表，其元素具必须是其它资源的引用，不支持任意表达式。</p>
<p>depends_on应当仅仅用作最后手段，避免滥用。</p>
<p>示例：</p>
<pre class="crayon-plain-tag">resource "aws_iam_role" "example" {
  name = "example"
  assume_role_policy = "..."
}

# 这个策略允许运行在EC2中的实例访问S3 API
resource "aws_iam_role_policy" "example" {
  name   = "example"
  role   = aws_iam_role.example.name
  policy = jsonencode({
    "Statement" = [{
      "Action" = "s3:*",
      "Effect" = "Allow",
    }],
  })
}


resource "aws_iam_instance_profile" "example" {
  # 这是可以自动分析出的依赖
  role = aws_iam_role.example.name
}


resource "aws_instance" "example" {
  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  # 这是可以自动分析出的依赖，包括传递性依赖
  iam_instance_profile = aws_iam_instance_profile.example

  # 如果这个实例中的程序需要访问S3接口，我们需要用元参数显式的声明依赖
  # 从而分配策略
  depends_on = [
    aws_iam_role_policy.example,
  ]
} </pre>
<div class="blog_h3"><span class="graybg">count</span></div>
<p>默认情况下，一个resource块代表单个云上基础设施对象。如果你想用一个resource块生成多个类似的资源，可以用count或for_each参数。</p>
<p>设置了此元参数的上下文中，可以访问名为<pre class="crayon-plain-tag">count</pre>的变量，它具有属性<pre class="crayon-plain-tag">index</pre>，为从0开始计数的资源实例索引。 示例：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "server" {
  # 创建4个类似的实例
  count = 4

  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  tags = {
    #              实例的索引作为tag的一部分
    Name = "Server ${count.index}"
  }
}</pre>
<p>在当前模块的其它地方，可以用这样的语法访问如上资源实例：</p>
<pre class="crayon-plain-tag">&lt;RESOURCE_TYPE&gt;.&lt;NAME&gt;[&lt;INDEX&gt;]

aws_instance.server[0]</pre>
<div class="blog_h3"><span class="graybg">for_each</span></div>
<p>如果资源的规格几乎完全一致，可以用count，否则，需要使用更加灵活的for_each元参数。</p>
<p><span style="background-color: #c0c0c0;">for_each的值必须是一个映射或set(string)</span>，你可以在上下文中访问<pre class="crayon-plain-tag">each</pre>对象， 它具有<pre class="crayon-plain-tag">key</pre>和<pre class="crayon-plain-tag">value</pre>两个属性，如果for_each的值是<span style="background-color: #c0c0c0;">集合，则key和value相等</span>。示例：</p>
<pre class="crayon-plain-tag">resource "azurerm_resource_group" "rg" {
  for_each = {
    a_group = "eastus"
    another_group = "westus2"
  }
  # 对于每个键值对都会生成azurerm_resource_group资源
  name     = each.key
  location = each.value
}


resource "aws_iam_user" "the-accounts" {
  # 数组转换为集合
  for_each = toset( ["Todd", "James", "Alice", "Dottie"] )
  name     = each.key
}</pre>
<p>调用子模块时使用for_each的示例：</p>
<pre class="crayon-plain-tag"># 父模块 my_buckets.tf
module "bucket" {
  for_each = toset(["assets", "media"])
  # 调用publish_bucket子模块
  source   = "./publish_bucket"
  # 将键赋值给子模块的name变量
  name     = "${each.key}_bucket"
}

# 子模块          文件名
# publish_bucket/bucket-and-cloudfront.tf

# 声明模块的输入参数
variable "name" {} 

resource "aws_s3_bucket" "example" {
  # 访问变量
  bucket = var.name
  # ...
}</pre>
<p>关于for_each的值的元素，有如下限制：</p>
<ol>
<li><span style="background-color: #c0c0c0;">键必须是确定的值</span>，如果应用配置前值无法确定会报错。例如，你不能引用CVM的ID，因为这个ID必须在配置应用之后才可知</li>
<li>如果<span style="background-color: #c0c0c0;">键</span>是函数调用，则此函数<span style="background-color: #c0c0c0;">不能是impure的（非幂等）</span>，impure函数包括uuid/bcrypt/timestamp等</li>
<li><span style="background-color: #c0c0c0;">敏感（Sensitive）值不能用做键值</span>。需要注意，大部分函数，在接受敏感值参数后，返回值仍然是敏感的</li>
</ol>
<p>当使用集合的时候，你必须明确的将值转换为集合。例如通过<pre class="crayon-plain-tag">toset</pre>函数将列表、元组转换为集合。使用<pre class="crayon-plain-tag">flatten</pre>函数可以将多层次的嵌套结构转换为列表。</p>
<p>for_each所在块定义的资源A，可以赋值给另一个资源B的for_each参数。这时，<span style="background-color: #c0c0c0;">B那个for_each的键值对，值是资源的完整（创建或更新过的）实例</span>。这种用法叫<span style="background-color: #c0c0c0;">chaining</span>：</p>
<pre class="crayon-plain-tag">variable "vpcs" {
  # 这里定义了map类型的变量，并且限定了map具有的键
  type = map(object({
    cidr_block = string
  }))
}


# 创建多个VPC资源
resource "aws_vpc" "example" {
  for_each = var.vpcs
  cidr_block = each.value.cidr_block
}
# 上述资源作为下面那个for_each的值

# 创建对应数量的网关资源
resource "aws_internet_gateway" "example" {
  # 为每个VPC创建一个网关
  #          资源作为值
  for_each = aws_vpc.example

  # 映射的值，在这里是完整的VPC对象
  vpc_id = each.value.id
}


# 输出所有VPC ID
output "vpc_ids" {
  value = {
    for k, v in aws_vpc.example : k =&gt; v.id
  }

  # 显式依赖网关资源，确保网关创建后，输出才可用
  depends_on = [aws_internet_gateway.example]
}</pre>
<p>引用for_each创建的资源的实例时，使用如下语法： </p>
<pre class="crayon-plain-tag">&lt;TYPE&gt;.&lt;NAME&gt;[&lt;KEY&gt;]

azurerm_resource_group.rg["a_group"]</pre>
<div class="blog_h3"><span class="graybg">provider</span></div>
<p>这个参数用于指定<span style="background-color: #c0c0c0;">使用的provider配置</span>。可以覆盖Terraform的<span style="background-color: #c0c0c0;">默认行为：将资源类型名的第一段（下划线分隔）作为provider的本地名称。同时使用provider的默认配置</span>。例如资源类型google_compute_instance默认识别为google这个provider。</p>
<p>每个provider可以提供多个配置，<span style="background-color: #c0c0c0;">配置常常用于管理多区域服务</span>中的某个特定Region。每个provider可以有一个默认配置。</p>
<p>该参数的值必须是不被引号包围的<pre class="crayon-plain-tag">&lt;PROVIDER&gt;.&lt;ALIAS&gt;</pre>。使用该参数你可以显式的指定provider及其配置：</p>
<pre class="crayon-plain-tag"># 默认配置
provider "google" {
  region = "us-central1"
}

# 备选配置
provider "google" {
  alias  = "europe"
  region = "europe-west1"
}

resource "google_compute_instance" "example" {
  # 通过使用该参数选择备选配置
  provider = google.europe

  # ...
}</pre>
<p>注意：<span style="background-color: #c0c0c0;">资源总是隐含对它的provider的依赖</span>，这确保创建资源前provider被配置好。 </p>
<div class="blog_h3"><span class="graybg">lifecycle</span></div>
<p>该参数可以对资源的生命周期进行控制。示例：</p>
<pre class="crayon-plain-tag">resource "azurerm_resource_group" "example" {
  # ...
  lifecycle {
    create_before_destroy = true
  }
}</pre>
<p>lifecycle作为一个块，只能出现在resources块内。可用参数包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">参数</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>create_before_destroy</td>
<td>
<p>默认情况下， 当Terraform无法进行in-place更新时，会删除并重新创建资源</p>
<p>该参数可以修改此行为：先创建新资源，然后删除旧资源</p>
</td>
</tr>
<tr>
<td>prevent_destroy</td>
<td>如果设置为true，并且配置会导致基础设施中某个对象被删除，则报错</td>
</tr>
<tr>
<td>ignore_changes</td>
<td>
<p>默认情况下，Terraform会对比真实基础设施中对象和当前配置文件中的所有字段，任何字段的不一致都会引发更新操作</p>
<p>该参数指定，在Terraform评估是否需要更新时，资源的哪些字段被忽略</p>
<p>如果设置为特殊值<pre class="crayon-plain-tag">all</pre>，则Terraform不会进行任何更新操作</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">timeouts参数</span></div>
<p>某些资源类型提供了特殊的<pre class="crayon-plain-tag">timeouts</pre>内嵌块，用于指定多长时间后认定操作失败：</p>
<pre class="crayon-plain-tag">resource /* ... */ {
  # ...
  timeouts {
    create = "60m"
    update = "30s"
    delete = "2h"
  }
}</pre>
<div class="blog_h2"><span class="graybg">资源特定参数</span></div>
<p>资源的绝大部分参数由资源类型决定。需要翻阅Provider的文档了解哪些参数可用。</p>
<p>对于大部分公共的、托管在Terraform仓库的Provider来说，其文档可以直接在<a href="https://registry.terraform.io/browse/providers">仓库网站</a>上获得。</p>
<div class="blog_h2"><span class="graybg">Provisioner</span></div>
<p>对于一些无法使用Terraform声明式模型来表达的某些行为，可以使用<span style="background-color: #c0c0c0;">Provisioner作为最后（总是不推荐的）手段</span>。</p>
<p>使用Provisioner会引入复杂性和不确定性：</p>
<ol>
<li>Terraform无法将Provisioner执行的动作，作为计划的一部分。因为Provisioner理论上可以做任何事情</li>
<li>Provisioner通常需要使用更多细节信息，例如直接访问服务器的网络、使用Terraform的登录凭证</li>
</ol>
<div class="blog_h3"><span class="graybg">self对象</span></div>
<p>Provisioner块不能用名字访问其所在的上下文资源，你必须使用<pre class="crayon-plain-tag">self</pre>对象。这个对象就指代对应的资源，你可以访问它的参数和属性。</p>
<div class="blog_h3"><span class="graybg">when参数</span></div>
<p>该参数指定何时（create/update/destroy）运行Provisioner，下面是一个仅仅在删除资源时才执行的Provisioner：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  provisioner "local-exec" {
    # 在实际删除前调用
    when    = destroy
    command = "echo 'Destroy-time provisioner'"
  }
}</pre>
<p>默认情况下，Provisioner在资源创建的时候调用，在更新/删除时不会调用。最<span style="background-color: #c0c0c0;">常见的用法是使用Provisioner来初始化系统</span>。如果Provisioner失败，则资源被标记为tainted，并且会在下一次<pre class="crayon-plain-tag">terraform apply</pre>时删除、重新创建。</p>
<div class="blog_h3"><span class="graybg">on_failure参数</span></div>
<p>定制Provisioner失败时的行为：</p>
<ol>
<li><pre class="crayon-plain-tag">continue</pre> 忽略错误</li>
<li><pre class="crayon-plain-tag">fail</pre> 默认行为，导致配置应用立即失败，如果正在创建资源，则taint该资源</li>
</ol>
<div class="blog_h3"><span class="graybg">连接设置</span></div>
<p>大部分Provisioner要求通过SSH或WinRM来访问远程资源。你可以在<pre class="crayon-plain-tag">connection</pre>块中声明如何连接。connection块可以内嵌在以下位置：</p>
<ol>
<li>resource，对资源的所有Provisioner生效</li>
<li>provisioner，仅仅对当前Provisioner生效</li>
</ol>
<p>在connection块中，你也可以使用<pre class="crayon-plain-tag">self</pre>来访问包含它的resource，这一点类似于provisioner</p>
<p>示例：</p>
<pre class="crayon-plain-tag">provisioner "file" {
  # Linux
  connection {
    type     = "ssh"
    user     = "root"
    password = "${var.root_password}"
    host     = "${var.host}"
  }
}

provisioner "file" {
  # Windows
  connection {
    type     = "winrm"
    user     = "Administrator"
    password = "${var.admin_password}"
    host     = "${var.host}"
  }
}</pre>
<p>关于如何通过证书进行身份验证，如何通过堡垒机连接，参考<a href="https://www.terraform.io/docs/language/resources/provisioners/connection.html">官方文档</a>。 </p>
<div class="blog_h3"><span class="graybg">null_resource</span></div>
<p>如果你希望运行一个Provisioner，但是不想在任何真实的资源的上下文下运行，可以使用这种特殊的资源。</p>
<pre class="crayon-plain-tag">resource "aws_instance" "cluster" {
  count = 3

  # ...
}

resource "null_resource" "cluster" {
  # Provisioner的触发时机
  triggers = {
    # cluster中任何实例改变会触发Provisioner的重新执行
    #                                  这种通配符语法会得到一个列表
    cluster_instance_ids = "${join(",", aws_instance.cluster.*.id)}"
  }

  # 仅仅连接到第一个实例
  connection {
    #        该函数访问列表的特定元素
    host = "${element(aws_instance.cluster.*.public_ip, 0)}"
  }

  provisioner "remote-exec" {
    # 执行命令
    inline = [
      "bootstrap-cluster.sh ${join(" ", aws_instance.cluster.*.private_ip)}",
    ]
  }
}</pre>
<div class="blog_h3"><span class="graybg">通用Provisioners</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 100px; text-align: center;">Provisioner</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>file</td>
<td>
<p>从运行Terraform的机器，复制文件或目录到新创建的资源（通常是虚拟机）。示例：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  # ...

  # 文件到文件
  provisioner "file" {
    source      = "conf/myapp.conf"
    destination = "/etc/myapp.conf"
  }

  # 字符串到文件
  provisioner "file" {
    content     = "ami used: ${self.ami}"
    destination = "/tmp/file.log"
  }

  # 目录到目录，拷贝后生成/etc/configs.d目录
  provisioner "file" {
    source      = "conf/configs.d"
    destination = "/etc"
  }

  # Windows下的路径语法
  provisioner "file" {
    # apps/app1/下的所有文件拷贝到D:/IIS/webapp1下
    source      = "apps/app1/"
    destination = "D:/IIS/webapp1"
  }
}</pre>
<p>关于整个目录的拷贝，需要注意：</p>
<ol>
<li>如果连接type是ssh，则目标目录必须已经存在。你可能需要使用remote-exec预先创建好目录</li>
<li>原路径以/结尾，则拷贝目录下的所有文件，而非目录本身</li>
</ol>
</td>
</tr>
<tr>
<td>local-exec</td>
<td>
<p>在资源创建之后，调用一个本地（运行Terraform的机器）可执行程序</p>
<p>注意：即使是在资源创建之后，但是不保证sshd这样的服务已经可用了。因此不要尝试在local-exec中调用ssh命令登录到资源</p>
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  # ...

  provisioner "local-exec" {
    command = "echo ${self.private_ip} &gt;&gt; private_ips.txt"
    # 可选的工作目录
    working_dir = "/root"
    # 可选的解释器
    interpreter = [ "/bin/bash", "-c" ]
    # 可选的环境变量
    environment = {
      FOO = "bar"
    }
  }
}</pre>
</td>
</tr>
<tr>
<td>remote-exec</td>
<td>
<p>在资源创建之后，登录到新创建的资源，执行命令
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  provisioner "remote-exec" {
    # 命令列表，逐个执行
    inline = [
      "puppet apply",
      "consul join ${aws_instance.web.private_ip}",
    ]
    # 单个脚本路径
    script = ""
    # 多个脚本路径
    scripts = []
  }
} </pre>
</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">数据资源(data)</span></div>
<p>数据资源，由data块来描述，让Terraform从数据源读取信息。Data是一种特殊的Resource，也是由Provider来提供。<span style="background-color: #c0c0c0;">Data只支持读取操作</span>，相对的，<span style="background-color: #c0c0c0;">普通的Resource支持增删改查操作，普通资源也叫受管（Manged）资源，一般简称Resource</span>。
<p>数据资源要求Terraform去特定数据源（第1标签）读取数据，并将结果导出为本地名称（第2标签）：</p>
<pre class="crayon-plain-tag"># 读取匹配参数的aws_ami类型的数据源，并存放到example
data "aws_ami" "example" {
  # 这些参数是查询条件(query constraints)，具体哪些可用取决于数据源类型
  most_recent = true

  owners = ["self"]
  tags = {
    Name   = "app-server"
    Tested = "true"
  }
}</pre>
<p>两个标签组合起来是data在模块中的唯一标识。</p>
<div class="blog_h2"><span class="graybg">行为</span></div>
<div class="blog_h3"><span class="graybg">生命周期</span></div>
<p>如果数据资源的查询约束（参数）都是<span style="background-color: #c0c0c0;">常量值，或者是已知的变量值</span>，那么<span style="background-color: #c0c0c0;">数据资源的状态会在Terraform的Refresh阶段更新，这个阶段在创建执行计划之前</span>。</p>
<p>查询约束可能引用了某些值，这些值在应用配置文件之前无法获知（例如资源ID）。<span style="background-color: #c0c0c0;">这种情况下，读取数据源的操作将推迟到Apply之后</span>。在Data读取完成之前，所有对它结果（导出名称）的引用都是不可用的。</p>
<div class="blog_h3"><span class="graybg">Local-Only数据源</span></div>
<p>大部分数据源都对应了某种云上基础设施，需要通过云API远程的读取。</p>
<p>某些特殊数据源仅仅供Terraform自己使用，例如Hashicorp的Provider template，它提供的template_file数据源，用于在本地渲染模板文件。这类数据源叫Local-Only数据源，其行为和一般数据源没有区别。</p>
<div class="blog_h3"><span class="graybg">资源依赖关系</span></div>
<p>数据资源的依赖解析行为，和受管资源一致。</p>
<div class="blog_h2"><span class="graybg">元参数</span></div>
<p>数据资源支持的元参数，和受管资源一致。</p>
<div class="blog_h1"><span class="graybg">变量和输出</span></div>
<p>和传统编程语言对比，模块类似于函数，输入变量类似于函数参数，输出值类似于返回值，本地值则类似于函数内的局部变量。</p>
<div class="blog_h2"><span class="graybg"><a id="input-vars"></a>输入变量</span></div>
<p>输入变量作为<span style="background-color: #c0c0c0;">Terraform模块的参数</span>，从而实现模块的参数化、可跨多个配置复用。</p>
<p>对于定义在<span style="background-color: #c0c0c0;">根模块中的变量，其值可以从Terraform CLI选项传入</span>。对于<span style="background-color: #c0c0c0;">子模块中定义的变量，则必须通过module块传入其值</span>。</p>
<div class="blog_h3"><span class="graybg">声明语法</span></div>
<pre class="crayon-plain-tag"># 声明一个字符串类型的输入变量
#        变量名必须在模块范围内唯一
#        不得用做变量名：ource, version, providers, count, for_each, lifecycle, depends_on, locals
variable "image_id" {
  # 类型
  type = string
  # 描述
  description = ""
  # 校验规则
  validation {
    # 如果为true则校验成功
    condition = bool-expr
    # 校验失败时的消息
    error_message = ""
  }
  # 是否敏感，敏感信息不会现在Terraform UI上输出
  sensitive = false
}

# 声明一个字符串的列表，并给出默认值
variable "availability_zone_names" {
  type    = list(string)
  default = ["us-west-1a"]
}

# 声明一个对象的列表，并给出默认值
variable "docker_ports" {
  type = list(object({
    internal = number
    external = number
    protocol = string
  }))
  default = [
    {
      internal = 8300
      external = 8300
      protocol = "tcp"
    }
  ]
}</pre>
<div class="blog_h3"><span class="graybg">类型</span></div>
<p>支持的简单类型：<pre class="crayon-plain-tag">string</pre>、<pre class="crayon-plain-tag">number</pre>、<pre class="crayon-plain-tag">bool</pre></p>
<p>支持的容器类型：</p>
<ol>
<li>列表：<pre class="crayon-plain-tag">list(&lt;TYPE&gt;)</pre></li>
<li>集合：<pre class="crayon-plain-tag">set(&lt;TYPE&gt;)</pre></li>
<li>映射：<pre class="crayon-plain-tag">map(&lt;TYPE&gt;)</pre></li>
<li>对象：<pre class="crayon-plain-tag">object({&lt;ATTR NAME&gt; = &lt;TYPE&gt;, ... })</pre></li>
<li>元组：<pre class="crayon-plain-tag">tuple([&lt;TYPE&gt;, ...])</pre></li>
</ol>
<p>关键字<pre class="crayon-plain-tag">any</pre>表示，任何类型都允许。</p>
<div class="blog_h3"><span class="graybg">默认值</span></div>
<p>如果同时指定了类型和默认值，则提供的默认值必须可以转换为类型。</p>
<div class="blog_h3"><span class="graybg">校验规则</span></div>
<p>validation是一个块，其中condition是一个bool表达式：</p>
<pre class="crayon-plain-tag">variable "image_id" {
  type        = string
  description = "The id of the machine image (AMI) to use for the server."

  validation {
    #               can函数将错误转换为false
    #                  regex函数在找不到匹配的时候会失败
    condition     = can(regex("^ami-", var.image_id))
    error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
  }
}</pre>
<div class="blog_h3"><span class="graybg">引用输入变量</span></div>
<p><span style="background-color: #c0c0c0;">在声明输入变量的模块</span>中，可以使用<pre class="crayon-plain-tag">var.&lt;NAME&gt;</pre>引用输入变量的值：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "example" {
  instance_type = "t2.micro"
  ami           = var.image_id
}</pre>
<div class="blog_h3"><span class="graybg">给根模块变量赋值</span></div>
<p>要给根模块中定义的变量赋值，有以下几种方式：</p>
<ol>
<li>使用<pre class="crayon-plain-tag">-var</pre>命令行选项，可以多次使用，每次赋值一个变量，示例：<br />
<pre class="crayon-plain-tag">terraform apply -var="image_id=ami-abc123"
terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"
terraform apply -var='image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' </pre>
</li>
<li>作为环境变量传入，示例：<br />
<pre class="crayon-plain-tag"># 环境变量需要TF_VAR_前缀
export TF_VAR_image_id=ami-abc123</pre>
</li>
<li>使用<pre class="crayon-plain-tag">.tfvars</pre>文件，此文件可以自动载入或者通过命令行选项显式载入，示例：<br />
<pre class="crayon-plain-tag">image_id = "ami-abc123"
availability_zone_names = [
  "us-east-1a",
  "us-west-1c",
]</pre><br />
<pre class="crayon-plain-tag">terraform apply -var-file="testing.tfvars"</pre></p>
<p>注意以下文件可以自动识别并载入：</p>
<ol>
<li>名为<pre class="crayon-plain-tag">terraform.tfvars</pre>或<pre class="crayon-plain-tag">terraform.tfvars.json</pre>的文件</li>
<li>以<pre class="crayon-plain-tag">.auto.tfvars</pre>或<pre class="crayon-plain-tag">.auto.tfvars.json</pre>结尾的文件</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">变量值优先级</span></div>
<p>如果通过多种方式给变量赋值，则优先级高的生效。优先级顺序<span style="background-color: #c0c0c0;">从低到高</span>：</p>
<ol>
<li>环境变量文件</li>
<li>terraform.tfvars</li>
<li>terraform.tfvars.json</li>
<li>*.auto.tfvars和*.auto.tfvars.json文件，多个文件，按字典序，后面的优先级高</li>
<li>-var或-var-file命令行选项，多个选项，后面的优先级高</li>
</ol>
<div class="blog_h2"><span class="graybg">输出值</span></div>
<p>输出值是模块的“返回值”，具有以下用途：</p>
<ol>
<li>子模块使用输出值将它创建的<span style="background-color: #c0c0c0;">资源的属性的子集暴露给父模块</span></li>
<li>根模块可以利用输出值，将一些<span style="background-color: #c0c0c0;">信息在terraform apply之后打印到控制台</span>上</li>
<li>当使用远程状态（remote state）时，根模块的输出可以<span style="background-color: #c0c0c0;">被其它配置通过 terraform_remote_state 数据源捕获到</span></li>
</ol>
<div class="blog_h3"><span class="graybg">声明语法</span></div>
<pre class="crayon-plain-tag"># 输出的名字
output "instance_ip_addr" {
  # 值
  value = aws_instance.server.private_ip
  # 描述
  description = ""
  # 是否敏感
  sensitive = ""
  # 依赖
  depends_on = []
}</pre>
<div class="blog_h3"><span class="graybg">访问子模块输出</span></div>
<p>子模块的输出值，通过这样的表达式访问：</p>
<pre class="crayon-plain-tag">module.&lt;MODULE NAME&gt;.&lt;OUTPUT NAME&gt;

# 访问子模块web_server的输出值instance_ip_addr
module.web_server.instance_ip_addr</pre>
<div class="blog_h3"><span class="graybg">输出值的依赖</span></div>
<p>使用depends_on参数可以明确指定输出值依赖什么：</p>
<pre class="crayon-plain-tag">output "instance_ip_addr" {
  value       = aws_instance.server.private_ip
  depends_on = [
    aws_security_group_rule.local_access,
  ]
}</pre>
<div class="blog_h2"><span class="graybg">本地值</span></div>
<p>在一个模块内部，将表达式分配给一个名称。你可以同时声明多个本地值：</p>
<pre class="crayon-plain-tag">locals {
  service_name = "forum"
  instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id)
  common_tags = {
    Service = local.service_name
    Owner   = local.owner
  }
}</pre>
<p>引用本地值时，使用表达式： <pre class="crayon-plain-tag">local.&lt;NAME&gt;</pre></p>
<div class="blog_h1"><span class="graybg">模块(modules)</span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>一套完整的配置文件（Configuration，简称配置），由1个根目录和若干文件/子目录组成。配置文件的扩展名为<pre class="crayon-plain-tag">.tf</pre>，此外HCL语言还提供了基于JSON的变体，这种配置文件的扩展名为<pre class="crayon-plain-tag">.tf.json</pre>。配置文件的编码方式为UTF-8，使用Unix风格的换行符。</p>
<p>所谓模块（Module）是存放在一个目录中的配置文件的集合。子目录中的文件不属于模块，不会自动包含到配置中。</p>
<p>将块存放在不同配置文件中仅仅是方便人类的阅读，和Terraform的行为无关。Terraform总是会评估模块中的所有文件，并将它们合并为单一的文档来看待。</p>
<div class="blog_h3"><span class="graybg">根模块</span></div>
<p>Terraform命令总是在单个根模块的上下文中运行，根模块的目录通常作为命令的工作目录。此根模块会调用其它模块，这种调用关系会递归的发生，从而形成一个子模块树结构。</p>
<div class="blog_h3"><span class="graybg">子模块</span></div>
<p>一个模块（通常是根模块）可以调用其它模块，从而将这些模块中定义的资源包含到配置中。当一个模块被其它模块调用时，它的角色是子模块。</p>
<p>子模块可以被同一个配置调用多次，不同模块也可以同时调用一个子模块。</p>
<div class="blog_h3"><span class="graybg">发布的模块</span></div>
<p>引用子模块时，除了可以从本地文件系统获取，还可以从公共/私有仓库下载。</p>
<p>Terraform Registry托管了大量公共模块，用于管理各种各样的基础设施，这些模块可以被免费使用。Terraform能够自动下载这些模块的正确版本。Terraform Cloud / Enterprise版本都包含一个私服模块可以托管组织私有的模块。</p>
<div class="blog_h3"><span class="graybg">覆盖文件</span></div>
<p>使用名为<pre class="crayon-plain-tag">override.tf</pre>或<pre class="crayon-plain-tag">override.tf.json</pre>，或者后缀为<pre class="crayon-plain-tag">_override.tf</pre>或<pre class="crayon-plain-tag">_override.tf.json</pre>的文件，可以覆盖既有配置的某一部分。这些文件叫做覆盖文件。</p>
<p>加载配置时，Terraform最初会跳过覆盖文件。加载完成功文件后，会按照字典序处理覆盖文件。对于覆盖文件中的每个顶级块，Terraform会尝试寻找已经存在的，定义在常规文件中的对应块，并将块的内容进行合并。内容合并规则如下：</p>
<ol>
<li>覆盖文件中的顶级块、普通配置文件中的顶级块，对应关系通过块头（块类型+标签）识别，相同块头的块被合并</li>
<li>顶级块中的参数被替换</li>
<li>顶级块中的内嵌块被替换，不会递归的进行合并</li>
<li>多个覆盖文件覆盖了同一个块的定义时，按覆盖文件名的字典序依次合并</li>
</ol>
<p>此外，对于resource / data块，有如下特殊规则：</p>
<ol>
<li>内嵌的lifecycle块不是简单的直接替换。假设覆盖文件仅仅设置了lifecycle的create_before_destroy属性，原始配置中任何ignore_changes参数保持原样</li>
<li>对于内嵌的provisioner块，原始配置中的（不管有几个）provisioner块直接被忽略</li>
<li>原始配置中的内嵌connection块被完全覆盖</li>
<li>元参数（meta-argument）depends_on不能出现在覆盖文件中</li>
</ol>
<p>对于variable（变量）块，有如下特殊规则：</p>
<ol>
<li>如果原始块定义了default参数（默认值）并且覆盖块修改了变量的type，则Terraform尝试将default转换为新的type，如果转换无法自动完成则报错</li>
<li>如果覆盖块修改了default，那么其值必须匹配原始块中的type</li>
</ol>
<p>不建议过多的使用覆盖文件，这会降低配置的可读性。</p>
<p>对于output块，有如下特殊规则：</p>
<ol>
<li>元参数depends_on不能出现在覆盖文件中</li>
</ol>
<p>对于local块，有如下特殊规则：</p>
<ol>
<li>每个local块定义（或修改）了若干具有名字的值，覆盖时使用value-by-value的方式，至于值在何处定义不影响</li>
</ol>
<p>对于terraform块，有如下特殊规则：</p>
<ol>
<li>required_providers的值，按element-by-element的方式进行覆盖。这样，覆盖块可以仅仅调整单个provider的配置，而不影响其他providers</li>
<li>覆盖required_version、required_providers时，被覆盖的元素被整个替换</li>
</ol>
<p>下面是原始文件+覆盖文件的示例：</p>
<pre class="crayon-plain-tag"># example.tf
resource "aws_instance" "web" {
  instance_type = "t2.micro"
  ami           = "ami-408c7f28"
}

# override.tf
resource "aws_instance" "web" {
  ami = "foo"
}</pre>
<div class="blog_h2"><span class="graybg">使用</span></div>
<div class="blog_h3"><span class="graybg">调用子模块</span></div>
<p>所谓调用，就是将特定的值传递给子模块作为输入变量，从而将子模块中的配置包含进来。</p>
<pre class="crayon-plain-tag">module "servers" {
  # 源，必须，指定子模块的位置
  source = "./app-cluster"
  # 版本，如果模块位于仓库中，建议制定
  version = "0.0.5"
  
  # 支持一些元参数
  
  # 大部分其它参数，都是为子模块提供输入变量
  servers = 5
}</pre>
<div class="blog_h2"><span class="graybg">元参数</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 100px; text-align: center;">元参数</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td class=" blog_h3">count</td>
<td>用于创建多个模块实例，参考资源(resources)的元参数</td>
</tr>
<tr>
<td class=" blog_h3">for_each</td>
<td>用于创建多个模块实例，参考资源(resources)的元参数</td>
</tr>
<tr>
<td class=" blog_h3">providers</td>
<td>
<p>将Provider配置传递给子模块：</p>
<pre class="crayon-plain-tag">provider "aws" {
  alias  = "usw2"
  region = "us-west-2"
}

module "example" {
  source    = "./example"
  providers = {
    # 配置名称由provider块的第1标签（+可选的alias参数）构成
    # 键是子模块中的Provider配置名称
    # 值是父模块中的Provider配置名称
    aws = aws.usw2
  }
}</pre>
<p>如果子模块没有定义任何Provider alias，则该元参数是可选的。不指定该元参数时，子模块会从父模块继承所有默认Provider配置，所谓<span style="background-color: #c0c0c0;">默认配置就是没有alias的provider块所定义的配置</span></p>
</td>
</tr>
<tr>
<td class=" blog_h3">depends_on</td>
<td>
<p>显式指定整个模块对特定目标的依赖，参考资源(resources)的元参数</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg"><a id="module-source"></a>源</span></div>
<p>module块的source参数指定了从何处下载子模块的代码。</p>
<p>terraform init中有一个步骤，专门负责模块的安装。它支持从本地路径、Terraform Registry、GitHub、一般性Git仓库、HTTP URL、S3桶等多种来源下载模块。</p>
<div class="blog_h3"><span class="graybg">本地路径</span></div>
<p>对于紧密相关的模块，例如为了减少代码重复，从单一模块拆分得到的，建议使用本地路径方式存储。</p>
<pre class="crayon-plain-tag">module "consul" {
  #        必须以./或../开头，提示这是一个本地模块
  source = "./consul"
}</pre>
<p>本地路径的源具有一个特殊的地方，它没有“安装”这个步骤。</p>
<div class="blog_h3"><span class="graybg">Terraform Registry</span></div>
<p>对于希望公开分享的模块，可以存放在这个Terraform仓库中。这种仓库的源地址格式为<pre class="crayon-plain-tag">&lt;HOSTNAME&gt;/&lt;NAMESPACE&gt;/&lt;NAME&gt;/&lt;PROVIDER&gt;</pre>。示例： </p>
<pre class="crayon-plain-tag"># 这个模块创建一个Consul服务
module "consul" {
  # 为AWS提供的consule模块，使用Terraform公共仓库时HOSTNAME可以省略
  source = "hashicorp/consul/aws"
  version = "0.1.0"
}</pre>
<p>为了访问私有仓库，你可能需要在CLI配置中添加访问令牌。</p>
<div class="blog_h3"><span class="graybg">GitHub</span></div>
<p>如果source以github.com开头，则Terraform会尝试到GitHub拉取模块源码：</p>
<pre class="crayon-plain-tag">module "consul" {
  # 通过HTTPS
  source = "github.com/hashicorp/example"
}

module "consul" {
  # 通过SSH
  source = "git@github.com:hashicorp/example.git"
}</pre>
<div class="blog_h3"><span class="graybg">Git</span></div>
<p>如果source以<pre class="crayon-plain-tag">git::</pre>开头，则Terraform认为模块托管在一般性的Git服务器中：</p>
<pre class="crayon-plain-tag">module "vpc" {
  source = "git::https://example.com/vpc.git"
}

module "storage" {
  source = "git::ssh://username@example.com/storage.git"
}</pre>
<p>Terraform使用git clone命令下载模块，因此本地机器的任何Git配置都可用，包括凭证信息。</p>
<p>默认情况下，使用Git仓库的HEAD，要选择其它修订版，使用ref参数：</p>
<pre class="crayon-plain-tag">module "vpc" {
  source = "git::https://example.com/vpc.git?ref=v1.2.0"
}</pre>
<p>任何可以作为git checkout参数的值，都可以作为ref参数。</p>
<div class="blog_h3"><span class="graybg">HTTP</span></div>
<p>如果source指定为一个普通的URL，那么Terraform会：</p>
<ol>
<li>附加GET参数<pre class="crayon-plain-tag">terraform-get=1</pre>，请求那个URL</li>
<li>如果得到2xx应答，那么尝试从以下位置读取模块实际地址：
<ol>
<li>响应头<pre class="crayon-plain-tag">X-Terraform-Get</pre></li>
<li>HTML元素：<br />
<pre class="crayon-plain-tag">&lt;meta name="terraform-get" content="github.com/hashicorp/example" /&gt; </pre>
</li>
</ol>
</li>
</ol>
<p>如果URL的结尾是可识别的压缩格式扩展名（zip tar.bz2  tbz2 tar.gz  tgz tar.xz txz）则Terraform会跳过上面处理过程，直接下载压缩包：</p>
<pre class="crayon-plain-tag">module "vpc" {
  source = "https://example.com/vpc-module?archive=zip"
}</pre>
<div class="blog_h3"><span class="graybg">位于源子目录的模块</span></div>
<p>如果需要的模块位于源的子目录中，可以使用特殊的<pre class="crayon-plain-tag">//</pre>语法来引用：</p>
<p style="padding-left: 30px;">hashicorp/consul/aws//modules/consul-cluster<br />git::https://example.com/network.git//modules/vpc<br />https://example.com/network-module.zip//modules/vpc<br />s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/network.zip//modules/vpc<br />git::https://example.com/network.git//modules/vpc?ref=v1.2.0</p>
<div class="blog_h1"><span class="graybg">开发模块</span></div>
<p>开发模块就是构思和编写TF文件的过程，你需要考虑模块的输入（变量）、输出（值），模块读取和创建哪些资源。你只需要将这些TF文件放在一个目录中就可以了。</p>
<p>如果开发的模块可能被很多配置复用，建议在独立的版本库中管理。</p>
<p>尽管内嵌多个子目录（作为子模块）是允许的，但是建议尽可能的让目录扁平化，<span style="background-color: #c0c0c0;">可以使用模块组合，而避免深层次的目录树</span>，这可以增强可复用性。</p>
<div class="blog_h2"><span class="graybg">何时模块化</span></div>
<p>过度的模块化容易让配置难以理解。</p>
<p>一个好的模块应该通过描述架构中新概念来提升抽象层次，这个概念由一些Provider中的基本元素组成。例如，我们想基于一些CVM、一个CLB构建一个Redis集群，这样的集群就适合封装在一个模块中。</p>
<p>永远不要开发那种仅仅为了包装单个其它资源的模块。</p>
<div class="blog_h2"><span class="graybg">模块标准结构</span></div>
<pre class="crayon-plain-tag">$ tree complete-module/          # 根模块，这是标准模块结构中唯一必须的元素
.
├── README.md                    # 文档
├── main.tf                      # 建议文件名，模块主入口点，资源在此创建
├── variables.tf                 # 定义模块的输入变量
├── outputs.tf                   # 定义模块的输出值
├── ...
├── modules/                     # 嵌套的子模块
│   ├── nestedA/
│   │   ├── README.md
│   │   ├── variables.tf
│   │   ├── main.tf
│   │   ├── outputs.tf
│   ├── nestedB/
│   ├── .../
├── examples/                    # 使用模块的样例
│   ├── exampleA/
│   │   ├── main.tf
│   ├── exampleB/
│   ├── .../</pre>
<div class="blog_h2"><span class="graybg">模块组合</span></div>
<div class="blog_h3"><span class="graybg">简介</span></div>
<p>一个简单的Terraform配置，仅仅包含一个根模块，我们在这个扁平的结构中创建所有资源： </p>
<pre class="crayon-plain-tag">resource "aws_vpc" "example" {
  cidr_block = "10.1.0.0/16"
}

resource "aws_subnet" "example" {
  vpc_id = aws_vpc.example.id

  availability_zone = "us-west-2b"
  cidr_block        = cidrsubnet(aws_vpc.example.cidr_block, 4, 1)
}</pre>
<p>当引入<pre class="crayon-plain-tag">modules</pre>块后，配置变成层次化的，每个模块可以创建自己的资源，甚至有自己的下级子模块。 无节制的使用子模块会导致很深的树状配置结构，这是反模式。</p>
<div class="blog_h3"><span class="graybg">依赖反转原则</span></div>
<p>Terraform建议保持配置的扁平，仅仅引入一层子模块。假设我们需要在AWS上创建一个Consul集群，它依赖一个子网。我们可以将子网的创建封装到模块：</p>
<pre class="crayon-plain-tag">module "network" {
  source = "./modules/aws-network"

  base_cidr_block = "10.0.0.0/8"
}</pre>
<p>将Consul集群的创建封装到另外一个模块：</p>
<pre class="crayon-plain-tag">module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.subnet_ids
}</pre>
<p>根模块通过上面两个module块使用这两个子模块，这种<span style="background-color: #c0c0c0;">简单的单层结构叫模块组合（ module composition）</span></p>
<p>上面这个例子也体现了<span style="background-color: #c0c0c0;">依赖反转</span>的原则： consul_cluster需要一个网络，但是不是它（这个模块）自己去创建网络，而是由外部创建并注入给它。</p>
<p>在未来进一步的重构中，可能由另外一个配置负责创建网络，而仅仅通过数据资源将读取网络信息：</p>
<pre class="crayon-plain-tag">data "aws_vpc" "main" {
  tags = {
    Environment = "production"
  }
}

data "aws_subnet_ids" "main" {
  vpc_id = data.aws_vpc.main.id
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = data.aws_vpc.main.id
  subnet_ids = data.aws_subnet_ids.main.ids
}</pre>
<div class="blog_h3"><span class="graybg">条件性创建</span></div>
<p>开发可复用模块时，一个很常见的情况是，某个依赖的资源在某些条件下不需要创建 —— 例如在某些环境下，资源预先已经存在。</p>
<p>这种情况下同样要应用依赖反转原则：将这些可能需要创建的<span style="background-color: #c0c0c0;">依赖资源作为模块的输入参数</span>：</p>
<pre class="crayon-plain-tag"># 下面这个变量代表依赖的资源
variable "ami" {
  type = object({
    # 仅仅需要定义对于本模块有意义的属性
    id           = string
    architecture = string
  })
}</pre>
<p>由模块的调用者决定创建依赖资源：</p>
<pre class="crayon-plain-tag">resource "aws_ami" "example" {
  name              = "local-copy-of-ami"
  source_ami_id     = "ami-abc123"
  source_ami_region = "eu-west-1"
}

module "example" {
  source = "./modules/example"
  ami = aws_ami.example
}</pre>
<p>还是直接使用已经存在的依赖资源：</p>
<pre class="crayon-plain-tag">data "aws_ami" "example" {
  owner = "10000"
  tags = {
    application = "example-app"
    environment = "dev"
  }
}

module "example" {
  source = "./modules/example"
  ami = data.aws_ami.example
}</pre>
<p>因为，只有调用者才清楚实际环境是什么样的。 </p>
<div class="blog_h3"><span class="graybg">多云抽象</span></div>
<p>Terraform本身没有提供适配多个云服务商的相似资源的抽象，这会屏蔽云服务商的差异性。尽管如此，作为Terraform用户来说，进行多云抽象是常见需求，特别是云迁移这样的应用场景。 </p>
<p>举例来说，任何一个云服务商的域名系统都支持域名解析。但是某些云服务商可能提供智能负载均衡、地理位置感知的解析这样高级特性。我们可以将域名系统的公共特性抽象为模块：</p>
<pre class="crayon-plain-tag">module "webserver" {
  source = "./modules/webserver"
}

locals {
  fixed_recordsets = [
    {
      name = "www"
      type = "CNAME"
      ttl  = 3600
      records = [
        "webserver01",
        "webserver02",
      ]
    },
  ]
  server_recordsets = [
    for i, addr in module.webserver.public_ip_addrs : {
      name    = format("webserver%02d", i)
      type    = "A"
      records = [addr]
    }
  ]
}</pre>
<p>上面的recordset，抽象了所有域名系统都应该支持的DNS记录集资源。</p>
<p>当针对某个云服务商实现此资源时，我们可以开发一个模块。</p>
<pre class="crayon-plain-tag"># 此模块在AWS Route53上实现了记录集资源
module "dns_records" {
  source = "./modules/route53-dns-records"
  route53_zone_id = var.route53_zone_id
  recordsets      = concat(local.fixed_recordsets, local.server_recordsets)
}</pre>
<p>需要切换云服务商时，只需要替换上述dns_records模块的source即可，指向对应的模块即可。所有这些模块都需要定义输入参数：</p>
<pre class="crayon-plain-tag">variable "recordsets" {
  type = list(object({
    name    = string
    type    = string
    ttl     = number
    records = list(string)
  }))
}</pre>
<div class="blog_h3"><span class="graybg">仅Data模块</span></div>
<p>大部分模块都会描述需要被创建和管理的基础设施对象，偶尔的情况下，模块仅仅去抓取需要的信息。</p>
<p>其中一种情况是，一套系统被划分为多个子系统，这些子系统都需要获取某种信息，这些信息可以用由一个data-only模块抓取。</p>
<div class="blog_h2"><span class="graybg">发布模块</span></div>
<p>出于复用目的的模块可以发布到Terraform Registry， 这样模块可以<a href="#module-source">很容易被所有人使用</a>。如果仅仅在组织内部共享，可以发布到私有仓库。</p>
<p>通过Git、S3、HTTP等方式发布模块也是可以的，模块支持多种源。</p>
<div class="blog_h1"><span class="graybg">提供者(providers) </span></div>
<p>所谓提供者，就是Terraform的插件/驱动，负责和特定云厂商的API或者其它任何API打交道。</p>
<p>每个<span style="background-color: #c0c0c0;">Provider都可以提供若干受管资源、数据资源</span>，供Terraform使用。 反过来说，任何资源都是由Provider提供，没有Provider，Terraform什么都做不了。</p>
<p>Provider和Terraform完全独立的分发，公共的Provider托管在Terraform仓库（Registry）。</p>
<div class="blog_h2"><span class="graybg">安装Provider</span></div>
<p>在每次Terraform运行过程中，需要的Provider会自动被安装。</p>
<p>Terraform CLI会在初始化工作目录的时候安装Provider，它能够<span style="background-color: #c0c0c0;">自动从Terraform Registry下载Provider，或者从本地镜像/缓存加载</span>。要指定缓存位置，在CLI配置文件中设置<pre class="crayon-plain-tag">plugin_cache_dir</pre>。或者设置环境变量<pre class="crayon-plain-tag">TF_PLUGIN_CACHE_DIR</pre></p>
<p>为了保证，针对一套配置，<span style="background-color: #c0c0c0;">总是安装相同版本的Provider，可以使用CLI创建一个依赖锁文件</span>，并将此文件和配置一起纳入版本管理。</p>
<div class="blog_h2"><span class="graybg">引入Provider</span></div>
<p>每个模块都必须声明它需要哪些Provider，这样Terraform才能够安装和使用它们。声明在<pre class="crayon-plain-tag">required_providers</pre>块中进行：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    # 该块的每个参数，启用一个Provider
    # key是Provider的本地名称，必须是模块范围内的唯一标识符
    mycloud = {
      # 源地址
      source  = "mycorp/mycloud"
      # 版本约束
      version = "~&gt; 1.0"
    }
  }
}</pre>
<p>在required_providers块之外，Terraform总是使用本地名称来引用Provider：</p>
<pre class="crayon-plain-tag">provider "mycloud" {
  # 配置mycloud这个Provider
}</pre>
<div class="blog_h3"><span class="graybg">源地址</span></div>
<p>Provider的源地址，是它的全局标识符，这个地址当然也指明了应该从何处下载Provider。</p>
<p>源地址的格式为：<pre class="crayon-plain-tag">[&lt;HOSTNAME&gt;/]&lt;NAMESPACE&gt;/&lt;TYPE&gt;</pre>，各字段说明如下： </p>
<ol>
<li>可选的主机名，默认registry.terraform.io，即Terraform Registry的主机名</li>
<li>命名空间，通常是发布Provider的组织</li>
<li>类型，通常是Provider管理的平台/系统的简短名称</li>
</ol>
<div class="blog_h3"><span class="graybg">版本约束</span></div>
<p><pre class="crayon-plain-tag">&gt;= 1.0</pre>表示要求1.0或更高版本。<pre class="crayon-plain-tag">~&gt; 1.0.4</pre>表示仅仅允许1.0.x版本。</p>
<div class="blog_h2"><span class="graybg">内置Provider</span></div>
<p>目前仅有一个内置于Terraform的Provider，名为terraform_remote_state。你不需要再配置文件中引入它，尽管如此它还是有自己的源地址terraform.io/builtin/terraform。</p>
<div class="blog_h2"><span class="graybg">私有Provider</span></div>
<p>某些组织可能会开发自己的Provider，以管理特殊的基础设施。他们可能希望在Terraform中使用这些Provider，但是却不将其发布到公共仓库中。这种情况下，，构建自己的私有仓库。更简单的，可以使用<span style="background-color: #c0c0c0;">更简单的Provider安装方法，例如自己将其存放在本地文件系统的特定目录</span>。</p>
<p>任何Provider都必须有源地址，源地址必须包含（或者隐含默认值）一个主机名。如果通过本地文件系统分发Provider，则这个主机名只是个占位符，甚至不需要可解析。你通常可以使用terraform.yourcompany.com作为主机名。你可以这样引入私有Provider：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    mycloud = {
      source  = "terraform.example.com/examplecorp/ourcloud"
      version = "&gt;= 1.0"
    }
  }
}</pre>
<p>选择一个隐式本地镜像目录（implied local mirror directories ），并创建目录terraform.example.com/examplecorp/<span style="background-color: #99cc00;">ourcloud</span>/1.0.0，在此目录中创建一个代表运行平台的子目录，例如linux_amd64，并将Provider的可执行文件、以及任何其它需要的文件存放到其中即可。对于Windows，可执行文件的路径可能是terraform.example.com/examplecorp/ourcloud/1.0.0/windows_amd64/<span style="background-color: #c0c0c0;">terraform-provider</span>-<span style="background-color: #99cc00;">ourcloud</span>.exe</p>
<div class="blog_h2"><span class="graybg">配置Provider</span></div>
<p>除了引入Provider，你可能还需要对其进行配置才能使用。</p>
<p>配置时，使用<span style="color: #1d1e23;">provider块。只有根模块才可以配置一个Provider。<span style="background-color: #c0c0c0;">子模块会自动从根模块继承Provider配置</span>。示例：</span></p>
<pre class="crayon-plain-tag">#        本地名称，引入Provider时指定
provider "google" {
  project = "acme-app"
  region  = "us-central1"
}</pre>
<p>具体哪些配置参数可用，取决于Provider。配置时可以使用表达式，但是<span style="background-color: #c0c0c0;">只能引用那些应用配置之前即可知的值 —— 可以安全的引用输入变量，但是不能使用那些由资源导出的属性</span>。</p>
<div class="blog_h3"><span class="graybg">alias</span></div>
<p>Provider支持两个元参数，其中一个是alias，用于定义备选的Provider配置：</p>
<pre class="crayon-plain-tag">provider "aws" {
  alias  = "west"
  region = "us-west-2"
}</pre>
<p>声明资源时，可以指定使用备选的Provider配置： </p>
<pre class="crayon-plain-tag">resource "aws_instance" "foo" {
  provider = aws.west
} </pre>
<div class="blog_h3"><span class="graybg">version</span></div>
<p>这个元参数已经弃用，是旧的管理Provider版本的方式。 </p>
<div class="blog_h2"><span class="graybg">依赖锁文件</span></div>
<p>Terraform配置文件可以引用两类外部依赖：</p>
<ol>
<li>Providers，如上个章节所述，用于和外部系统交互的插件</li>
<li>Modules，可复用的配置文件集合</li>
</ol>
<p>这两类依赖都可以独立发布，并进行版本管理。引用这些依赖时，Terraform需要知道使用什么版本。</p>
<p>配置文件中的版本约束，指定了潜在的兼容性版本范围。但是到底选择（并锁定使用）依赖的哪个版本，由<span style="background-color: #c0c0c0;">名为.terraform.lock.hcl的依赖锁文件</span>决定。注意，当前<span style="background-color: #c0c0c0;">依赖锁文件仅仅管理Provider的版本，对于Module，仍然总是拉取匹配版本约束的最新版本</span>。</p>
<p>每当运行<pre class="crayon-plain-tag">terraform init</pre>命令时，依赖锁文件会自动创建/更新。此文件应该纳入版本管理。依赖锁文件使用和TF类似的语法。</p>
<p>运行terraform init时，如果：</p>
<ol>
<li>依赖没有记录在依赖锁文件中，则尝试拉取匹配版本约束的最新版本。并将获取到的版本信息记录到依赖锁文件</li>
<li>依赖已经记录，则使用记录的版本</li>
</ol>
<p>运行<pre class="crayon-plain-tag">terraform init -upgrade</pre>会强制拉取最新的、匹配约束的版本并更新依赖锁文件。</p>
<div class="blog_h1"><span class="graybg">开发插件</span></div>
<div class="blog_h2"><span class="graybg">核心和插件</span></div>
<p>Terraform逻辑上划分为两个部分：核心和插件。Terraform核心通过RPC来调用插件。Terraform支持多种发现和加载插件的方式。Terraform插件有两类</p>
<ol>
<li>Provider：通常用于对接到某特定云服务商，在其上创建基础设施对象</li>
<li>Provisioner：对接到某种provisioner，例如Bash</li>
</ol>
<p>核心的职责包括：</p>
<ol>
<li>基础设施即代码：读取和解释配置文件和模块</li>
<li>资源状态管理</li>
<li>构造资源依赖图</li>
<li>执行计划</li>
<li>通过RPC和插件交互</li>
</ol>
<p>插件和核心一样，基于Go语言编写。Terraform使用的所有Provider和Provisioner都是插件，它们在独立进程中运行。</p>
<p>Provider插件的职责是：</p>
<ol>
<li>初始化任何必要的库，用于进行API调用</li>
<li>与基础设施提供者进行交互</li>
<li>定义映射到特定服务的资源</li>
</ol>
<p>Provisioner插件的职责是：</p>
<ol>
<li>在特定资源创建后、销毁前执行命令或脚本</li>
</ol>
<div class="blog_h2"><span class="graybg">插件的发现</span></div>
<p>当<pre class="crayon-plain-tag">terraform init</pre>运行后，Terraform会读取工作目录中的配置文件，确定需要哪些插件。并在多个位置搜索以及安装的插件，下载缺失的插件，确定使用插件的什么版本，并且更新依赖锁文件，锁定插件版本。</p>
<p>关于插件发现，有以下规则：</p>
<ol>
<li>如果已经安装了满足版本约束的插件，Terraform会使用其中最新的。即使Terraform Registry有更新的满足版本约束的插件，默认也不会主动下载。使用<pre class="crayon-plain-tag">terraform init -upgrade</pre>可以强制下载最新版本</li>
<li>如果没有安装满足版本约束的插件，且插件托管在Registry，则下载并存放到<pre class="crayon-plain-tag">.terraform/providers/</pre>目录下</li>
</ol>
<div class="blog_h2"><span class="graybg">Provider设计原则</span></div>
<div class="blog_h3"><span class="graybg">专注于单一API或SDK </span></div>
<p>Provider应该基于单一API集合，或者SDK，例如仅仅针对腾讯云API，实现对腾讯云基础实施对象CRUD的封装。</p>
<div class="blog_h3"><span class="graybg">资源应当表示单一API对象</span></div>
<p>Terraform插件定义的资源，应该对应单一的云资源，作为该云资源的声明式表示。资源通常应该提供创建/读取/删除，以及可选的更新方法。</p>
<p><span style="background-color: #c0c0c0;">对多个云资源的组合，或者其它高级特性，应该通过模块来实现</span>。</p>
<div class="blog_h3"><span class="graybg">资源及其属性的Schema应当尽可能和底层API匹配</span></div>
<p>名称、数据类型、结构，应当尽可能匹配，除非这样做会影响Provider用户的体验。</p>
<div class="blog_h3"><span class="graybg">资源应该可导入</span></div>
<p>Terraform资源应该支持<pre class="crayon-plain-tag">terraform import</pre>操作。</p>
<div class="blog_h3"><span class="graybg">注意状态和版本</span></div>
<p>一旦Provider发布，后续就面临向后兼容性问题。</p>
<div class="blog_h2"><span class="graybg">两个SDK</span></div>
<p>开发Provider时，有两个SDK可供选择：</p>
<ol>
<li>SDKv2：当前大部分现有的Provider基于此SDK开发，提供稳定的开发体验</li>
<li>Terraform Plugin Framework：新的SDK，还在积极的开发中。目标是提升开发体验，对齐Terraform新的架构</li>
</ol>
<p>如何选择：</p>
<ol>
<li>如果维护既有Provider，沿用它当前使用的SDK。如果开发全新的Provider，可以考虑使用Framework</li>
<li>如果使用的Terraform版本小于v1.0.3，则只能基于SDKv2开发。Framework基于Terraform Protocol Version 6构建，旧版本的Terraform不能下载基于Version 6的Provider</li>
<li>Framework的接口可能发生改变，考虑成本</li>
<li>是否需要Framework提供的新特性：
<ol>
<li>支持获知一个值是否在配置、状态或计划中设置</li>
<li>支持获知一个值是否null、unknown，或者是空白值</li>
<li>支持object这样的结构化类型</li>
<li>支持嵌套属性</li>
<li>支持以any类型为元素的map</li>
<li>支持获知何时一个optional或计算出的字段从配置中移除了</li>
</ol>
</li>
<li>是否需要Framework尚未实现的，SDKv2已经支持的特性：
<ol>
<li>超时支持</li>
<li>定义资源状态upgraders</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">手工构建和发布Provider</span></div>
<div class="blog_h3"><span class="graybg">构建Provider</span></div>
<p>构建Provider的时候，使用go build命令，按照构建二进制文件的常规方式即可。你也可以使用GoReleaser来自动化构建多平台的、包含checksum的、自动签名的Provider。</p>
<div class="blog_h3"><span class="graybg">准备一个发布</span></div>
<p>每个发布包含以下文件：</p>
<ol>
<li>一个或多个zip文件，其命名格式为<pre class="crayon-plain-tag">terraform-provider-{NAME}_{VERSION}_{OS}_{ARCH}.zip</pre>
<ol>
<li>zip中包含Provider的二进制文件，命名为<pre class="crayon-plain-tag">terraform-provider-{NAME}_v{VERSION}</pre></li>
</ol>
</li>
<li>一个摘要文件<pre class="crayon-plain-tag">terraform-provider-{NAME}_{VERSION}_SHA256SUMS</pre>，包含每个zip的sha256：<br />
<pre class="crayon-plain-tag">shasum -a 256 *.zip &gt; terraform-provider-{NAME}_{VERSION}_SHA256SUMS</pre>
</li>
<li>一个GPG二进制文件<pre class="crayon-plain-tag">terraform-provider-{NAME}_{VERSION}_SHA256SUMS.sig</pre>，使用密钥对上述摘要文件进行前面：<br />
<pre class="crayon-plain-tag">gpg --detach-sign terraform-provider-{NAME}_{VERSION}_SHA256SUMS</pre>
</li>
<li>发布必须是finalized的（不是一个私有的草稿）</li>
</ol>
<div class="blog_h1"><span class="graybg">基于SDKv2开发Provider</span></div>
<p>本章开发和使用一个Provider，它和虚构的咖啡店应用Hashicups进行交互。此咖啡店应用支持查看和订购咖啡，它开放公共API端点：</p>
<ol>
<li>列出咖啡品种</li>
<li>列出特定咖啡的成分</li>
</ol>
<p>以及需要身份认证的API端点：</p>
<ol>
<li>CRUD咖啡订单</li>
</ol>
<p>HashiCups Provider基于一个Golang客户端库，利用REST API访问以上API端点，管理咖啡订单。</p>
<div class="blog_h2"><span class="graybg">使用Provider</span></div>
<p>首先我们从用户角度来感受一下，如何使用HashiCups Provider管理咖啡订单。本章后续会分析和重构该Provider的源码。</p>
<div class="blog_h3"><span class="graybg">下载空白项目</span></div>
<p>执行下面的命令下载使用HashiCups Provider的Terraform配置的空白项目。此项目没有Terraform配置，但是提供了在本地运行HashiCup咖啡店应用的脚本。</p>
<pre class="crayon-plain-tag">git clone https://github.com/hashicorp/learn-terraform-hashicups-provider
cd learn-terraform-hashicups-provider</pre>
<p>执行下面的命令，在本地启动HashiCup咖啡店应用：</p>
<pre class="crayon-plain-tag">cd docker_compose &amp;&amp; docker-compose up</pre>
<p>API监听端口是19090。检查并确认服务器正常工作：</p>
<pre class="crayon-plain-tag">curl localhost:19090/health </pre>
<div class="blog_h3"><span class="graybg">安装Provider</span></div>
<p>从0.13+开始，必须在Terraform配置中声明所有依赖的Provider及其源（从哪里下载）。<span style="background-color: #c0c0c0;">源的格式为[hostname]/[namespace]/[name]，对于Terraform Registry中的hashicorp命名空间，hostname和namespace都是可选的。Terraform Registry对应的hostname为registry.terraform.io</span></p>
<p>这里用到的Provider没有托管在Registry，需要手工下载：</p>
<pre class="crayon-plain-tag">curl -LO https://github.com/hashicorp/terraform-provider-hashicups/releases/download/v0.3.1/terraform-provider-hashicups_0.3.1_linux_amd64.zip</pre>
<p>或者从源码编译：</p>
<pre class="crayon-plain-tag">git clone https://github.com/hashicorp/terraform-provider-hashicups
go mod vendor
go build -o terraform-provider-hashicups
mv terraform-provider-hashicups ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64</pre>
<p>并且存放到：</p>
<p style="padding-left: 30px;">~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target}<br />~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1</p>
<div class="blog_h3"><span class="graybg">创建用户</span></div>
<pre class="crayon-plain-tag">curl -X POST localhost:19090/signup -d '{"username":"education", "password":"test123"}'</pre>
<p>登录以获得Token：</p>
<pre class="crayon-plain-tag">curl -X POST localhost:19090/signin -d '{"username":"education", "password":"test123"}'
{"UserID":1,"Username":"education","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw"}</pre>
<p>将Token设置到环境变量：</p>
<pre class="crayon-plain-tag">export HASHICUPS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw</pre>
<div class="blog_h3"><span class="graybg">初始化工作区</span></div>
<p>添加下面的代码到main.tf：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    hashicups = {
      version = "~&gt; 0.3.1"
      source  = "hashicorp.com/edu/hashicups"
    }
  }
}</pre>
<p>并进行初始化：<pre class="crayon-plain-tag">terraform init</pre></p>
<div class="blog_h3"><span class="graybg">创建订单</span></div>
<p>将以下内容添加到main.tf中：</p>
<pre class="crayon-plain-tag"># 配置Provider
provider "hashicups" {
  username = "education"
  password = "test123"
}

# 定义一个名为edu的订单资源
resource "hashicups_order" "edu" {
  # 订单包含2个品种3的咖啡
  items {
    coffee {
      id = 3
    }
    quantity = 2
  }
  # 订单包含2个品种2的咖啡
  items {
    coffee {
      id = 2
    }
    quantity = 2
  }
}

# 输出edu资源，这个输出在资源创建后可用
output "edu_order" {
  value = hashicups_order.edu
}</pre>
<p>执行下面的命令获取执行计划：</p>
<pre class="crayon-plain-tag">terraform plan 

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create # +表示创建操作

Terraform will perform the following actions:
  # 这里会列出计划中包含的操作
  #                             这是一个创建操作
  # hashicups_order.edu will be created
  + resource "hashicups_order" "edu" {
      # 每个属性的值     这个表示未知值
      + id           = (known after apply)
      + last_updated = (known after apply)

      + items {
          + quantity = 2

          + coffee {
              + description = (known after apply)
              + id          = 3
              + image       = (known after apply)
              + name        = (known after apply)
              + price       = (known after apply)
              + teaser      = (known after apply)
            }
        }
      + items {
          + quantity = 2

          + coffee {
              + description = (known after apply)
              + id          = 2
              + image       = (known after apply)
              + name        = (known after apply)
              + price       = (known after apply)
              + teaser      = (known after apply)
            }
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

# 这里显示输出值会发生的变更
Changes to Outputs:
  + edu_order = {
      + id           = (known after apply)
      + items        = [
          + {
              + coffee   = [
                  + {
                      + description = (known after apply)
                      + id          = 3
                      + image       = (known after apply)
                      + name        = (known after apply)
                      + price       = (known after apply)
                      + teaser      = (known after apply)
                    },
                ]
              + quantity = 2
            },
          + {
              + coffee   = [
                  + {
                      + description = (known after apply)
                      + id          = 2
                      + image       = (known after apply)
                      + name        = (known after apply)
                      + price       = (known after apply)
                      + teaser      = (known after apply)
                    },
                ]
              + quantity = 2
            },
        ]
      + last_updated = (known after apply)
    }</pre>
<p>执行<pre class="crayon-plain-tag">terraform apply</pre>即可应用变更。 利用<pre class="crayon-plain-tag">terraform state show</pre>命令可以显示资源状态：</p>
<pre class="crayon-plain-tag">terraform state show hashicups_order.edu 
# hashicups_order.edu:
resource "hashicups_order" "edu" {
    id = "1"

    items {
        quantity = 2

        coffee {
            id     = 3
            image  = "/nomad.png"
            name   = "Nomadicano"
            price  = 150
            teaser = "Drink one today and you will want to schedule another"
        }
    }
    items {
        quantity = 2

        coffee {
            id     = 2
            image  = "/vault.png"
            name   = "Vaulatte"
            price  = 200
            teaser = "Nothing gives you a safe and secure feeling like a Vaulatte"
        }
    }
}</pre>
<p>可以看到Schema中所有属性均被填充。</p>
<div class="blog_h3"><span class="graybg">更新订单</span></div>
<p>我们修改一下订单配置，将items[*].quantity改一下，然后看看执行计划：</p>
<pre class="crayon-plain-tag">terraform plan
hashicups_order.edu: Refreshing state... [id=1]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place  # ~表示原地更新操作

Terraform will perform the following actions:
  # 这里会显示diff
  # hashicups_order.edu will be updated in-place
  ~ resource "hashicups_order" "edu" {
        id = "1"

      ~ items {
          ~ quantity = 2 -&gt; 3

            # (1 unchanged block hidden)
        }
      ~ items {
          ~ quantity = 2 -&gt; 1

            # (1 unchanged block hidden)
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Changes to Outputs:
  # 这里会显示diff
  ~ edu_order = {
      ~ items        = [
          ~ {
              ~ quantity = 2 -&gt; 3
                # (1 unchanged element hidden)
            },
          ~ {
              ~ quantity = 2 -&gt; 1
                # (1 unchanged element hidden)
            },
        ]
        # (2 unchanged elements hidden)
    }</pre>
<p>通过apply命令应用上述执行计划。</p>
<div class="blog_h3"><span class="graybg">读取配料表 </span></div>
<p>本节我们来演示如何读取已经创建的订单的咖啡的配料表：</p>
<pre class="crayon-plain-tag">data "hashicups_ingredients" "first_coffee" {
  #                               声明多次的内嵌块，自动成为数组
  coffee_id = hashicups_order.edu.items[0].coffee[0].id
}

output "first_coffee_ingredients" {
  value = data.hashicups_ingredients.first_coffee
}</pre>
<div class="blog_h3"><span class="graybg">删除订单 </span></div>
<pre class="crayon-plain-tag">terraform destroy
hashicups_order.edu: Refreshing state... [id=1]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy  # -表示删除操作

Terraform will perform the following actions:

  # hashicups_order.edu will be destroyed
  - resource "hashicups_order" "edu" {
      - id           = "1" -&gt; null
      - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST" -&gt; null

      - items {
          - quantity = 3 -&gt; null

          - coffee {
              - id     = 3 -&gt; null
              - image  = "/nomad.png" -&gt; null
              - name   = "Nomadicano" -&gt; null
              - price  = 150 -&gt; null
              - teaser = "Drink one today and you will want to schedule another" -&gt; null
            }
        }
      - items {
          - quantity = 1 -&gt; null

          - coffee {
              - id     = 2 -&gt; null
              - image  = "/vault.png" -&gt; null
              - name   = "Vaulatte" -&gt; null
              - price  = 200 -&gt; null
              - teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" -&gt; null
            }
        }
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  - edu_order                = {
      - id           = "1"
      - items        = [
          - {
              - coffee   = [
                  - {
                      - description = ""
                      - id          = 3
                      - image       = "/nomad.png"
                      - name        = "Nomadicano"
                      - price       = 150
                      - teaser      = "Drink one today and you will want to schedule another"
                    },
                ]
              - quantity = 3
            },
          - {
              - coffee   = [
                  - {
                      - description = ""
                      - id          = 2
                      - image       = "/vault.png"
                      - name        = "Vaulatte"
                      - price       = 200
                      - teaser      = "Nothing gives you a safe and secure feeling like a Vaulatte"
                    },
                ]
              - quantity = 1
            },
        ]
      - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST"
    } -&gt; null
  - first_coffee_ingredients = {
      - coffee_id   = 3
      - id          = "3"
      - ingredients = [
          - {
              - id       = 1
              - name     = "ingredient - Espresso"
              - quantity = 20
              - unit     = "ml"
            },
          - {
              - id       = 3
              - name     = "ingredient - Hot Water"
              - quantity = 100
              - unit     = "ml"
            },
        ]
    } -&gt; null</pre>
<div class="blog_h2"><span class="graybg">实现读操作</span></div>
<p>签出Provider源码：</p>
<pre class="crayon-plain-tag">git clone --branch boilerplate https://github.com/hashicorp/terraform-provider-hashicups</pre>
<p>注意SDKv2的依赖：</p>
<pre class="crayon-plain-tag">github.com/hashicorp/terraform-plugin-sdk/v2 v2.8.0 </pre>
<div class="blog_h3"><span class="graybg">骨架代码</span></div>
<p>分支boilerplate包含一些样板文件。 程序的入口点如下：</p>
<pre class="crayon-plain-tag">package main

import (
	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
	"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"

	"terraform-provider-hashicups/hashicups"
)

func main() {
    // 启动插件的RPC服务器端
	plugin.Serve(&amp;plugin.ServeOpts{
		ProviderFunc: func() *schema.Provider {
			return hashicups.Provider()
		},
	})
}</pre>
<p>Provider函数：</p>
<pre class="crayon-plain-tag">package hashicups

import (
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// 定义了一个Provider
func Provider() *schema.Provider {
  return &amp;schema.Provider{
    // 资源映射，从资源类型名到Schema
    ResourcesMap: map[string]*schema.Resource{},
    // 数据资源映射，从资源类型名到Schema。注意资源和数据资源本质上是同一种结构
    DataSourcesMap: map[string]*schema.Resource{},
  }
}</pre>
<div class="blog_h3"><span class="graybg">定义咖啡数据源 </span></div>
<p>上面已经定义了Provider的骨架，这里我们实现一个咖啡数据源，此数据源能够从HasiCups服务拉取所有售卖的咖啡信息。</p>
<p>建议<span style="background-color: #c0c0c0;">每个数据源都在独立的源文件中编写，并且文件名以<pre class="crayon-plain-tag">data_source_</pre>开头</span>： </p>
<pre class="crayon-plain-tag">package hashicups

import (
  "context"
  "encoding/json"
  "fmt"
  "net/http"
  "strconv"
  "time"

  "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// 定义了一个Resource，其中包含Schema以及CRUD操作
func dataSourceCoffees() *schema.Resource {
  return &amp;schema.Resource{
    // 由于数据资源仅仅支持读操作，因此仅声明ReadContext
    ReadContext: dataSourceCoffeesRead,
    Schema: map[string]*schema.Schema{},
  }
}</pre>
<p>下面我们完善此数据资源的Schema，根据Terraform的最佳实践，Schema应该尽量和基础实施匹配：</p>
<pre class="crayon-plain-tag">// curl localhost:19090/coffees
[
  {
    "id": 1,
    "name": "Packer Spiced Latte",
    "teaser": "Packed with goodness to spice up your images",
    "description": "",
    "price": 350,
    "image": "/packer.png",
    "ingredients": [
      {
        "ingredient_id": 1
      },
      {
        "ingredient_id": 2
      },
      {
        "ingredient_id": 4
      }
    ]
  }
]</pre>
<p>根据服务器返回的咖啡数据结构，编写对应的Schema并且添加到上面的dataSourceCoffees方法中：</p>
<pre class="crayon-plain-tag">Schema: map[string]*schema.Schema{
			"coffees": {
				// 注意这里是列表
				Type:     schema.TypeList,
				Computed: true,
				Elem: &amp;schema.Resource{
					Schema: map[string]*schema.Schema{
						"id": {
							Type:     schema.TypeInt,
							// 此值是"计算得到的"，也就是在创建资源时（除非手工配置）会得到此值的结果
							Computed: true,
						},
						"name": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"teaser": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"description": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"price": {
							Type:     schema.TypeInt,
							Computed: true,
						},
						"image": {
							Type:     schema.TypeString,
							Computed: true,
						},
						// 复杂类型，一个列表
						"ingredients": {
							Type:     schema.TypeList,
							Computed: true,
							// 列表元素的Schema
							Elem: &amp;schema.Resource{
								Schema: map[string]*schema.Schema{
									"ingredient_id": {
										Type:     schema.TypeInt,
										Computed: true,
									},
								},
							},
							// 如果是list(string)，可以这样声明Elem
							Elem: schema.Schema{
								Type:             schema.TypeString,
							},
						},
					},
				},
			},
		},
	}
}</pre>
<p>定义好Schema后，我们需要实现读操作： </p>
<pre class="crayon-plain-tag">//                                              读结果存放在这里          这个m是meta，元参数，
//                                                                     是配置Provider的返回值，下文有说明
func dataSourceCoffeesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	client := &amp;http.Client{Timeout: 10 * time.Second}

	// 这是一个切片，用于收集警告或者错误信息
	var diags diag.Diagnostics

	// 像云API发起请求
	req, err := http.NewRequest("GET", fmt.Sprintf("%s/coffees", "http://localhost:19090"), nil)
	if err != nil {
		// 收集错误
		return diag.FromErr(err)
	}
	r, err := client.Do(req)
	if err != nil {
		return diag.FromErr(err)
	}
	defer r.Body.Close()

	// 将响应反串行化到临时对象中
	coffees := make([]map[string]interface{}, 0)
	err = json.NewDecoder(r.Body).Decode(&amp;coffees)
	if err != nil {
		return diag.FromErr(err)
	}

	// schema.ResourceData用于查询和设置资源属性
	//        此方法将响应设置到Terraform数据源，并且保证字段设置到Schema对应位置
	if err := d.Set("coffees", coffees); err != nil {
		return diag.FromErr(err)
	}

	// 设置数据资源的ID
	d.SetId(strconv.FormatInt(time.Now().Unix(), 10))

	return diags
}</pre>
<p><span style="background-color: #c0c0c0;">数据资源的ID被设置为非空值，这提示Terraform，目标资源已经被创建</span>。 作为一个列表，此数据资源没有真实的ID，因此我们这里设置为时间戳。</p>
<p>如果数据资源被从Terraform外部删除了，这里应该设置空ID：</p>
<pre class="crayon-plain-tag">if resourceDoesntExist {
  d.SetID("")
  return
}</pre>
<p>这样Terraform会自动将state中的数据资源清除掉。</p>
<p>将上面的数据资源配置到Provider中：</p>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		ResourcesMap: map[string]*schema.Resource{},
		DataSourcesMap: map[string]*schema.Resource{
			"hashicups_coffees": dataSourceCoffees(),
		},
	}
}</pre>
<div class="blog_h3"><span class="graybg">使用咖啡数据源</span></div>
<p>现在我们开发一个模块，使用上面定义的咖啡数据源。首先需要编译好Provider：<pre class="crayon-plain-tag">make install</pre>。 </p>
<p>根模块配置：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    hashicups = {
      // 当前构建的版本是0.2
      version = "0.2"
      source  = "hashicorp.com/edu/hashicups"
    }
  }
}

// 目前Provider不支持配置
provider "hashicups" {}

// 调用coffee子模块
module "psl" {
  source = "./coffee"
  // 传递输入参数
  coffee_name = "Packer Spiced Latte"
}

// 打印coffee模块的coffee输出值
output "psl" {
  value = module.psl.coffee
}</pre>
<p>coffee子模块配置：</p>
<pre class="crayon-plain-tag">// 子模块需要声明自己的依赖
terraform {
  required_providers {
    hashicups = {
      version = "0.2"
      source  = "hashicorp.com/edu/hashicups"
    }
  }
}

// 输入变量，咖啡品类
variable "coffee_name" {
  type    = string
  default = "Vagrante espresso"
}

// 调用上面开发的数据源，拉取所有咖啡品类
data "hashicups_coffees" "all" {}

# 输出所有咖啡
output "all_coffees" {
  value = data.hashicups_coffees.all.coffees
}

output "coffee" {
  value = {
    // 遍历所有咖啡
    for coffee in data.hashicups_coffees.all.coffees :
    // 返回咖啡ID到咖啡资源的映射
    coffee.id =&gt; coffee
    // 过滤，要求名称匹配输入参数
    if coffee.name == var.coffee_name
  }
}</pre>
<p>应用根模块：</p>
<pre class="crayon-plain-tag"># terraform init &amp;&amp; terraform apply --auto-approve

psl = {
  "1" = {
    "description" = ""
    "id" = 1
    "image" = "/packer.png"
    "ingredients" = tolist([
      {
        "ingredient_id" = 1
      },
      {
        "ingredient_id" = 2
      },
      {
        "ingredient_id" = 4
      },
    ])
    "name" = "Packer Spiced Latte"
    "price" = 350
    "teaser" = "Packed with goodness to spice up your images"
  }
}</pre>
<div class="blog_h2"><span class="graybg">添加身份验证 </span></div>
<p>本节我们来演示如何为Provider增加参数，Provider的实现又是如何读取这些参数的。</p>
<div class="blog_h3"><span class="graybg">Provider的Schema </span></div>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		// ...
		// 定义Provider的配置参数
		Schema: map[string]*schema.Schema{
			"username": {
				Type:        schema.TypeString,
				Optional:    true,
				DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_USERNAME", nil),
			},
			"password": {
				Type:        schema.TypeString,
				Optional:    true,
				// 敏感数据，在输出时会处理
				Sensitive:   true,
				// 默认值函数                        从环境变量读取默认值
				DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_PASSWORD", nil),
			},
		},
		ConfigureContextFunc: providerConfigure,
	}
}</pre>
<div class="blog_h3"><span class="graybg">配置Provider </span></div>
<p>用户提供的username/password，如何被Provider的ReadContext函数访问呢？这需要配置Provider。配置过程是由<pre class="crayon-plain-tag">schema.Provider</pre>的<pre class="crayon-plain-tag">ConfigureContextFunc</pre>函数负责的：</p>
<pre class="crayon-plain-tag">func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
	// 读取Provider的配置参数
	username := d.Get("username").(string)
	password := d.Get("password").(string)

	var diags diag.Diagnostics

	if (username != "") &amp;&amp; (password != "") {
		c, err := hashicups.NewClient(nil, &amp;username, &amp;password)
		if err != nil {
			return nil, diag.FromErr(err)
		}

		return c, diags
	}

	c, err := hashicups.NewClient(nil, nil, nil)
	if err != nil {
		return nil, diag.FromErr(err)
	}

	return c, diags
}</pre>
<p>可以看到配置Provider后，会返回一个interface{}，这个对象会传递给CRUD操作的最后一个参数：</p>
<pre class="crayon-plain-tag">// See Resource documentation.
type CreateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics

// See Resource documentation.
type ReadContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics

// See Resource documentation.
type UpdateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics

// See Resource documentation.
type DeleteContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics</pre>
<p>一般情况下，这个interface{}是配置好的云API客户端，或者是云API配置结构。</p>
<div class="blog_h2"><span class="graybg">复杂读操作 </span></div>
<p>本节我们演示如何在读操作中使用Provider参数，更精确的说是基于这些参数配置Provider后的结果。</p>
<p>首先我们创建几个咖啡订单（用于后续查询）：</p>
<pre class="crayon-plain-tag">curl -X POST -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders -d '[{"coffee": { "id":1 }, "quantity":4}, {"coffee": { "id":3 }, "quantity":3}]'</pre>
<p>看一下订单的数据结构：</p>
<pre class="crayon-plain-tag">// curl -X GET -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders/2 

{
  "id": 2,
  "items": [
    {
      "coffee": {
        "id": 1,
        "name": "Packer Spiced Latte",
        "teaser": "Packed with goodness to spice up your images",
        "description": "",
        "price": 350,
        "image": "/packer.png",
        "ingredients": null
      },
      "quantity": 4
    },
    {
      "coffee": {
        "id": 3,
        "name": "Nomadicano",
        "teaser": "Drink one today and you will want to schedule another",
        "description": "",
        "price": 150,
        "image": "/nomad.png",
        "ingredients": null
      },
      "quantity": 3
    }
  ]
}</pre>
<p>下面我们定义对应的数据源：</p>
<pre class="crayon-plain-tag">func dataSourceOrder() *schema.Resource {
	return &amp;schema.Resource{
		// 读取操作，见下文
		ReadContext: dataSourceOrderRead,
		Schema: map[string]*schema.Schema{
			"id": {
				Type:     schema.TypeInt,
				Required: true,
			},
			"items": {
				Type:     schema.TypeList,
				Computed: true,
				Elem: &amp;schema.Resource{
					Schema: map[string]*schema.Schema{
						"coffee_id": {
							Type:     schema.TypeInt,
							Computed: true,
						},
						"coffee_name": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"coffee_teaser": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"coffee_description": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"coffee_price": {
							Type:     schema.TypeInt,
							Computed: true,
						},
						"coffee_image": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"quantity": {
							Type:     schema.TypeInt,
							Computed: true,
						},
					},
				},
			},
		},
	}
}</pre>
<p>注意这个数据源的Schema和API的结构没有做对应，进行了扁平化处理。</p>
<p>读取订单的操作如下：</p>
<pre class="crayon-plain-tag">func dataSourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	// 配置Provider后，得到的是一个客户端
	c := m.(*hc.Client)
	var diags diag.Diagnostics

	orderID := strconv.Itoa(d.Get("id").(int))

	// 基于此客户端进行订单查询
	order, err := c.GetOrder(orderID)
	if err != nil {
		return diag.FromErr(err)
	}

	// 订单结构和我们数据源的结构不一致，这里做转换
	orderItems := flattenOrderItemsData(&amp;order.Items)
	if err := d.Set("items", orderItems); err != nil {
		return diag.FromErr(err)
	}

	d.SetId(orderID)

	return diags
}</pre>
<p>将此资源注册到Provider：</p>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		// ...
		DataSourcesMap: map[string]*schema.Resource{
			// ...
			"hashicups_order":   dataSourceOrder(),
		},
		ConfigureContextFunc: providerConfigure,
	}
}</pre>
<p>在配置文件中使用该数据源：</p>
<pre class="crayon-plain-tag">data "hashicups_order" "order" {
  id = 1
}

output "order" {
  value = data.hashicups_order.order
}</pre>
<div class="blog_h2"><span class="graybg">诊断和调试 </span></div>
<p>本节演示如何基于日志来调试Provider，我们会添加定制的错误消息，并且显示详细的Terraform Provider日志。</p>
<p>开发Provider时需要实现很多函数，这些函数常常具有一个返回值<pre class="crayon-plain-tag">diag.Diagnostics</pre>，例如上面的CRUD操作，以及配置Provider的函数：</p>
<pre class="crayon-plain-tag">type ConfigureContextFunc func(context.Context, *ResourceData) (interface{}, diag.Diagnostics)</pre>
<p>警告/错误级别的调试信息，都要放到diag.Diagnostics中，执行Terraform CLI命令时，这些信息会自动打印。</p>
<p>当创建HashiCups客户端失败时，我们可以添加一条针对信息：</p>
<pre class="crayon-plain-tag">func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
	// ...
	if (username != "") &amp;&amp; (password != "") {
		c, err := hashicups.NewClient(nil, &amp;username, &amp;password)
		if err != nil {
			diags = append(diags, diag.Diagnostic{
				Severity: diag.Error,
				Summary:  "Unable to create HashiCups client",
				Detail:   "Unable to auth user for authenticated HashiCups client",
			})
			return nil, diags
		}

		return c, diags
	}

	c, err := hashicups.NewClient(nil, nil, nil)
	if err != nil {
		diags = append(diags, diag.Diagnostic{
			Severity: diag.Error,
			Summary:  "Unable to create HashiCups client",
			Detail:   "Unable to auth user for authenticated HashiCups client",
		})
		return nil, diags
	}

	return c, diags
}</pre>
<p>执行命令时，错误信息会打印出来：</p>
<pre class="crayon-plain-tag">terraform init &amp;&amp; terraform apply --auto-approve
## ...
module.psl.data.hashicups_coffees.all: Refreshing state...

# 摘要
Error: Unable to create HashiCups client
# 详情
Unable to auth user for authenticated HashiCups client</pre>
<div class="blog_h2"><span class="graybg">实现创建操作 </span></div>
<p>资源支持创建、修改、删除等写操作，上面编写的数据源则仅支持读。尽管资源、数据源可能指向同一类实体，但是Schema不能共用。</p>
<div class="blog_h3"><span class="graybg">定义订单资源</span></div>
<p>资源的文件名前缀通常使用<pre class="crayon-plain-tag">resource_</pre>，下面是订单资源的骨架代码：</p>
<pre class="crayon-plain-tag">package hashicups

import (
  "context"

  "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func resourceOrder() *schema.Resource {
  return &amp;schema.Resource{
    CreateContext: resourceOrderCreate,
    ReadContext:   resourceOrderRead,
    UpdateContext: resourceOrderUpdate,
    DeleteContext: resourceOrderDelete,
    Schema: map[string]*schema.Schema{},
  }
}

func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  // Warning or errors can be collected in a slice type
  var diags diag.Diagnostics

  return diags
}

func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  // Warning or errors can be collected in a slice type
  var diags diag.Diagnostics

  return diags
}

func resourceOrderUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  return resourceOrderRead(ctx, d, m)
}

func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  // Warning or errors can be collected in a slice type
  var diags diag.Diagnostics

  return diags
}</pre>
<p>我们需要将其注册到Provider：</p>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		// ...
		ResourcesMap: map[string]*schema.Resource{
			"hashicups_order": resourceOrder(),
		},
		// ...
	}
}</pre>
<div class="blog_h3"><span class="graybg">订单Schema </span></div>
<p>不同于上问的订单数据源，这里我们设计了和API结构更加匹配的Schema：</p>
<pre class="crayon-plain-tag">Schema: map[string]*schema.Schema{
			"items": {
				Type:     schema.TypeList,
				Required: true,
				Elem: &amp;schema.Resource{
					Schema: map[string]*schema.Schema{
						"coffee": {
							Type:     schema.TypeList, // coffee明明是一个，还非得声明为列表
							MaxItems: 1,
							Required: true,
							Elem: &amp;schema.Resource{
								Schema: map[string]*schema.Schema{
									"id": {
										Type:     schema.TypeInt,
										Required: true,
									},
									"name": {
										Type:     schema.TypeString,
										Computed: true,
									},
									"teaser": {
										Type:     schema.TypeString,
										Computed: true,
									},
									"description": {
										Type:     schema.TypeString,
										Computed: true,
									},
									"price": {
										Type:     schema.TypeInt,
										Computed: true,
									},
									"image": {
										Type:     schema.TypeString,
										Computed: true,
									},
								},
							},
						},
						"quantity": {
							Type:     schema.TypeInt,
							Required: true,
						},
					},
				},
			},
		}</pre>
<p>注意和订单数据源Schema的其它几个重要区别：</p>
<ol>
<li>在资源Schema中，顶级的id属性不存在。这是因为资源中你<span style="background-color: #c0c0c0;">无法提前知道id，也不能将id作为输入参数。id是在资源创建过程中自动生成</span>的</li>
<li>在资源Schema中，items是<span style="background-color: #c0c0c0;">必须字段，而不是计算出的字段。这是因为我们在配置中声明订单资源时必须提供订单项信息</span></li>
</ol>
<div class="blog_h3"><span class="graybg">创建操作 </span></div>
<pre class="crayon-plain-tag">func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	// 元参数的真实类型是HashCups客户端
	c := m.(*hc.Client)
	var diags diag.Diagnostics

	// 从ResourceData中获得订单条目（的参数）
	items := d.Get("items").([]interface{})
	ois := []hc.OrderItem{}

	// 遍历订单条目，构造为HashCups客户端所需的OrderItem
	for _, item := range items {
		i := item.(map[string]interface{})

		co := i["coffee"].([]interface{})[0] // 只取第一个coffee，为何非要定义为列表
		coffee := co.(map[string]interface{})

		oi := hc.OrderItem{
			Coffee: hc.Coffee{
				ID: coffee["id"].(int),
			},
			Quantity: i["quantity"].(int),
		}

		ois = append(ois, oi)
	}

	// 调用客户端创建订单
	o, err := c.CreateOrder(ois)
	if err != nil {
		return diag.FromErr(err)
	}

	// 将生成的标识符设置为资源ID
	d.SetId(strconv.Itoa(o.ID))

	return diags
}</pre>
<div class="blog_h3"><span class="graybg">创建后填充状态</span></div>
<p>实现了创建操作后，必须同时实现读操作，并<span style="background-color: #c0c0c0;">在创建操作中调用读操作，这样才能在创建资源后，立即以最新资源填充state</span>：</p>
<pre class="crayon-plain-tag">func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	// ...
	d.SetId(strconv.Itoa(o.ID))

	resourceOrderRead(ctx, d, m)

	return diags
}</pre>
<div class="blog_h3"><span class="graybg">读操作 </span></div>
<pre class="crayon-plain-tag">func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  c := m.(*hc.Client)
  var diags diag.Diagnostics
  // 读操作时，资源的ID是已知的（从state中获取）
  orderID := d.Id()
  // 根据ID查询订单
  order, err := c.GetOrder(orderID)
  if err != nil {
    return diag.FromErr(err)
  }
  // 结构转换
  orderItems := flattenOrderItems(&amp;order.Items)
  if err := d.Set("items", orderItems); err != nil {
    return diag.FromErr(err)
  }

  return diags
}

func flattenOrderItems(orderItems *[]hc.OrderItem) []interface{} {
  if orderItems != nil {
    ois := make([]interface{}, len(*orderItems), len(*orderItems))

    for i, orderItem := range *orderItems {
      oi := make(map[string]interface{})

      oi["coffee"] = flattenCoffee(orderItem.Coffee)
      oi["quantity"] = orderItem.Quantity
      ois[i] = oi
    }

    return ois
  }

  return make([]interface{}, 0)
}

func flattenCoffee(coffee hc.Coffee) []interface{} {
  c := make(map[string]interface{})
  c["id"] = coffee.ID
  c["name"] = coffee.Name
  c["teaser"] = coffee.Teaser
  c["description"] = coffee.Description
  c["price"] = coffee.Price
  c["image"] = coffee.Image

  return []interface{}{c}
}</pre>
<div class="blog_h3"><span class="graybg">编写Terraform配置</span></div>
<p>下面的配置，创建了一个订单，并且输出其内容</p>
<pre class="crayon-plain-tag">resource "hashicups_order" "edu" {
  // 订单的Schema中，items是List，要为其声明多个元素，多次添加items块
  items {
    // 订单的Schema中，coffee也是一个List
    coffee {
      id = 3
    }
    quantity = 2
  }
  items {
    coffee {
      id = 2
    }
    quantity = 2
  }
}

output "edu_order" {
  value = hashicups_order.edu
}</pre>
<div class="blog_h2"><span class="graybg">实现更新操作</span></div>
<div class="blog_h3"><span class="graybg">更新订单Schema </span></div>
<pre class="crayon-plain-tag">func resourceOrder() *schema.Resource {
	return &amp;schema.Resource{
		// ...
		UpdateContext: resourceOrderUpdate,
		Schema: map[string]*schema.Schema{
			"last_updated": &amp;schema.Schema{
				Type:     schema.TypeString,
				Optional: true,  // 可选字段，允许配置时不提供
				Computed: true,  // 在创建时自动计算出（由Provider给出）
			},
			"items": &amp;schema.Schema{ 
			/...</pre>
<div class="blog_h3"><span class="graybg">更新操作</span></div>
<pre class="crayon-plain-tag">// 判断指定的键，对应的值是否改变了。通过对比比较配置文件前后的差异达成
	if d.HasChange("items") {
		items := d.Get("items").([]interface{})
		ois := []hc.OrderItem{}

		for _, item := range items {
			i := item.(map[string]interface{})

			co := i["coffee"].([]interface{})[0]
			coffee := co.(map[string]interface{})

			oi := hc.OrderItem{
				Coffee: hc.Coffee{
					ID: coffee["id"].(int),
				},
				Quantity: i["quantity"].(int),
			}
			ois = append(ois, oi)
		}
		// 调用HashCups API更新灯胆
		_, err := c.UpdateOrder(orderID, ois)
		if err != nil {
			return diag.FromErr(err)
		}

		// 更新 last_updated字段
		d.Set("last_updated", string(time.Now().Format(time.RFC850)))
	}
	
	// 总是重新读取订单最新状态
	return resourceOrderRead(ctx, d, m)</pre>
<div class="blog_h2"><span class="graybg">实现删除操作 </span></div>
<div class="blog_h3"><span class="graybg">删除操作 </span></div>
<pre class="crayon-plain-tag">func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	c := m.(*hc.Client)
	var diags diag.Diagnostics

	orderID := d.Id()
	// 调用HashiCups API执行删除
	err := c.DeleteOrder(orderID)
	if err != nil {
		return diag.FromErr(err)
	}

	// 注意：d.SetId("") 会在本方法无错误返回的情况下，自动调用
	// 通常不需要手工调用
	d.SetId("")

	return diags
}</pre>
<p>Terraform处理该回调的返回值的逻辑：</p>
<ol>
<li>如果没有返回错误，则Terraform认为资源被删除，其所有状态信息被从state中移除</li>
<li>如果返回了错误，则Terraform认为资源仍然存在，其所有状态信息会被保留</li>
</ol>
<p>该回调：</p>
<ol>
<li><span style="background-color: #c0c0c0;">永远不应该更新资源的任何状态</span></li>
<li>总是应当处理<span style="background-color: #c0c0c0;">资源已经被删除的场景</span>，这种情况下不应该返回错误</li>
<li>如果云API不支持删除操作，则该回调应该检查资源是否存在，如果不存在则设置ID为""，确保资源从state中删除</li>
</ol>
<div class="blog_h2"><span class="graybg">实现导入操作</span></div>
<p>导入操作能够通过API拉取已经存在的订单，并且同步到Terraform state中，不进行创建订单的操作。 <span style="background-color: #c0c0c0;">导入的资源</span>和通过Terraform创建的资源已有，<span style="background-color: #c0c0c0;">被Terraform管理</span>。</p>
<div class="blog_h3"><span class="graybg">导入操作</span></div>
<pre class="crayon-plain-tag">func resourceOrder() *schema.Resource {
	return &amp;schema.Resource{
		// ...
		Importer: &amp;schema.ResourceImporter{
			StateContext: schema.ImportStatePassthroughContext,
		},
	}
}</pre>
<p>StateContext被设置为Terraform库提供的函数schema.ImportStatePassthroughContext，该函数签名为：</p>
<pre class="crayon-plain-tag">// StateContextFunc用于导入资源到Terraform state。入参是仅仅设置了ID的资源，这个ID
// 由用户提供，因此需要校验
// 返回值是ResourceData，代表需要存入state的资源状态。最简单的情况下，仅仅包含原封不动的入参
type StateContextFunc func(context.Context, *ResourceData, interface{}) ([]*ResourceData, error)</pre>
<p>函数的实现很简单，就是直接把入参返回：</p>
<pre class="crayon-plain-tag">func ImportStatePassthroughContext(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) {
	return []*ResourceData{d}, nil
}</pre>
<div class="blog_h3"><span class="graybg">导入命令 </span></div>
<pre class="crayon-plain-tag">terraform import hashicups_order.sample &lt;order_id&gt;</pre>
<p>上述命令将名为sample的hasicups_order和指定的订单ID关联起来。Terraform会调用Importer，并将ID传递给resourceOrderRead来读取完整的状态。</p>
<div class="blog_h3"><span class="graybg">关于ID</span></div>
<p>从导入操作我们可以看到，资源的唯一性ID很重要。某些情况下，我们可能需要从云API的多个字段去构造ID，例如<pre class="crayon-plain-tag">&lt;region&gt;:&lt;resource_id&gt;</pre></p>
<div class="blog_h2"><span class="graybg">发布Provider</span></div>
<p>Terraform Registry是Provider和Module的公共仓库。如果你开发了有复用价值的Provider，可以<a href="https://learn.hashicorp.com/tutorials/terraform/provider-release-publish?in=terraform/providers">考虑上传到其中</a>。</p>
<div class="blog_h2"><span class="graybg">深入Schema </span></div>
<p>几乎所有Provider为用户提供配置参数，以实现云API的访问凭证、Region信息等的可定制化。下面的资源，允许你提供uuid、name两个参数：</p>
<pre class="crayon-plain-tag">func resourceExampleResource() *schema.Resource {
    return &amp;schema.Resource{
        // 每个资源，从根上来说，是一个{键值}结构
        //                 需要为每个值指定Schema
        Schema: map[string]*schema.Schema{
            "uuid": {
                // 指定值的类型
                Type:     schema.TypeString,
                // 这个字段会在资源创建时，自动生成
                Computed: true,
            },

            "name": {
                Type:         schema.TypeString,
                // 这是必须字段，用户必须提供
                Required:     true,
                // 该字段改变后，资源会被删除、重新创建
                ForceNew:     true,
                // 该字段的校验逻辑
                ValidateFunc: validatName,
            },
        },
    }
}</pre>
<div class="blog_h3"><span class="graybg">类型</span></div>
<p>类型可以分为两大类：基本类型、聚合类型。</p>
<p>基本类型包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 90px; text-align: center;">类型</td>
<td style="width: 55%; text-align: center;">Schema示例<br />配置示例</td>
<td style="text-align: center;">状态表示</td>
</tr>
</thead>
<tbody>
<tr>
<td><span style="color: #000000;">TypeBool<br />(bool)</span></td>
<td>
<pre class="crayon-plain-tag">"encrypted": {
  Type:     schema.TypeBool,
}, </pre><br />
<pre class="crayon-plain-tag">resource "example_volume" "ex" {
  encrypted = true
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"encrypted": "true",</pre></p>
<p>&nbsp;</p>
</td>
</tr>
<tr>
<td>TypeInt<br />(int)</td>
<td>
<pre class="crayon-plain-tag">"cores": {
  Type:     schema.TypeInt,
},</pre><br />
<pre class="crayon-plain-tag">resource "example_compute_instance" "ex" {
  cores = 16
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"cores": "16",</pre>
</td>
</tr>
<tr>
<td>TypeFloat<br />(float64)</td>
<td>
<pre class="crayon-plain-tag">"price": {
  Type:     schema.TypeFloat,
},</pre><br />
<pre class="crayon-plain-tag">resource "example_spot_request" "ex" {
  price = 0.37
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"price": "0.37",</pre>
</td>
</tr>
<tr>
<td rowspan="2">
<p>TypeString</p>
<p>(string)</p>
</td>
<td>
<pre class="crayon-plain-tag">"name": {
  Type:     schema.TypeString,
},</pre><br />
<pre class="crayon-plain-tag">resource "example_spot_request" "ex" {
  description = "Managed by Terraform"
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"description": "Managed by Terraform",</pre>
</td>
</tr>
<tr>
<td>
<p>日期时间，也使用TypeString，配合校验函数：</p>
<pre class="crayon-plain-tag">"expiration": {
  Type:         schema.TypeString,
  ValidateFunc: validation.ValidateRFC3339TimeString,
},

resource "example_resource" "ex" {
  expiration = "2006-01-02T15:04:05+07:00"
} </pre>
</td>
<td>
<pre class="crayon-plain-tag">"expiration": "2006-01-02T15:04:05+07:00",</pre>
</td>
</tr>
</tbody>
</table>
<p>聚合类型包括：
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 90px; text-align: center;">类型</td>
<td style="width: 55%; text-align: center;">Schema示例<br />配置示例</td>
<td style="text-align: center;">状态表示</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<p>TypeMap</p>
</td>
<td>
<p>(map[string]interface{})</p>
<pre class="crayon-plain-tag">"tags": {
  Type:     schema.TypeMap,
  Elem: &amp;schema.Schema{
    Type: schema.TypeString,
  },
},</pre><br />
<pre class="crayon-plain-tag">resource "example_compute_instance" "ex" {
  tags {
    env = "development"
    name = "example tag"
  }
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">#     元素数量
"tags.%": "2",
"tags.env": "development",
"tags.name": "example tag",</pre>
</td>
</tr>
<tr>
<td>TypeList</td>
<td>
<p>([]interface{})
<pre class="crayon-plain-tag">"termination_policies": {
  Type:     schema.TypeList,
  Elem: &amp;schema.Schema{
    Type: schema.TypeString,
  },
},</pre><br />
<pre class="crayon-plain-tag">resource "example_compute_instance" "ex" {
  termination_policies = ["OldestInstance",
    "ClosestToNextInstanceHour"]
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">#             元素数量
"name_servers.#": "4",
"name_servers.0": "ns-1508.awsdns-60.org",
"name_servers.1": "ns-1956.awsdns-52.co.uk",</pre>
<p>&nbsp;</p>
</td>
</tr>
<tr>
<td>TypeSet</td>
<td>
<p>(*schema.Set)</p>
<pre class="crayon-plain-tag">"ingress": {
  Type:     schema.TypeSet,
  Elem: &amp;schema.Resource{
    Schema: map[string]*schema.Schema{
      "from_port": {
        Type:     schema.TypeInt,
        Required: true,
      },

      "to_port": {
        Type:     schema.TypeInt,
        Required: true,
      },
  },
}</pre><br />
<pre class="crayon-plain-tag">resource "example_security_group" "ex" {
  name        = "sg_test"              
  description = "managed by Terraform" 

  ingress {                            
    protocol    = "tcp"                
    from_port   = 80                   
    to_port     = 9000                 
    cidr_blocks = ["10.0.0.0/8"]       
  }                                    

  ingress {                            
    protocol    = "tcp"                
    from_port   = 80                   
    to_port     = 8000                 
    cidr_blocks = ["0.0.0.0/0", "10.0.0.0/8"]
  }                                    
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"ingress.#": "2",
"ingress.1061987227.cidr_blocks.#": "1",
"ingress.1061987227.cidr_blocks.0": "10.0.0.0/8",
"ingress.1061987227.description": "",
"ingress.1061987227.from_port": "80",
"ingress.1061987227.ipv6_cidr_blocks.#": "0",
"ingress.1061987227.protocol": "tcp",
"ingress.1061987227.security_groups.#": "0",
"ingress.1061987227.self": "false",</pre>
</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">行为</span></div>
<p>Schema的一些字段的设置，会对Terraform的plan/apply行为产生影响。
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">字段</td>
<td style="text-align: center;">影响</td>
</tr>
</thead>
<tbody>
<tr>
<td>Optional</td>
<td>是否在配置中是可选的</td>
</tr>
<tr>
<td>Required</td>
<td>是否必须在配置中提供</td>
</tr>
<tr>
<td>Computed</td>
<td>提示字段不能由用户提供，并且在terraform apply之前，其值是未知的</td>
</tr>
<tr>
<td>ForceNew</td>
<td>对资源的该字段的修改，会导致删除并重新创建资源</td>
</tr>
<tr>
<td>Default</td>
<td>如果用户没有配置，使用的默认值</td>
</tr>
<tr>
<td>DiffSuppressFunc</td>
<td>
<p>用于计算该字段的（前后值的）差异，下面的例子，不区分大小写：</p>
<pre class="crayon-plain-tag">"base_image": {
  Type:     schema.TypeString,
  DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
    if strings.ToLower(old) == strings.ToLower(new) {
      return true 
    }
    return false
  },
}, </pre>
</td>
</tr>
<tr>
<td>DefaultFunc</td>
<td>用于动态提供默认值</td>
</tr>
<tr>
<td>StateFunc</td>
<td>将字段转换为一个字符串，该字符串存储在state中</td>
</tr>
<tr>
<td>ValidateFunc</td>
<td>校验该字段</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">深入资源</span></div>
<div class="blog_h3"><span class="graybg">CustomizeDiff</span></div>
<p>Terrafrom通过比较用户提供的配置、资源的state，来确定是否需要更新。
<p>可以为schema.Resource传递CustomizeDiff，该回调和Terraform生成的Diff（表示资源的变更）一起工作，它可以：</p>
<ol>
<li>修改Diff</li>
<li>否决Diff，终止计划</li>
</ol>
<pre class="crayon-plain-tag">type CustomizeDiffFunc func(context.Context, *ResourceDiff, interface{}) error</pre>
<div class="blog_h3"><span class="graybg">超时</span></div>
<p>云上的很多操作比较耗时，例如启动操作系统、跨越网络边缘复制状态。开发Provider时，应该注意考虑云API的延迟，Terraform支持为资源的各种操作设置超时：</p>
<pre class="crayon-plain-tag">func resourceExampleInstance() *schema.Resource {
    return &amp;schema.Resource{
        // ...
        Timeouts: &amp;schema.ResourceTimeout{
            Create: schema.DefaultTimeout(45 * time.Minute),
        },
    }
}</pre>
<div class="blog_h3"><span class="graybg">重试</span></div>
<p>Terraform提供了一个重试助手函数：</p>
<pre class="crayon-plain-tag">type RetryFunc func() *RetryError

func RetryContext(ctx context.Context, timeout time.Duration, f RetryFunc) error</pre>
<p>RetryContext能够反复重试RetryFunc：</p>
<ol>
<li>timeout指定Terraform调用RetryFunc的最大用时。可以传递<pre class="crayon-plain-tag">schema.TimeoutCreate</pre>给<pre class="crayon-plain-tag">*schema.ResourceData.Timeout()</pre>获取用户配置的超时值</li>
<li>RetryFunc可以返回：
<ol>
<li>resource.NonRetryableError，这样直接导致重试终止</li>
<li>resource.RetryableError，这样会重试</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">可接受测试</span></div>
<p>基于测试用例来组织，每个用例使用1-N个Terraform配置，创建一些资源，并并验证实际创建的基础设施对象符合预期。</p>
<p>Terraform的<pre class="crayon-plain-tag">resource</pre>包提供了<pre class="crayon-plain-tag">Test()</pre>方法，该方法是Terraform可接受测试框架的入口点，它接受两个参数：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">*testing.T</pre> 来自Go语言的测试框架<br /><pre class="crayon-plain-tag">TestCase</pre>开发者提供的用于设立可接受测试的结构</p>
<p>下面是一个例子，它测试一个名为Example的Provider，被测试的资源是Widget：</p>
<pre class="crayon-plain-tag">package example

var testAccProviders map[string]*schema.Provider
var testAccProvider *schema.Provider

func init() {
	testAccProvider = Provider()
	// 被测试Provider
	testAccProviders = map[string]*schema.Provider{
		"example": testAccProvider,
	}
}

// 方法命名约定TestAccXxx
func TestAccExampleWidget_basic(t *testing.T) {
	var widgetBefore, widgetAfter example.Widget
	rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)

	// 大部分可接受测试，都是仅仅调用Test方法并退出
	// 任何时候（不管是PreCheck还是Steps...）出错，框架都会调用t.Error()方法，导致测试失败并终止
	resource.Test(t, resource.TestCase{
		// 是否单元测试。该参数允许在不考虑TF_ACC环境变量的情况下运行测试。应当仅仅用于本地资源的快速测试
		// 默认情况下，如果没有设置环境变量TF_ACC，测试会立即失败
		IsUnitTest: false,
		// 在任何Steps之前，允许的回调，通常用来检查测试需要的值（例如用于配置Provider的环境变量）存在
		PreCheck: func() { testAccPreCheck(t) },

		// map[string]*schema.Provider类型，表示将被测试的Providers。配置文件中引用的任何Provider都
		// 需要在此配置，否则用例会报错
		Providers:    testAccProviders,
		// 注意：Providers已经废弃，请使用ProviderFactories代替
		ProviderFactories: map[string]func() (*schema.Provider, error),
		// 类似ProviderFactories，但是用于基于terraform-plugin-go ProviderServer接口实现
		// Protocol V5的Provider
		ProtoV5ProviderFactories map[string]func() (tfprotov5.ProviderServer, error)
		// 类似ProviderFactories，但是用于基于terraform-plugin-go ProviderServer接口实现
		// Protocol V6的Provider
		ProtoV6ProviderFactories map[string]func() (tfprotov6.ProviderServer, error)

		// 所有Steps运行之后，并且Terraform已经针对state运行了destroy命令之后执行的回调
		// 该方法以最后一次已知的staet作为入参，通常直接使用基础设施的SDK来确认目标对象已都不存在
		// 如果应该被删除的对象仍然存在，此方法应该报错
		CheckDestroy: testAccCheckExampleResourceDestroy,
		// 允许Provider有选择性的处理错误，例如基于特定错误，跳过某些测试
		ErrorCheck ErrorCheckFunc,
		// 一个TestStep，通常对应单次apply。基础的测试包含1-2个Step，验证资源可以被创建，然后更新
		Steps: []resource.TestStep{
			{
				Config: testAccExampleResource(rName),
				Check: resource.ComposeTestCheckFunc(
					testAccCheckExampleResourceExists("example_widget.foo", &amp;widgetBefore),
				),
			},
			{
				Config: testAccExampleResource_removedPolicy(rName),
				Check: resource.ComposeTestCheckFunc(
					testAccCheckExampleResourceExists("example_widget.foo", &amp;widgetAfter),
				),
			},
		},
	})
}</pre>
<div class="blog_h3"><span class="graybg">TestStep</span></div>
<p>每个TestStep需要Terraform Configuration作为输入，提供多种验证被测试资源行为的方法。</p>
<p>Terraform的测试框架支持两个独立的测试模式：</p>
<ol>
<li>Lifecycle模式，常用，提供1-N个配置文件并测试Provider在terraform apply时的行为</li>
<li>Import模式，测试Provider在terraform import时的行为</li>
</ol>
<p>测试模式被传递给TestStep的字段隐式的确定。</p>
<p>每个TestStep包含一个需要被Apply的配置（由Config字段给出）、0-N个校验（在Check字段中编排），多个TestStep按顺序，依次执行。</p>
<p>当需要执行多个校验时，需要使用下面的函数之一来编排：<pre class="crayon-plain-tag">ComposeTestCheckFunc</pre>、<pre class="crayon-plain-tag">ComposeAggregateTestCheckFunc</pre>。示例：</p>
<pre class="crayon-plain-tag">Steps: []resource.TestStep{
  {
    Check: resource.ComposeTestCheckFunc(
      // 检查资源是否存在于基础设施
      testAccCheckExampleResourceExists("example_widget.foo", &amp;widgetBefore), 
      // 校验资源属性
      resource.TestCheckResourceAttr("example_widget.foo", "size", "expected size"),
    ),
  },
},</pre>
<p><span style="background-color: #c0c0c0;">ComposeAggregateTestCheckFunc</span>的区别是，尽管也是顺序执行校验，但是<span style="background-color: #c0c0c0;">某个校验失败，并不会立即停止</span>，还会继续执行其它校验，收集所有错误。 </p>
<div class="blog_h2"><span class="graybg">调试</span></div>
<div class="blog_h3"><span class="graybg">基于日志</span></div>
<p>Terratform将所有stderr的输出都通过gRPC协议传输到CLI，并打印到控制台。编写插件时，<span style="background-color: #c0c0c0;">绝不要将日志打印到stdout，因为stdout作为Terraform内部到CLI的通信通道</span>。</p>
<p>建议使用Go内置的<pre class="crayon-plain-tag">log.Println</pre>或<pre class="crayon-plain-tag">log.Printf</pre>进行日志输出。日志的每行必须以<span style="background-color: #c0c0c0;">[日志级别]</span>开始，支持的级别包括<span style="background-color: #c0c0c0;">ERROR WARN INFO DEBUG TRACE</span>。示例：</p>
<pre class="crayon-plain-tag">log.Println("[DEBUG] Something happened!")</pre>
<p>Terraform使用环境变量<pre class="crayon-plain-tag">TF_LOG</pre>来控制Provider、CLI的日志输出级别：</p>
<pre class="crayon-plain-tag">export TF_LOG=DEBUG</pre>
<p>可以使用环境变量<pre class="crayon-plain-tag">TF_LOG_CORE</pre>、<pre class="crayon-plain-tag">TF_LOG_PROVIDER</pre>为Terraform核心、Provider设置不同的日志级别。</p>
<p>如果需要输出到文件，使用环境变量<pre class="crayon-plain-tag">TF_LOG_PATH</pre>，默认输出到CLI的stderr。</p>
<div class="blog_h3"><span class="graybg">单步跟踪</span></div>
<p>使用下面的代码，在Provider中启用单步跟踪支持：</p>
<pre class="crayon-plain-tag">func main() {
    var debugMode bool

    flag.BoolVar(&amp;debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve")
    flag.Parse()

    opts := &amp;plugin.ServeOpts{ProviderFunc: provider.New}

    if debugMode {
        //            调试模式
        err := plugin.Debug(context.Background(), "registry.terraform.io/my-org/my-provider", opts)
        if err != nil {
            log.Fatal(err.Error())
        }
        return
    }
    //     正常模式
    plugin.Serve(opts)
}</pre>
<p>关于调试模式，需要注意：</p>
<ol>
<li>在此模式下，Terraform不会启动Provider进程，你需要手工启动它：<br />
<pre class="crayon-plain-tag">dlv exec --headless ./terraform-provider-my-provider -- --debug</pre>
</li>
<li>你需要将IDE连接到到dlv进程，这时Provider会打印日志：<br />
<pre class="crayon-plain-tag">Provider started, to attach Terraform set the TF_REATTACH_PROVIDERS env var:

        TF_REATTACH_PROVIDERS='{"registry.terraform.io/my-org/my-provider":{"Protocol":"grpc","Pid":3382870,"Test":true,"Addr":{"Network":"unix","String":"/tmp/plugin713096927"}}}' </pre>
</li>
<li>你需要export上述日志输出的环境变量，然后执行Terraform CLI</li>
<li>在terraform init时不会对Provider进行约束检查</li>
<li>当遍历了Terraform资源依赖图后，Provider进程不会被重启 </li>
</ol>
<div class="blog_h3"><span class="graybg">可接受测试中的单步跟踪</span></div>
<p>在运行可接受测试的时候，直接在当前测试进程中运行Provider，因此可以进行单步跟踪而不需要特别设置。</p>
<div class="blog_h1"><span class="graybg">基于TPF开发Provider</span></div>
<p>Terraform Plugin Framework是新的（但还不稳定）Provider开发方式，关于它的优缺点，上文已经介绍过。本章主要以例子说明如何使用该框架。</p>
<p>要使用TPF，在go.mod增加依赖：</p>
<pre class="crayon-plain-tag">github.com/hashicorp/terraform-plugin-framework v0.4.2 </pre>
<div class="blog_h2"><span class="graybg">入口点</span></div>
<pre class="crayon-plain-tag">package main

import (
    "context"
    "github.com/hashicorp/terraform-plugin-framework/tfsdk"
    "terraform-provider-hashicups/hashicups"
)

func main() {
    tfsdk.Serve(context.Background(), hashicups.New, tfsdk.ServeOpts{
        Name: "hashicups",
    })
}</pre>
<p>tfsdk.Serve函数的第二个参数，就是任何Provider需要实现的接口：</p>
<pre class="crayon-plain-tag">type Provider interface {
	// 返回配置Provider的Schema，如果Provider不需要配置返回空的Schema
	GetSchema(context.Context) (Schema, diag.Diagnostics)

	// 在Provider生命周期的最初期此方法被调用，Terraform会此方法发送用户在provider块中
	// 提供的参数，并且存放在ConfigureProviderRequest中。注意，Terraform不保证此方法调用时，
	// 所有参数都是Known的。如果Provider在某些参数在Unknown的时候仍然可被配置，建议发出警告
	// 否则发出错误
	Configure(context.Context, ConfigureProviderRequest, *ConfigureProviderResponse)

	// 返回此Provider支持的资源类型列表
	// 资源列表的键，是资源的名字，并且应当以Provider名为前缀
	GetResources(context.Context) (map[string]ResourceType, diag.Diagnostics)

	// 返回此Provider支持的数据源类型列表
	// 资源列表的键，是资源的名字，并且应当以Provider名为前缀
	GetDataSources(context.Context) (map[string]DataSourceType, diag.Diagnostics)
}</pre>
<div class="blog_h2"><span class="graybg">配置Provider</span></div>
<pre class="crayon-plain-tag">package hashicups

import (
    "context"
    "os"

    "github.com/hashicorp-demoapp/hashicups-client-go"
    "github.com/hashicorp/terraform-plugin-framework/diag"
    "github.com/hashicorp/terraform-plugin-framework/tfsdk"
    "github.com/hashicorp/terraform-plugin-framework/types"
)

var stderr = os.Stderr

func New() tfsdk.Provider {
    return &amp;provider{}
}

type provider struct {
    configured bool
    client     *hashicups.Client
}</pre>
<div class="blog_h3"><span class="graybg">Schema</span></div>
<p>GetSchema方法，获取Provider的配置参数的Schema： </p>
<pre class="crayon-plain-tag">// GetSchema
func (p *provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) {
    return tfsdk.Schema{
        Attributes: map[string]tfsdk.Attribute{
            "host": {
                Type:     types.StringType,
                Optional: true,
                Computed: true,
            },
            "username": {
                Type:     types.StringType,
                Optional: true,
                Computed: true,
            },
            "password": {
                Type:      types.StringType,
                Optional:  true,
                Computed:  true,
                Sensitive: true,
            },
        },
    }, nil
}

// Provider schema struct
type providerData struct {
    Username types.String `tfsdk:"username"`
    Host     types.String `tfsdk:"host"`
    Password types.String `tfsdk:"password"`
}</pre>
<div class="blog_h3"><span class="graybg">Configure </span></div>
<pre class="crayon-plain-tag">// req 代表Terraform在配置Provider时，发送过来的请求
// resp 代表响应
func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, 
                                                  resp *tfsdk.ConfigureProviderResponse) {

	var config providerData
	// 从用户给出的Provider配置中取出数据，存放到providerData中
	diags := req.Config.Get(ctx, &amp;config)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	// 校验username password host是否提供
	var username string
	// 值不能是未知的
	if config.Username.Unknown {
		// Cannot connect to client with an unknown value
		resp.Diagnostics.AddWarning(
			"Unable to create client",
			"Cannot use unknown value as username",
		)
		return
	}

	// 如果值没有设置（或者被明确的设置为null），则尝试从环境变量读取
	if config.Username.Null {
		username = os.Getenv("HASHICUPS_USERNAME")
	} else {
		username = config.Username.Value
	}

	// 如果配置是空字符串，并且也没有配置环境变量，则报错
	if username == "" {
		// Error vs warning - empty value must stop execution
		resp.Diagnostics.AddError(
			"Unable to find username",
			"Username cannot be an empty string",
		)
		return
	}
	// password, host类似...

	c, err := hashicups.NewClient(&amp;host, &amp;username, &amp;password)
	if err != nil {
		resp.Diagnostics.AddError(
			"Unable to create client",
			"Unable to create hashicups client:\n\n"+err.Error(),
		)
		return
	}

	p.client = c
	p.configured = true
}</pre>
<div class="blog_h2"><span class="graybg">定义资源</span></div>
<div class="blog_h3"><span class="graybg">资源模型</span></div>
<pre class="crayon-plain-tag">package hashicups

import (
    "github.com/hashicorp/terraform-plugin-framework/types"
)

// Order -
type Order struct {
    ID          types.String `tfsdk:"id"`
    Items       []OrderItem  `tfsdk:"items"`
    LastUpdated types.String `tfsdk:"last_updated"`
}

// OrderItem -
type OrderItem struct {
    Coffee   Coffee `tfsdk:"coffee"`
    Quantity int    `tfsdk:"quantity"`
}

// Coffee -
// This Coffee struct is for Order.Items[].Coffee which does not have an
// ingredients field in the schema defined in the provider code. Since the
// resource schema must match the struct exactly (any extra field will return an
// error), this struct has Ingredients commented out.
type Coffee struct {
    ID          int          `tfsdk:"id"`
    Name        types.String `tfsdk:"name"`
    Teaser      types.String `tfsdk:"teaser"`
    Description types.String `tfsdk:"description"`
    Price       types.Number `tfsdk:"price"`
    Image       types.String `tfsdk:"image"`
    // Ingredients []Ingredient   `tfsdk:"ingredients"`
}</pre>
<p>注意：资源模型必须和资源的Schema（如下节）严格匹配，如果模型里面有某字段，而Schema没有，会导致出错。</p>
<p>TPF和SDKv2比起来，一个优势是<span style="background-color: #c0c0c0;">实现了模型字段的自动绑定</span>，不再需要一个个字段手工设置。但是这个模型不一定能和云API返回的资源结构自动的相互转换。</p>
<div class="blog_h3"><span class="graybg">资源类型</span></div>
<p>首先需要声明Provider支持哪些资源，即提供资源名称（对应resource块第2标签）到资源类型<pre class="crayon-plain-tag">tfsdk.ResourceType</pre>的映射：</p>
<pre class="crayon-plain-tag">func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) {
    return map[string]tfsdk.ResourceType{
        "hashicups_order": resourceOrderType{},
    }, nil
}</pre>
<p>资源类型需要实现下面的接口：</p>
<pre class="crayon-plain-tag">type ResourceType interface {
	// 返回资源的Schema
	GetSchema(context.Context) (Schema, diag.Diagnostics)

	// 创建该资源类型的Resource对象
	NewResource(context.Context, Provider) (Resource, diag.Diagnostics)
} </pre>
<div class="blog_h3"><span class="graybg">资源Schema</span></div>
<pre class="crayon-plain-tag">type resourceOrderType struct{}

func (r resourceOrderType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) {
    return tfsdk.Schema{
        Attributes: map[string]tfsdk.Attribute{
            "id": {
                Type: types.StringType,
                Computed: true,
            },
            "last_updated": {
                Type:     types.StringType,
                Computed: true,
            },
            "items": {
                Required: true,
                Attributes: tfsdk.ListNestedAttributes(map[string]tfsdk.Attribute{
                    "quantity": {
                        Type:     types.NumberType,
                        Required: true,
                    },
                    "coffee": {
                        Required: true,
                        Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{
                            "id": {
                                Type:     types.NumberType,
                                Required: true,
                            },
                            "name": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                            "teaser": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                            "description": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                            "price": {
                                Type:     types.NumberType,
                                Computed: true,
                            },
                            "image": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                        }),
                    },
                }, tfsdk.ListNestedAttributesOptions{}),
            },
        },
    }, nil
}</pre>
<div class="blog_h3"><span class="graybg">资源 </span></div>
<p>资源类型的NewResource方法返回的是Resource类型，它抽象了针对单个资源的CRUD操作：</p>
<pre class="crayon-plain-tag">type Resource interface {
	// 当Provider需要创建一个新资源的时候调用此方法。配置和planned state可以从
	// CreateResourceRequest读取。新的（实际完成创建后的）state则设置到
	// CreateResourceResponse
	Create(context.Context, CreateResourceRequest, *CreateResourceResponse)

	// 当Provider需要读取资源values来更新state时调用此方法。从
	// ReadResourceRequest读取planned state，新的state则设置到
	// ReadResourceResponse.
	Read(context.Context, ReadResourceRequest, *ReadResourceResponse)

	// 当Provider需要更新资源状态时调用此方法。配置和planned state可以从
	// UpdateResourceRequest读取。新的（更新后的）state则设置到
	// UpdateResourceResponse.
	Update(context.Context, UpdateResourceRequest, *UpdateResourceResponse)

	// 当Provider需要删除资源时调用此方法。配置从DeleteResourceRequest读取
	Delete(context.Context, DeleteResourceRequest, *DeleteResourceResponse)

	// 当前Provider需要导入一个资源时调用此方法。
	// 如果不支持导入，建议返回 ResourceImportStateNotImplemented()
	//
	// If setting an attribute with the import identifier, it is recommended
	// to use the ResourceImportStatePassthroughID() call in this method.
	ImportState(context.Context, ImportResourceStateRequest, *ImportResourceStateResponse)
}</pre>
<div class="blog_h3"><span class="graybg">实现创建接口</span></div>
<p>创建操作的关键步骤：</p>
<ol>
<li>从请求中读取plan数据，读取为模型</li>
<li>将模型转换为云API请求，并发送求</li>
<li>将响应转换并同步到模型，写入state</li>
</ol>
<p>需要注意：plan中的每个已知（known）值，必须和state中对应值的完全一样（逐字节相等），也就是说，<span style="background-color: #c0c0c0;">用户指定的配置不能被改变</span>，否则Terraform抛出错误。<span style="background-color: #c0c0c0;">Provider只能修改计划中unknown的值，而且必须解析所有unknown的值，state中不会有任何unknown的值</span>。</p>
<pre class="crayon-plain-tag">import (
    "context"
    "math/big"
    "strconv"
    "time"


    "github.com/hashicorp-demoapp/hashicups-client-go"
    "github.com/hashicorp/terraform-plugin-framework/diag"
    "github.com/hashicorp/terraform-plugin-framework/tfsdk"
    "github.com/hashicorp/terraform-plugin-framework/types"
)


func (r resourceOrder) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    // 必须确保Provider已经被配置过
    if !r.p.configured {
        resp.Diagnostics.AddError(
            "Provider not configured",
            "The provider hasn't been configured before apply, likely because it depends on an unknown value from another resource. This leads to weird stuff happening, so we'd prefer if you didn't do that. Thanks!",
        )
        return
    }

    // 从执行计划获取订单模型
    var plan Order
    //                注意这里会自动将配置绑定到模型，不需要手工处理
    diags := req.Plan.Get(ctx, &amp;plan)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    // 遍历订单项，将其适配为HashiCups客户端需要的入参
    var items []hashicups.OrderItem
    for _, item := range plan.Items {
        items = append(items, hashicups.OrderItem{
            Coffee: hashicups.Coffee{
                ID: item.Coffee.ID,
            },
            Quantity: item.Quantity,
        })
    }

    // 调用HashiCups客户端创建订单
    order, err := r.p.client.CreateOrder(items)
    if err != nil {
        resp.Diagnostics.AddError(
            "Error creating order",
            "Could not create order, unexpected error: "+err.Error(),
        )
        return
    }

    // 将HasiCups创建的完整订单对象，适配回模型OrderItem
    var ois []OrderItem
    for _, oi := range order.Items {
        ois = append(ois, OrderItem{
            Coffee: Coffee{
                ID:          oi.Coffee.ID,
                Name:        types.String{Value: oi.Coffee.Name},
                Teaser:      types.String{Value: oi.Coffee.Teaser},
                Description: types.String{Value: oi.Coffee.Description},
                Price:       types.Number{Value: big.NewFloat(oi.Coffee.Price)},
                Image:       types.String{Value: oi.Coffee.Image},
            },
            Quantity: oi.Quantity,
        })
    }

    // 完整订单模型
    var result = Order{
        ID:          types.String{Value: strconv.Itoa(order.ID)},
        Items:       ois,
        LastUpdated: types.String{Value: string(time.Now().Format(time.RFC850))},
    }

    // 设置状态
    diags = resp.State.Set(ctx, result)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<div class="blog_h3"><span class="graybg">实现读取接口</span></div>
<p>读取操作更新state，使其反映（通过云API得到的）云基础设施对象的最新的、真实状态。</p>
<p>实现读操作的时候，<span style="background-color: #c0c0c0;">没有plan（用户提供的配置）可用</span>。你需要<span style="background-color: #c0c0c0;">从请求中读取当前的状态，从中取得执行调用云API所需的信息</span>。</p>
<p>在更新时，Provider理论上可以修改state中的任何值，但是主要应当：</p>
<ol>
<li>处理值漂移（drift），也就是外部系统或人员修改了Terraform所拥有（创建并在状态中管理）的资源。<span style="background-color: #c0c0c0;">漂移的值总应该反映到state中</span></li>
<li>处理<span style="background-color: #c0c0c0;">语义上没有改变的值</span>，比如某个值是个JSON，它的字段顺序可能调整了，尽管新旧值并不是逐字节相等的，但是<span style="background-color: #c0c0c0;">并不应当更新state</span></li>
</ol>
<pre class="crayon-plain-tag">func (r resourceOrder) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) {
    // 获取当前状态，状态和计划一样，都是模型
    var state Order
    diags := req.State.Get(ctx, &amp;state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    // 得到状态中的ID，则是访问HashiCups所需参数
    orderID := state.ID.Value

    // 通过HashiCups客户端获取资源最新信息
    order, err := r.p.client.GetOrder(orderID)
    if err != nil {
        resp.Diagnostics.AddError(
            "Error reading order",
            "Could not read orderID "+orderID+": "+err.Error(),
        )
        return
    }

    // 转换为模型
    state.Items = []OrderItem{}
    for _, item := range order.Items {
        state.Items = append(state.Items, OrderItem{
            Coffee: Coffee{
                ID:          item.Coffee.ID,
                Name:        types.String{Value: item.Coffee.Name},
                Teaser:      types.String{Value: item.Coffee.Teaser},
                Description: types.String{Value: item.Coffee.Description},
                Price:       types.Number{Value: big.NewFloat(item.Coffee.Price)},
                Image:       types.String{Value: item.Coffee.Image},
            },
            Quantity: item.Quantity,
        })
    }

    // 设置到状态
    diags = resp.State.Set(ctx, &amp;state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<div class="blog_h3"><span class="graybg">实现更新操作</span></div>
<p>更新操作将用户对资源配置的修改，通过云API同步到基础设施对象，然后更新state。关键步骤：</p>
<ol>
<li>从请求中读取plan数据，并绑定到模型</li>
<li>将模型转换为云API请求</li>
<li>将响应转换并同步到模型，写入state</li>
</ol>
<p>需要注意：known的值在更新前后，必须在state中逐字节对应。state不能包含任何unknown的值。</p>
<div class="blog_h3"><span class="graybg">实现删除操作</span></div>
<p>删除操作读取state信息，发起云API请求删除对应基础设施对象，然后从state中清除资源记录。</p>
<p>清除资源记录时调用<pre class="crayon-plain-tag">State.RemoveResource</pre>方法。</p>
<div class="blog_h3"><span class="graybg">实现导入操作</span></div>
<p>ImportState方法可以创建一个初始的state，将资源纳管起来。此方法的实现必须<span style="background-color: #c0c0c0;">确保后续读操作能够正常刷新状态</span>。 </p>
<p>通常需要从请求中读取<pre class="crayon-plain-tag">req.ID</pre>，并设置到响应中：</p>
<pre class="crayon-plain-tag">resp.State.SetAttribute(ctx, path, req.ID)

// 或者，直接调用：
tfsdk.ResourceImportStatePassthroughID()</pre>
<div class="blog_h2"><span class="graybg">定义数据源</span></div>
<p>数据源的资源模型和资源没有区别，数据源仅仅支持Read方法。该方法无法使用plan或state，它只能从<pre class="crayon-plain-tag">tfsdk.ReadDataSourceRequest</pre>中读取调用云API所需的参数。 </p>
<div class="blog_h2"><span class="graybg">属性/值类型</span></div>
<p>属性就是Provider、资源、数据源的Schema的字段，属性持有最终落地到state的值。每个属性都有对应的类型。当你从config、state或者plan访问属性时，实际上是访问属性的值。</p>
<div class="blog_h3"><span class="graybg">Null和Unknown</span></div>
<p>任何类型的属性，都可以持有这两种值。</p>
<p>Null表示值不存在，通常是由于用户没有给optional属性赋值。required属性永远不会是Null。</p>
<p>Unknown表示属性的值尚不知道。Unknown值和Terraform的依赖管理有关。Terraform会构建资源之间的依赖DAG，当资源A引用了资源B的属性a，并且a此时的值是Unknown，则此时就Terraform就会转而去处理B，通过调用云API取得a的值，然后再处理A。资源创建/读取后，任何字段都不能是Unknown的。</p>
<div class="blog_h3"><span class="graybg">内置类型和值</span></div>
<p>TPF的<pre class="crayon-plain-tag">types</pre>包，提供了一系列内置属性类型。每个属性类型对应两个Go结构，其中一个用做Schema中的属性类型声明（tfsdk.Attribute.Type），另外一个在定义模型时，作为模型的字段类型。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 200px; text-align: center;">Schema属性类型<br />值（模型字段）类型</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">StringType<br />String</td>
<td>
<p>表示UTF-8编码的字符串</p>
</td>
</tr>
<tr>
<td>Int64Type<br />Int64</td>
<td>64bit整数</td>
</tr>
<tr>
<td>Float64Type<br />Float64</td>
<td>64bit浮点数</td>
</tr>
<tr>
<td>NumberType<br />Number</td>
<td>通用数字类型，对应Go语言的<pre class="crayon-plain-tag">*big.Float</pre></td>
</tr>
<tr>
<td>BoolType<br />Bool</td>
<td>布尔类型</td>
</tr>
<tr>
<td>ListType<br />List</td>
<td>
<p>列表类型</p>
<p>types.ListType的<pre class="crayon-plain-tag">ElemType</pre>属性，说明元素的类型</p>
<p>types.List的属性：</p>
<p style="padding-left: 30px;">ElemType，总是和ListType的ElemType一致<br />Elem，是值的列表，每个值的真实类型为ElemType<br />Null，当整个列表的值是null时，为true</p>
<p>types.List的非Null、非Unknown元素，可以直接通过其<pre class="crayon-plain-tag">ElementsAs</pre>方法访问，不需要类型断言</p>
</td>
</tr>
<tr>
<td>SetType<br />Set</td>
<td>类似于列表，但是元素唯一、无序</td>
</tr>
<tr>
<td>MapType<br />Map</td>
<td>
<p>具有string类型键的映射</p>
<p>types.Map的属性：</p>
<p style="padding-left: 30px;">ElemType，总是和MapType的ElemType一致<br />Elem，是一个映射，每个键是字符串，每个值的真实类型为ElemType<br />Null，当整个映射是null时，为true</p>
<p>types.Map的非Null、非Unknown元素，可以直接通过其<pre class="crayon-plain-tag">ElementsAs</pre>方法访问，不需要类型断言</p>
</td>
</tr>
<tr>
<td>ObjectType<br />Object</td>
<td>
<p>所谓对象，是指其它若干其它不限类型的属性的无序集合，每个属性都被赋予名字</p>
<p>你需要通过<pre class="crayon-plain-tag">AttrTypes</pre>为对象的所有属性声明名称和类型</p>
<p>types.Object的属性：</p>
<p style="padding-left: 30px;">AttrTypes，总是和ObjectType.AttrType一致<br />Attrs，从属性名到属性值的映射，每个属性都保证在其中，不管它的值是什么<br />Null，当整个Object是null时，为true</p>
<p>非Null、非Unknown的types.Object的值，可以使用<pre class="crayon-plain-tag">As</pre>方法转换为Go结构，不需要类型断言</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">访问状态/配置/计划</span></div>
<p>很多情况下Provider需要访问用户提供的配置数据、Terraform的状态、生成的执行计划中的数据。这些数据通常存放在请求对象中：</p>
<pre class="crayon-plain-tag">func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse)</pre>
<div class="blog_h3"><span class="graybg">整体读写 </span></div>
<p>最简单的访问配置/计划/状态的方法是，将其转换为一个Go类型（模型）：</p>
<pre class="crayon-plain-tag">type resourceData struct {
    Name types.String `tfsdk:"name"`
    Age types.Number `tfsdk:"age"`
    Registered types.Bool `tfsdk:"registered"`
    Pets types.List `tfsdk:"pets"`
    Tags types.Map `tfsdk:"tags"`
    Address types.Object `tfsdk:"address"`
}

func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    var plan resourceData
    diags := req.Plan.Get(ctx, &amp;plan)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    // 可以通过plan.Name.Value访问计划中的值，你需要检查
    // plan.Name.Null 判断值是否是null的
    // plan.Name.Unknown 判断值是否是unknown的
}</pre>
<p>上面这些模型，字段类型都是<pre class="crayon-plain-tag">attr.Value</pre>的实现。其好处是，能够知晓值是不是Null、Unknown的，但是带来了不必要的复杂性：</p>
<ol>
<li>这些类型都是Terraform私有的，不可能在云API的SDK中使用这些类型。因此将模型转换为SDK类型时就有了额外的负担，难以自动化转换</li>
</ol>
<p>好在<span style="background-color: #c0c0c0;">Get方法能够将值转换为Go类型</span>，因此你可以这样声明模型：</p>
<pre class="crayon-plain-tag">type resourceData struct {
  Name string `tfsdk:"name"`
  Age int64 `tfsdk:"age"`
  Registered bool `tfsdk:"registered"`
  Pets []string `tfsdk:"pets"`
  Tags map[string]string `tfsdk:"tags"`
  Address struct{
    Street string `tfsdk:"street"`
    City string `tfsdk:"city"`
    State string `tfsdk:"state"`
    Zip int64 `tfsdk:"zip"`
  } `tfsdk:"address"`
}</pre>
<p><span style="background-color: #c0c0c0;">警告：Null值/Unknown值可能无法转换，会导致报错</span>。参考下文的转换规则。</p>
<div class="blog_h3"><span class="graybg">读写单个属性 </span></div>
<p>另外一种访问配置/计划/状态的方法是，读取单个属性的值。这种情况下，不需要定义模型（除了ObjectType）：</p>
<pre class="crayon-plain-tag">func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    //                                          属性路径 
    attr, diags := req.Config.GetAttribute(ctx, tftypes.NewAttributePath().WithAttributeName("age"))
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    age := attr.(types.Number)
}</pre>
<div class="blog_h3"><span class="graybg">类型转换规则</span></div>
<p>上面提到过，Terraform能够自动将属性值转换为Go类型，这里列出转换规则：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">类型</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>String</td>
<td>只要值不为null/unknown，就可以转换为string</td>
</tr>
<tr>
<td>Number</td>
<td>
<p>只要值不为null/unknown，就可以转换为多种Go数字类型</p>
<p>当转换会导致溢出、丢失精度时，返回错误</p>
</td>
</tr>
<tr>
<td>Boolean</td>
<td>只要值不为null/unknown，就可以转换为bool</td>
</tr>
<tr>
<td>
<p>List</p>
</td>
<td>只要值不为unknown，就可以转换为[]ElemType切片，如果值是unknown则返回错误</td>
</tr>
<tr>
<td>Map</td>
<td>只要值不为unknown，就可以转换为map[string]ElemType，如果值是unknown则返回错误</td>
</tr>
<tr>
<td>Object</td>
<td>
<p>只要值不为null/unknown，在满足以下约束条件时，可以转换为Go结构：</p>
<ol>
<li>结构的每个字段都定义了<pre class="crayon-plain-tag">tfsdk</pre>标签</li>
<li>tfsdk标签必须和对象的属性名字一致，或者设置为<pre class="crayon-plain-tag">-</pre>表示不映射到任何属性</li>
<li>每个属性都对应到一个Go结构字段</li>
</ol>
</td>
</tr>
<tr>
<td>转换为指针</td>
<td>值为null的时候，不会导致报错，其余和转换为非指针类型一致</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">影响转换的接口</span></div>
<p>Get方法在进行转换时，会自动Go类型实现的特殊接口。</p>
<p>如果Go类型实现了<pre class="crayon-plain-tag">tftypes.ValueConverter</pre>接口，则转换工作代理给此接口进行。</p>
<p>如果Go类型实现了<pre class="crayon-plain-tag">Unknownable</pre>接口，则Terraform认为它能够处理unknown值。类似的，如果实现了<pre class="crayon-plain-tag">Nullable</pre>接口则认为能够处理null值。</p>
<div class="blog_h3"><span class="graybg">写状态</span></div>
<p>配置、计划仅仅支持读操作，但是状态还支持写操作。</p>
<p>写状态的时候，调用响应的State.Set方法：</p>
<pre class="crayon-plain-tag">type resourceData struct {
    Name types.Strings `tfsdk:"name"`
}

func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    var newState resourceData
    newState.Name.Value = "J. Doe"

    // 持久化到状态中
    diags := resp.State.Set(ctx, &amp;newState)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<p>写入单个属性：</p>
<pre class="crayon-plain-tag">func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    age := types.Number{Value: big.NewFloat(7)}
    diags := resp.State.SetAttribute(ctx, tftypes.NewAttributePath().WithAttributeName("age"), &amp;age)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<div class="blog_h2"><span class="graybg">可接受测试 </span></div>
<p>目前TPF框架依赖SDKv2的可接受测试框架，你需要编写和SDKv2可接受测试一样的PreCheck、TestStep...主要区别之处，是如何在TestCase中指定被测试的Provider。</p>
<p>在SDKv2中，通过设置TestCase的Provider属性，来指定map[string]*schema.Provider。在TPF中，则需要指定<pre class="crayon-plain-tag">ProtoV6ProviderFactories</pre>属性。该属性是<pre class="crayon-plain-tag">tfprotov6.ProviderServer</pre>的map。通过下面的方式创建tfprotov6.ProviderServer：</p>
<pre class="crayon-plain-tag">func TestAccTeleportFullVPCMigration(t *testing.T) {
	resource.Test(t, resource.TestCase{
		ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
			// 返回每个需要被测试的Provider
			"teleport": func() (tfprotov6.ProviderServer, error) {
				return tfsdk.NewProtocol6Server(New()), nil
			},
		},
		Steps: []resource.TestStep{
			{
				Config: `
# 不需要使用terraform块声明从何处加载Provider，因为此Provider就在运行在测试进程内
provider "teleport" {
  endpoint = "http://127.0.0.1:6080"
  token = ""
}
resource "teleport_fullvpcmigration" "fvm" {
  region               = "eu-moscow"
  vpc_id               = "vpc-10"
  src_app_id           = "10"
  src_uin              = "10"
  src_sub_account_uin  = "11"
  dest_app_id          = "20"
  dest_uin             = "20"
  dest_sub_account_uin = "21"
  operator             = "alex"
  migrate_timeout      = "15s"
}
`,
				Check:  nil,
			},
		},
	})
}</pre>
<div class="blog_h2"><span class="graybg">调试</span></div>
<div class="blog_h3"><span class="graybg">基于日志</span></div>
<p>参考SDKv2基于日志的调试。</p>
<div class="blog_h3"><span class="graybg">单步跟踪</span></div>
<p>目前TPF不支持向SDKv2那样，以调试模式启动Provider。</p>
<p>尽管如此，在可接受测试中，你可以单步跟踪Provider代码。</p>
<div class="blog_h2"><span class="graybg">dev_overrides</span></div>
<p>在开发阶段，你可以在.terraformrc中配置dev_overrides：</p>
<pre class="crayon-plain-tag">provider_installation {
  dev_overrides {
    "hashicorp.com/edu/hashicups-pf" = "/Users/Alex/.local/bin"
  }
}</pre>
<p>dev_overrides<span style="background-color: #c0c0c0;">必须放在所有安装方法的最前面</span>。 dev_overrides会让Terraform跳过适用版本检查、Checksum匹配检查。但是dev_overrides不能参与正常的Provider安装流程，它没有提供满足版本的元数据、不能产生锁文件。</p>
<p>因此，为了使用dev_overrides，你需要将编译好的插件存放在指定目录。然后<span style="background-color: #c0c0c0;">跳过terraform init，直接运行apply/plan等命令</span>。如果没有配置dev_overrides，跳过init会导致报错，提示Provider的本地缓存不存在或者录制的元数据不匹配。</p>
<div class="blog_h1"><span class="graybg">Terraform设置</span></div>
<p>特殊的块<pre class="crayon-plain-tag">terraform</pre>，可以针对当前配置设置Terraform自身的行为，例如指定应用当前配置所需Terraform的最小版本。</p>
<pre class="crayon-plain-tag">terraform {
  # 配置后端
  backend {
  }

  # 配置Terraform CLI的版本约束
  required_version = ""

  # 模块需要的所有provider的配置
  required_providers {
    aws = {
      version = "&gt;= 2.7.0"
      source = "hashicorp/aws"
    }
  }

  # 启用实验特性
  experiments = [example]
}</pre>
<div class="blog_h2"><span class="graybg">后端配置</span></div>
<p>每个Terraform配置，都可以指定一个后端：<span style="background-color: #c0c0c0;">后端决定了操作在何处执行，状态快照在何处存储</span>：</p>
<ol>
<li>所谓操作（operation）是指调用云API进行资源的CRUD</li>
<li>Terraform利用状态（state）来跟踪它管理的资源。它依赖于state来知晓配置文件中的资源和云上基础设施对象的对应关系，典型情况是将云上对象的ID和配置资源关联起来</li>
</ol>
<p>根据能力的不同，后端分为两类：</p>
<ol>
<li>增强后端，同时支持存储状态、执行操作，只有两个增强后端：<pre class="crayon-plain-tag">local</pre>、<pre class="crayon-plain-tag">remote</pre></li>
<li>标准后端，仅仅存储状态，并且<span style="background-color: #c0c0c0;">依赖于<pre class="crayon-plain-tag">local</pre>后端执行操作</span></li>
</ol>
<p>简单场景下可以使用local后端，不需要任何配置。后端配置仅仅被Terraform CLI使用，Terraform Cloud/Enterprise总是使用他们自己的状态存储，并忽略配置文件中的backend块。</p>
<div class="blog_h3"><span class="graybg">local </span></div>
<p>该后端在本地文件系统存储状态，并且使用系统API锁定状态数据。该后端在本地直接执行操作。不进行任何backend配置，默认使用的就是该后端。</p>
<p>示例配置：</p>
<pre class="crayon-plain-tag">terraform {
  backend "local" {
    # 状态存储路径
    path = "relative/path/to/terraform.tfstate"
    # 如果使用非默认工作区
    workspace_dir = ""
  }
}</pre>
<p>用在terraform_remote_state数据资源中：</p>
<pre class="crayon-plain-tag">data "terraform_remote_state" "foo" {
  backend = "local"
  config = {
    path = "${path.module}/../../terraform.tfstate"
  }
}</pre>
<p>使用此后端时，大部分Terraform CLI的、从后端读写state快照的命令，都支持以下选项：</p>
<p style="padding-left: 30px;">-state=FILENAME 读取先前状态快照时，使用的状态文件<br />-state-out=FILENAME 写入新的状态快照时，使用的状态文件<br />-backup=FILENAME 写入新状态快照时，先前状态的备份文件。取值 - 禁用备份</p>
<div class="blog_h3"><span class="graybg">remote</span></div>
<p>配合Terraform Cloud使用，在云端存储状态和执行操作。</p>
<p>在云端执行操作时，terraform plan / apply等命令在Terraform Cloud的运行环境下执行，日志则打印到本地终端。远程的plan/apply使用关联的Terraform云工作区中的变量。</p>
<div class="blog_h3"><span class="graybg">etcdv3</span></div>
<p>这是个标准后端，只能用于存储状态。示例配置：</p>
<pre class="crayon-plain-tag">terraform {
  backend "etcdv3" {
    # Etcd服务器列表
    endpoints = ["etcd-1:2379", "etcd-2:2379", "etcd-3:2379"]
    # 是否锁定状态访问
    lock      = true
    # 存储前缀
    prefix    = "terraform-state/"
    # 基于口令的身份验证
    username = "$ETCDV3_USERNAME"
    password = "$ETCDV3_PASSWORD"
    # 基于证书的身份验证
    cacert_path = ""
    cert_path = ""
    key_path = ""
    # 最大发送的请求，增大此值可以存放更大的状态，必须配合Etcd服务器配置 --max-request-bytes，默认2MB
  }
}

data "terraform_remote_state" "foo" {
  backend = "etcdv3"
  config = {
    endpoints = ["etcd-1:2379", "etcd-2:2379", "etcd-3:2379"]
    lock      = true
    prefix    = "terraform-state/"
  }
}</pre>
<div class="blog_h1"><span class="graybg">状态 </span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>Terraform需要存储被管理资源的状态，从而实现三个目的：</p>
<ol>
<li><span style="background-color: #c0c0c0;">将真实基础设施对象绑定到配置中定义的资源（最核心的用途）</span></li>
<li>跟踪元数据，例如资源依赖关系。通常情况下，Terraform直接使用配置文件来跟踪资源依赖，但是当你删除配置文件片段后，此依赖关系可能被破坏，这是它会利用状态总存储的最近的依赖关系</li>
<li>提升管理大规模基础设施时的性能。状态中存储了所有资源的属性值，这样可以避免每次访问属性时都需要请求provider</li>
</ol>
<p>在执行任何操作之前，Terraform会执行refresh操作来将状态和基础设施同步。 当配置发生变动后，Terraform可能会：</p>
<ol>
<li>如果配置文件中定义了新的资源：调用Provider创建对应基础设施对象，并且<span style="background-color: #c0c0c0;">将对象的标识符和配置中的资源定义绑定</span>，存储在状态中。Terraform期望基础设施对象和资源定义的实例（使用count/foreach时一个块定义了多个实例）是一一对应关系</li>
<li>如果配置文件中删除了资源定义：调用Provider删除对应基础设施对象，并且清除状态中对应数据</li>
</ol>
<p>如上一章节所述，默认情况下后端local负责存储状态，它默认将状态存储到名为terraform.tfstate的文件中。Terraform还支持大量其它后端，将状态存储到Etcd、S3等各种存储服务中。</p>
<div class="blog_h3"><span class="graybg">查看和修改状态</span></div>
<p>尽管状态就是JSON文本，也不要直接修改它。可以使用<pre class="crayon-plain-tag">terraform state</pre>命令进行基本的修改。</p>
<p>通过<pre class="crayon-plain-tag">terraform import</pre>导入外部创建对象时，或者通过<pre class="crayon-plain-tag">terraform state rm</pre>让Terraform忘记某个既有对象时，你必须保证基础设施对象和资源定义的一一对应关系。</p>
<div class="blog_h2"><span class="graybg">terraform_remote_state</span></div>
<p>该数据源能够从<span style="background-color: #c0c0c0;">其它Terraform配置</span>的最新状态快照拉取<span style="background-color: #c0c0c0;">根模块的输出值</span>。这个模块是内置的，不需要配置。</p>
<p>目标配置使用local后端，读取其输出值的例子：</p>
<pre class="crayon-plain-tag">data "terraform_remote_state" "vpc" {
  backend = "local"
  config = {
    # 目标配置的状态文件
    path = "..."
  }
}

# Terraform &gt;= 0.12
resource "aws_instance" "foo" {
  #                                           读取输出值
  subnet_id = data.terraform_remote_state.vpc.outputs.subnet_id
}

# Terraform &lt;= 0.11
resource "aws_instance" "foo" {
  # ...
  subnet_id = "${data.terraform_remote_state.vpc.subnet_id}"
}</pre>
<p>注意：仅仅目标配置的根模块的输出值被暴露，其子模块的输出值是不可见的。你必须手工在根模块将子模块的输出值再次输出：</p>
<pre class="crayon-plain-tag">module "app" {
  source = "..."
}

output "app_value" {
  # This syntax is for Terraform 0.12 or later.
  value = module.app.example
}</pre>
<div class="blog_h2"><span class="graybg">状态锁定 </span></div>
<p>如果后端支持，Terraform在执行任何可能修改状态的操作时，会锁定状态，防止并发修改损坏数据。</p>
<p>对于大部分命令，都可以使用命令行选项<pre class="crayon-plain-tag">-lock</pre>禁用锁定，但是不推荐这样做。 </p>
<p>命令<pre class="crayon-plain-tag">force-unlock</pre>用于强制解锁，这个命令存在危险性，会导致并发修改。</p>
<div class="blog_h2"><span class="graybg">工作区</span></div>
<p>存储在backend中的状态数据，属于一个工作区。<span style="background-color: #c0c0c0;">最开始仅有一个名为default的工作区，因而对于一个Terraform配置来说，只有一个关联的state</span>。</p>
<p>某些backend支持多个工作区，包括S3、Local、Kubernetes等。</p>
<p>命令<pre class="crayon-plain-tag">terraform workspace</pre>用于管理工作区。</p>
<div class="blog_h3"><span class="graybg">何时使用工作区</span></div>
<p>工作区可以用于区分测试/生产环境，开发人员可以在测试环境中创建并行的、完整的基础设施，并且测试配置文件的变更。</p>
<p>当管理大规模系统时，应该重构被拆分出多个配置（而不是引入新工作区），这些配置甚至可能由不同团队管理。</p>
<div class="blog_h2"><span class="graybg">敏感数据</span></div>
<p>在Local后端中存储的状态，敏感数据直接明文保存在文件中。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/terraform">Terraform快速参考</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/terraform/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>编写Kubernetes风格的APIServer</title>
		<link>https://blog.gmem.cc/kubernetes-style-apiserver</link>
		<comments>https://blog.gmem.cc/kubernetes-style-apiserver#comments</comments>
		<pubDate>Fri, 20 Aug 2021 07:33:34 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=38317</guid>
		<description><![CDATA[<p>背景 前段时间接到一个需求做一个工具，工具将在K8S中运行。需求很适合用控制器模式实现，很自然的就基于kubebuilder进行开发了。但是和K8S环境提供方沟通时发现，他们不允许工作负载调用控制平面的接口，这该怎么办呢。 最快速的解决方案是，自己运行一套kube-apiserver + etcd。但是这对我们来说太重了，kube-apiserver很多我们不需要的特性占用了过多资源，因此这里想寻找一个更轻量的方案。 apiserver库 kubernetes/apiserver同步自kubernertes主代码树的taging/src/k8s.io/apiserver目录，它提供了创建K8S风格的API Server所需要的库。包括kube-apiserver、kube-aggregator、service-catalog在内的很多项目都依赖此库。 apiserver库的目的主要是用来构建API Aggregation中的Extension API Server。它提供的特性包括： 将authn/authz委托给主kube-apiserver 支持kuebctl兼容的API发现 支持admisson control链 支持版本化的API类型 K8S提供了一个样例kubernetes/sample-apiserver，但是这个例子依赖于主kube-apiserver。即使不使用authn/authz或API聚合，也是如此。你需要通过--kubeconfig来指向一个主kube-apiserver，样例中的SharedInformer依赖于会连接到主kube-apiserver来访问K8S资源。 sample-apiserver分析 显然我们是不能对主kube-apiserver有任何依赖的，这里分析一下sample-apiserver的代码，看看如何进行改动。 入口点 [crayon-69d31392df697171679633/] <a class="read-more" href="https://blog.gmem.cc/kubernetes-style-apiserver">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/kubernetes-style-apiserver">编写Kubernetes风格的APIServer</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">背景</span></div>
<p>前段时间接到一个需求做一个工具，工具将在K8S中运行。需求很适合用控制器模式实现，很自然的就基于kubebuilder进行开发了。但是和K8S环境提供方沟通时发现，他们不允许工作负载调用控制平面的接口，这该怎么办呢。</p>
<p>最快速的解决方案是，自己运行一套kube-apiserver + etcd。但是这对我们来说太重了，kube-apiserver很多我们不需要的特性占用了过多资源，因此这里想寻找一个更轻量的方案。</p>
<div class="blog_h1"><span class="graybg">apiserver库</span></div>
<p><a href="https://github.com/kubernetes/apiserver">kubernetes/apiserver</a>同步自kubernertes主代码树的taging/src/k8s.io/apiserver目录，它提供了创建K8S风格的API Server所需要的库。包括kube-apiserver、kube-aggregator、service-catalog在内的很多项目都依赖此库。</p>
<p>apiserver库的目的主要是用来构建API Aggregation中的Extension API Server。它提供的特性包括：</p>
<ol>
<li>将authn/authz委托给主kube-apiserver</li>
<li>支持kuebctl兼容的API发现</li>
<li>支持admisson control链</li>
<li>支持版本化的API类型</li>
</ol>
<p>K8S提供了一个样例<a href="https://github.com/kubernetes/sample-apiserver">kubernetes/sample-apiserver</a>，但是这个例子依赖于主kube-apiserver。即使不使用authn/authz或API聚合，也是如此。你需要通过--kubeconfig来指向一个主kube-apiserver，样例中的SharedInformer依赖于会连接到主kube-apiserver来访问K8S资源。</p>
<div class="blog_h2"><span class="graybg">sample-apiserver分析</span></div>
<p>显然我们是不能对主kube-apiserver有任何依赖的，这里分析一下sample-apiserver的代码，看看如何进行改动。</p>
<div class="blog_h3"><span class="graybg">入口点</span></div>
<pre class="crayon-plain-tag">func main() {
	logs.InitLogs()
	defer logs.FlushLogs()

	stopCh := genericapiserver.SetupSignalHandler()
	// 初始化服务器选项
	options := server.NewWardleServerOptions(os.Stdout, os.Stderr)
	// 启动服务器
	cmd := server.NewCommandStartWardleServer(options, stopCh)
	cmd.Flags().AddGoFlagSet(flag.CommandLine)
	if err := cmd.Execute(); err != nil {
		klog.Fatal(err)
	}
}</pre>
<div class="blog_h3"><span class="graybg">服务器选项 </span></div>
<pre class="crayon-plain-tag">type WardleServerOptions struct {
	RecommendedOptions *genericoptions.RecommendedOptions

	SharedInformerFactory informers.SharedInformerFactory
	StdOut                io.Writer
	StdErr                io.Writer
}

func NewWardleServerOptions(out, errOut io.Writer) *WardleServerOptions {
	o := &amp;WardleServerOptions{
		RecommendedOptions: genericoptions.NewRecommendedOptions(
			// 数据默认存放在Etcd的/registry/wardle.example.com目录下
			defaultEtcdPathPrefix,
			// 指定wardle.example.com/v1alpha1使用遗留编解码器
			apiserver.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion),
			// API Server的进程信息
			genericoptions.NewProcessInfo("wardle-apiserver", "wardle"),
		),

		StdOut: out,
		StdErr: errOut,
	}
	// wardle.example.com/v1alpha1中的所有对象存储到Etcd
	o.RecommendedOptions.Etcd.StorageConfig.EncodeVersioner = 
		runtime.NewMultiGroupVersioner(v1alpha1.SchemeGroupVersion, 
			schema.GroupKind{Group: v1alpha1.GroupName})
	return o
}</pre>
<p>可以看到，选项的核心是genericoptions.RecommendedOptions，顾名思义，它用于提供运行apiserver所需的“推荐”选项：</p>
<pre class="crayon-plain-tag">type RecommendedOptions struct {
	// Etcd相关的配置
	Etcd           *EtcdOptions
	// HTTPS相关选项，包括监听地址、证书等配置。还负责创建并设置Lookback专用的rest.Config
	SecureServing  *SecureServingOptionsWithLoopback
	// authn选项
	Authentication *DelegatingAuthenticationOptions
	// authz选项
	Authorization  *DelegatingAuthorizationOptions
	// 审计选项
	Audit          *AuditOptions
	// 用于启用剖析、竞态条件剖析
	Features       *FeatureOptions
	// 核心API选项，指定主kube-apiserver配置文件位置
	CoreAPI        *CoreAPIOptions

	// 特性开关
	FeatureGate featuregate.FeatureGate
	// 所有以上选项的ApplyTo被调用后，调用下面的函数。返回的PluginInitializer会传递给Admission.ApplyTo
	ExtraAdmissionInitializers func(c *server.RecommendedConfig) ([]admission.PluginInitializer, error)
	Admission                  *AdmissionOptions
	// 提供服务器信息
	ProcessInfo *ProcessInfo
	// Webhook选项
	Webhook     *WebhookOptions
	// 控制服务器的出站流量
	EgressSelector *EgressSelectorOptions
}</pre>
<p>推荐的选项取值，可以由函数genericoptions.NewRecommendedOptions()提供。RecommendedOptions支持通过命令行参数获取选项取值：</p>
<pre class="crayon-plain-tag">func (o *RecommendedOptions) AddFlags(fs *pflag.FlagSet) {}</pre>
<div class="blog_h3"><span class="graybg">准备服务器 </span></div>
<p>服务器实现为cobra.Command命令，首先会将RecommendedOptions绑定到命令行参数。</p>
<pre class="crayon-plain-tag">func NewCommandStartWardleServer(defaults *WardleServerOptions, stopCh &lt;-chan struct{}) *cobra.Command {
	o := *defaults
	cmd := &amp;cobra.Command{
		Short: "Launch a wardle API server",
		Long:  "Launch a wardle API server",
		RunE: func(c *cobra.Command, args []string) error {
			// ...
		},
	}

	flags := cmd.Flags()
	// 将选项添加为命令行标记
	o.RecommendedOptions.AddFlags(flags)
	utilfeature.DefaultMutableFeatureGate.AddFlag(flags)

	return cmd
}</pre>
<p>然后，调用cmd.Execute()，进而调用上面的RunE方法：</p>
<pre class="crayon-plain-tag">if err := o.Complete(); err != nil {
	return err
}
if err := o.Validate(args); err != nil {
	return err
}
if err := o.RunWardleServer(stopCh); err != nil {
	return err
}
return nil</pre>
<p>Complete方法，就是注册了一个Admission控制器，<a href="#WardleServerOptions-Complete">参考下文</a>。</p>
<p>Validate方法调用RecommendedOptions进行选项（合并了用户提供的命令行标记）合法性校验：</p>
<pre class="crayon-plain-tag">func (o WardleServerOptions) Validate(args []string) error {
	errors := []error{}
	// 校验结果是错误的切片
	errors = append(errors, o.RecommendedOptions.Validate()...)
	// 合并为单个错误
	return utilerrors.NewAggregate(errors)
}</pre>
<div class="blog_h3"><span class="graybg"><a id="start-server"></a>启动服务器</span></div>
<p>RunWardleServer方法启动API Server。它包含了将服务器选项（Option）转换为服务器配置（Config），从服务器配置实例化APIServer，并运行APIServer的整个流程：</p>
<pre class="crayon-plain-tag">func (o WardleServerOptions) RunWardleServer(stopCh &lt;-chan struct{}) error {
	// 选项转换为配置
	config, err := o.Config()
	if err != nil {
		return err
	}
	// 配置转换为CompletedConfig，实例化APIServer
	server, err := config.Complete().New()
	if err != nil {
		return err
	}

	// 注册一个在API Server启动之后运行的钩子
	server.GenericAPIServer.AddPostStartHookOrDie("start-sample-server-informers", func(context genericapiserver.PostStartHookContext) error {
		config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
		o.SharedInformerFactory.Start(context.StopCh)
		return nil
	})

	//                             准备运行、  运行 APIServer
	return server.GenericAPIServer.PrepareRun().Run(stopCh)
}</pre>
<div class="blog_h3"><span class="graybg">服务器配置</span></div>
<p>API Server不能直接使用选项，必须将选项转换为apiserver.Config：</p>
<pre class="crayon-plain-tag">func (o *WardleServerOptions) Config() (*apiserver.Config, error) {
	// 这里又对选项进行了若干修改

	// 检查证书是否可以读取，如果不可以则尝试生成自签名证书
	if err := o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts("localhost", nil, []net.IP{net.ParseIP("127.0.0.1")}); err != nil {
		return nil, fmt.Errorf("error creating self-signed certificates: %v", err)
	}
	// 根据特性开关，决定是否支持分页
	o.RecommendedOptions.Etcd.StorageConfig.Paging = utilfeature.DefaultFeatureGate.Enabled(features.APIListChunking)
	// 
	o.RecommendedOptions.ExtraAdmissionInitializers = func(c *genericapiserver.RecommendedConfig) ([]admission.PluginInitializer, error) {
		// ...
	}

	// 创建推荐配置
	serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs)
	// 暴露OpenAPI端点
	serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(
		// 自动生成的
		sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme))
	serverConfig.OpenAPIConfig.Info.Title = "Wardle"
	serverConfig.OpenAPIConfig.Info.Version = "0.1"

	// 将RecommendedOptions应用到RecommendedConfig
	if err := o.RecommendedOptions.ApplyTo(serverConfig); err != nil {
		return nil, err
	}

	// 选项包含GenericConfig和你自定义的选项两部分
	config := &amp;apiserver.Config{
		GenericConfig: serverConfig,
		ExtraConfig:   apiserver.ExtraConfig{},
	}
	return config, nil
}</pre>
<p>从上面的代码可以看到RecommendedConfig是配置的核心：</p>
<pre class="crayon-plain-tag">type RecommendedConfig struct {
	// 用于配置GenericAPIServer的结构
	Config

	// SharedInformerFactory用于提供K8S资源的shared informers
	// 该字段由RecommendedOptions.CoreAPI.ApplyTo调用设置，informer默认使用in-cluster的ClientConfig
	SharedInformerFactory informers.SharedInformerFactory

	// 由RecommendedOptions.CoreAPI.ApplyTo设置，informer使用
	ClientConfig *restclient.Config
}

type Config struct {
	SecureServing *SecureServingInfo
	Authentication AuthenticationInfo
	Authorization AuthorizationInfo
	// 特权的本机使用的ClientConfig，PostStartHooks用到它
	LoopbackClientConfig *restclient.Config
	// ...
}</pre>
<p>通过调用ApplyTo方法，将RecommendedOptions中的选项传递给了RecommendedConfig：</p>
<pre class="crayon-plain-tag">func (o *RecommendedOptions) ApplyTo(config *server.RecommendedConfig) error {
	// 调用config.AddHealthChecks添加Etcd的健康检查
	// 设置config.RESTOptionsGetter
	if err := o.Etcd.ApplyTo(&amp;config.Config); err != nil {
		return err
	}
	// 创建config.Listener
	// 初始化config.Cert、config.CipherSuites、config.SNICerts等
	if err := o.SecureServing.ApplyTo(&amp;config.Config.SecureServing, &amp;config.Config.LoopbackClientConfig); err != nil {
		return err
	}
	// 初始化身份验证配置，获取相应的K8S客户端接口
	if err := o.Authentication.ApplyTo(&amp;config.Config.Authentication, config.SecureServing, config.OpenAPIConfig); err != nil {
		return err
	}
	// 初始化访问控制配置，获取相应的K8S客户端接口
	if err := o.Authorization.ApplyTo(&amp;config.Config.Authorization); err != nil {
		return err
	}
	if err := o.Audit.ApplyTo(&amp;config.Config, config.ClientConfig, config.SharedInformerFactory, o.ProcessInfo, o.Webhook); err != nil {
		return err
	}
	if err := o.Features.ApplyTo(&amp;config.Config); err != nil {
		return err
	}
	// 从配置文件加载kubeconfig或者使用incluster配置，提供config.ClientConfig和config.SharedInformerFactory
	if err := o.CoreAPI.ApplyTo(config); err != nil {
		return err
	}
	// 调用Admission初始化器
	if initializers, err := o.ExtraAdmissionInitializers(config); err != nil {
		return err
	// 逐个初始化Admission控制器
	} else if err := o.Admission.ApplyTo(&amp;config.Config, config.SharedInformerFactory, config.ClientConfig, o.FeatureGate, initializers...); err != nil {
		return err
	}
	if err := o.EgressSelector.ApplyTo(&amp;config.Config); err != nil {
		return err
	}
	if feature.DefaultFeatureGate.Enabled(features.APIPriorityAndFairness) {
		config.FlowControl = utilflowcontrol.New(
			config.SharedInformerFactory,
			kubernetes.NewForConfigOrDie(config.ClientConfig).FlowcontrolV1alpha1(),
			config.MaxRequestsInFlight+config.MaxMutatingRequestsInFlight,
			config.RequestTimeout/4,
		)
	}
	return nil
}</pre>
<p>可以看到，在生成服务器配置的阶段，对主kube-apiserver有强依赖，这些依赖导致了sample-apiserver无法脱离K8S独立运行。</p>
<p>RecommendedConfig还需要通过Conplete方法，变成CompletedConfig：</p>
<pre class="crayon-plain-tag">// server, err := config.Complete().New()

func (cfg *Config) Complete() CompletedConfig {
	c := completedConfig{
		cfg.GenericConfig.Complete(),
		&amp;cfg.ExtraConfig,
	}

	c.GenericConfig.Version = &amp;version.Info{
		Major: "1",
		Minor: "0",
	}

	return CompletedConfig{&amp;c}
}

// Complete补全缺失的、必须的配置信息，这些信息能够从已有配置导出
func (c *RecommendedConfig) Complete() CompletedConfig {
	return c.Config.Complete(c.SharedInformerFactory)
}</pre>
<div class="blog_h3"><span class="graybg">实例化APIServer</span></div>
<p>WardleServer，是从CompletedConfig实例化的：</p>
<pre class="crayon-plain-tag">func (c completedConfig) New() (*WardleServer, error) {
	// 创建GenericAPIServer
	//                                         名字用于在记录日志时进行区分
	//                                                           DelegationTarget用于进行APIServer的组合（composition）
	genericServer, err := c.GenericConfig.New("sample-apiserver", genericapiserver.NewEmptyDelegate())
	if err != nil {
		return nil, err
	}

	s := &amp;WardleServer{
		GenericAPIServer: genericServer,
	}</pre>
<p>上面的<pre class="crayon-plain-tag">New()</pre>创建了核心的GenericAPIServer：</p>
<pre class="crayon-plain-tag">func (c completedConfig) New(name string, delegationTarget DelegationTarget) (*GenericAPIServer, error) {
	// 断言
	if c.Serializer == nil {
		return nil, fmt.Errorf("Genericapiserver.New() called with config.Serializer == nil")
	}
	if c.LoopbackClientConfig == nil {
		return nil, fmt.Errorf("Genericapiserver.New() called with config.LoopbackClientConfig == nil")
	}
	if c.EquivalentResourceRegistry == nil {
		return nil, fmt.Errorf("Genericapiserver.New() called with config.EquivalentResourceRegistry == nil")
	}

	handlerChainBuilder := func(handler http.Handler) http.Handler {
		return c.BuildHandlerChainFunc(handler, c.Config)
	}
	// 构建请求处理器
	apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler())

	// 创建GenericAPIServer，很多字段直接来自completedConfig
	s := &amp;GenericAPIServer{
		discoveryAddresses:         c.DiscoveryAddresses,
		LoopbackClientConfig:       c.LoopbackClientConfig,
		legacyAPIGroupPrefixes:     c.LegacyAPIGroupPrefixes,
		admissionControl:           c.AdmissionControl,
		Serializer:                 c.Serializer,
		AuditBackend:               c.AuditBackend,
		Authorizer:                 c.Authorization.Authorizer,
		delegationTarget:           delegationTarget,
		EquivalentResourceRegistry: c.EquivalentResourceRegistry,
		HandlerChainWaitGroup:      c.HandlerChainWaitGroup,

		minRequestTimeout:     time.Duration(c.MinRequestTimeout) * time.Second,
		ShutdownTimeout:       c.RequestTimeout,
		ShutdownDelayDuration: c.ShutdownDelayDuration,
		SecureServingInfo:     c.SecureServing,
		ExternalAddress:       c.ExternalAddress,

		Handler: apiServerHandler,

		listedPathProvider: apiServerHandler,

		openAPIConfig:           c.OpenAPIConfig,
		skipOpenAPIInstallation: c.SkipOpenAPIInstallation,

		postStartHooks:         map[string]postStartHookEntry{},
		preShutdownHooks:       map[string]preShutdownHookEntry{},
		disabledPostStartHooks: c.DisabledPostStartHooks,

		healthzChecks:    c.HealthzChecks,
		livezChecks:      c.LivezChecks,
		readyzChecks:     c.ReadyzChecks,
		readinessStopCh:  make(chan struct{}),
		livezGracePeriod: c.LivezGracePeriod,

		DiscoveryGroupManager: discovery.NewRootAPIsHandler(c.DiscoveryAddresses, c.Serializer),

		maxRequestBodyBytes: c.MaxRequestBodyBytes,
		livezClock:          clock.RealClock{},
	}

	// ...

	// 添加delegationTarget的生命周期钩子
	for k, v := range delegationTarget.PostStartHooks() {
		s.postStartHooks[k] = v
	}
	for k, v := range delegationTarget.PreShutdownHooks() {
		s.preShutdownHooks[k] = v
	}

	// 添加预配置的钩子
	for name, preconfiguredPostStartHook := range c.PostStartHooks {
		if err := s.AddPostStartHook(name, preconfiguredPostStartHook.hook); err != nil {
			return nil, err
		}
	}

	// 如果配置包含了SharedInformerFactory，而启动该SharedInformerFactory的钩子没有注册
	// 则注册一个PostStart钩子来启动它
	genericApiServerHookName := "generic-apiserver-start-informers"
	if c.SharedInformerFactory != nil {
		if !s.isPostStartHookRegistered(genericApiServerHookName) {
			err := s.AddPostStartHook(genericApiServerHookName, func(context PostStartHookContext) error {
				c.SharedInformerFactory.Start(context.StopCh)
				return nil
			})
			if err != nil {
				return nil, err
			}
			// TODO: Once we get rid of /healthz consider changing this to post-start-hook.
			err = s.addReadyzChecks(healthz.NewInformerSyncHealthz(c.SharedInformerFactory))
			if err != nil {
				return nil, err
			}
		}
	}

	const priorityAndFairnessConfigConsumerHookName = "priority-and-fairness-config-consumer"
	if s.isPostStartHookRegistered(priorityAndFairnessConfigConsumerHookName) {
	} else if c.FlowControl != nil {
		err := s.AddPostStartHook(priorityAndFairnessConfigConsumerHookName, func(context PostStartHookContext) error {
			go c.FlowControl.Run(context.StopCh)
			return nil
		})
		if err != nil {
			return nil, err
		}
		// TODO(yue9944882): plumb pre-shutdown-hook for request-management system?
	} else {
		klog.V(3).Infof("Not requested to run hook %s", priorityAndFairnessConfigConsumerHookName)
	}

	// 添加delegationTarget的健康检查
	for _, delegateCheck := range delegationTarget.HealthzChecks() {
		skip := false
		for _, existingCheck := range c.HealthzChecks {
			if existingCheck.Name() == delegateCheck.Name() {
				skip = true
				break
			}
		}
		if skip {
			continue
		}
		s.AddHealthChecks(delegateCheck)
	}

	s.listedPathProvider = routes.ListedPathProviders{s.listedPathProvider, delegationTarget}

	// 安装Profiling、Metrics、URL / 下显示的路径列表（listedPathProvider）
	installAPI(s, c.Config)

	// use the UnprotectedHandler from the delegation target to ensure that we don't attempt to double authenticator, authorize,
	// or some other part of the filter chain in delegation cases.
	if delegationTarget.UnprotectedHandler() == nil &amp;&amp; c.EnableIndex {
		s.Handler.NonGoRestfulMux.NotFoundHandler(routes.IndexLister{
			StatusCode:   http.StatusNotFound,
			PathProvider: s.listedPathProvider,
		})
	}

	return s, nil
}</pre>
<p>GenericAPIServer.Handler就是HTTP请求的处理器，我们在下文的<a title="记录一次KeyDB缓慢的定位过程" href="#request-processing">请求处理过程</a>一节分析。</p>
<div class="blog_h3"><span class="graybg"><a id="InstallAPIGroup"></a>安装APIGroup</span></div>
<p>实例化GenericAPIServer之后，是安装APIGroup：</p>
<pre class="crayon-plain-tag">// 创建APIGroupInfo，关于一组API的各种信息，包括已经注册的API（Scheme），如何进行编解码（Codec）
	// 如何解析查询参数（ParameterCodec）
	apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(wardle.GroupName, Scheme, metav1.ParameterCodec, Codecs)

	// 从资源到rest.Storage的映射
	v1alpha1storage := map[stcongring]rest.Storage{}
	v1alpha1storage["flunders"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))
	v1alpha1storage["fischers"] = wardleregistry.RESTInPeace(fischerstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))
	// 两个版本分别对应一个映射
	apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage

	v1beta1storage := map[string]rest.Storage{}
	v1beta1storage["flunders"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))
	apiGroupInfo.VersionedResourcesStorageMap["v1beta1"] = v1beta1storage
	if err := s.GenericAPIServer.InstallAPIGroup(&amp;apiGroupInfo); err != nil {
		return nil, err
	}

	return s, nil
}</pre>
<p>上面的flunderstorage.NewREST等方法返回的registry.REST，内嵌的genericregistry.Store不仅仅实现了rest.Storage：</p>
<pre class="crayon-plain-tag">type Storage interface {
	// 当请求的数据存放到该方法创建的对象之后，可以调用Create/Update进行持久化
	// 必须返回一个适用于 Codec.DecodeInto([]byte, runtime.Object) 的指针类型
	New() runtime.Object
}</pre>
<p>还实现了rest.StandardStorage：</p>
<pre class="crayon-plain-tag">type StandardStorage interface {
	Getter
	Lister
	CreaterUpdater
	GracefulDeleter
	CollectionDeleter
	Watcher
}</pre>
<p>实现了这些接口，意味着registry.REST能够支持API对象的增删改查和Watch。更多细节我们在下面的<a href="#request-processing">请求处理过程</a>一节中探讨。</p>
<div class="blog_h3"><span class="graybg">启动APIServer</span></div>
<p>如<a href="#start-server">启动服务器</a>一节中的代码所示， 在将选项转换为配置、完成配置，并从配置实例化APIServer之后，会执行由两个步骤组成的启动逻辑。</p>
<p>首先是PrepareRun，这里执行一些需要在API安装（在实例化时）之后进行的操作：</p>
<pre class="crayon-plain-tag">func (s *GenericAPIServer) PrepareRun() preparedGenericAPIServer {
	s.delegationTarget.PrepareRun()
	// 安装OpenAPI的Handler
	if s.openAPIConfig != nil &amp;&amp; !s.skipOpenAPIInstallation {
		s.OpenAPIVersionedService, s.StaticOpenAPISpec = routes.OpenAPI{
			Config: s.openAPIConfig,
		}.Install(s.Handler.GoRestfulContainer, s.Handler.NonGoRestfulMux)
	}
	// 安装健康检查的Handler
	s.installHealthz()
	s.installLivez()
	err := s.addReadyzShutdownCheck(s.readinessStopCh)
	if err != nil {
		klog.Errorf("Failed to install readyz shutdown check %s", err)
	}
	s.installReadyz()

	// 为审计后端注册关闭前钩子
	if s.AuditBackend != nil {
		err := s.AddPreShutdownHook("audit-backend", func() error {
			s.AuditBackend.Shutdown()
			return nil
		})
		if err != nil {
			klog.Errorf("Failed to add pre-shutdown hook for audit-backend %s", err)
		}
	}

	return preparedGenericAPIServer{s}
}</pre>
<p>然后是Run，启动APIServer：</p>
<pre class="crayon-plain-tag">func (s preparedGenericAPIServer) Run(stopCh &lt;-chan struct{}) error {
	delayedStopCh := make(chan struct{})
	go func() {
		defer close(delayedStopCh)
		// 收到关闭信号
		&lt;-stopCh
		// 一旦关闭流程被触发，/readyz就需要立刻返回错误
		close(s.readinessStopCh)
		// 关闭服务器前休眠ShutdownDelayDuration，这让LB有个时间窗口来检测/readyz状态，不再发送请求给此服务器
		time.Sleep(s.ShutdownDelayDuration)
	}()

	// 运行服务器
	err := s.NonBlockingRun(delayedStopCh)
	if err != nil {
		return err
	}

	// 收到关闭信号
	&lt;-stopCh

	// 运行关闭前钩子
	err = s.RunPreShutdownHooks()
	if err != nil {
		return err
	}

	// 等待延迟关闭信号
	&lt;-delayedStopCh

	// 等待现有请求完毕，然后关闭
	s.HandlerChainWaitGroup.Wait()

	return nil
}</pre>
<p> NonBlockingRun是启动APIServer的核心代码。它会启动一个HTTPS服务器：</p>
<pre class="crayon-plain-tag">func (s preparedGenericAPIServer) NonBlockingRun(stopCh &lt;-chan struct{}) error {
	// 这个通道用于保证HTTP Server优雅关闭，不会导致丢失审计事件
	auditStopCh := make(chan struct{})

	// 首先启动审计后端，这时任何请求都进不来
	if s.AuditBackend != nil {
		if err := s.AuditBackend.Run(auditStopCh); err != nil {
			return fmt.Errorf("failed to run the audit backend: %v", err)
		}
	}

	// 下面的通道用于出错时清理listener
	internalStopCh := make(chan struct{})
	var stoppedCh &lt;-chan struct{}
	if s.SecureServingInfo != nil &amp;&amp; s.Handler != nil {
		var err error
		// 启动HTTPS服务器，仅当证书错误或内部listen调用出错时会失败
		// server loop在一个Goroutine中运行
		// 可以看到，这里的s.Handler是可以被我们访问到的，因此建立HTTP（非HTTPS）服务器应该很方便
		stoppedCh, err = s.SecureServingInfo.Serve(s.Handler, s.ShutdownTimeout, internalStopCh)
		if err != nil {
			close(internalStopCh)
			close(auditStopCh)
			return err
		}
	}

	// 清理
	go func() {
		&lt;-stopCh
		close(internalStopCh)
		if stoppedCh != nil {
			&lt;-stoppedCh
		}
		s.HandlerChainWaitGroup.Wait()
		close(auditStopCh)
	}()

	// 启动后钩子
	s.RunPostStartHooks(stopCh)

	if _, err := systemd.SdNotify(true, "READY=1\n"); err != nil {
		klog.Errorf("Unable to send systemd daemon successful start message: %v\n", err)
	}

	return nil
}</pre>
<div class="blog_h3"><span class="graybg">结构注册到Scheme</span></div>
<p>apis/wardle包，以及它的子包，定义了wardle.example.com组的API。</p>
<p>wardle包的register.go中定义了组，以及从GV获得GVK、GVR的函数：</p>
<pre class="crayon-plain-tag">const GroupName = "wardle.example.com"
//                                                             没有正式（formal）类型则使用此常量
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal}

func Kind(kind string) schema.GroupKind {
	return SchemeGroupVersion.WithKind(kind).GroupKind()
}

func Resource(resource string) schema.GroupResource {
	return SchemeGroupVersion.WithResource(resource).GroupResource()
}</pre>
<p>API包通常都要提供AddToScheme变量，用于将API注册到指定的scheme：</p>
<pre class="crayon-plain-tag">var (
	SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
	AddToScheme   = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
	// 注册API的核心，添加GV中的若干API类型
	scheme.AddKnownTypes(SchemeGroupVersion,
		&amp;Flunder{}, // 必须提供指针类型，Go反射得到类型在编码时作为Kind
		&amp;FlunderList{},
		&amp;Fischer{},
		&amp;FischerList{},
	)
	return nil
}

// 所谓SchemeBuilder其实就是切片
type SchemeBuilder []func(*Scheme) error
// NewSchemeBuilder方法支持多个回调函数，对这些函数调用SchemeBuilder.Register
func NewSchemeBuilder(funcs ...func(*Scheme) error) SchemeBuilder {
	var sb SchemeBuilder
	sb.Register(funcs...)
	return sb
}
// 所谓Register就是添加到切片中
func (sb *SchemeBuilder) Register(funcs ...func(*Scheme) error) {
	for _, f := range funcs {
		*sb = append(*sb, f)
	}
}

// 所谓AddToScheme就是遍历回调，Go方法可以作为变量传递
func (sb *SchemeBuilder) AddToScheme(s *Scheme) error {
	for _, f := range *sb {
		if err := f(s); err != nil {
			return err
		}
	}
	return nil
}</pre>
<p>wardle包的types.go则定义了API类型对应的Go结构体。</p>
<p>子包v1alpha1、v1beta1定义了API的两个版本。它们包含和wardle包类似的GroupName、SchemeGroupVersion、SchemeBuilder、AddToScheme…等变量/函数，以及对应的API类型结构体。还包括自动生成的、与APIVersionInternal版本API进行转换的函数。</p>
<p>回到wardle包，Install方法支持将APIVersionInternal、v1alpha1、v1beta1这些版本都注册到scheme：</p>
<pre class="crayon-plain-tag">func Install(scheme *runtime.Scheme) {
	utilruntime.Must(wardle.AddToScheme(scheme))
	utilruntime.Must(v1beta1.AddToScheme(scheme))
	utilruntime.Must(v1alpha1.AddToScheme(scheme))
	utilruntime.Must(scheme.SetVersionPriority(v1beta1.SchemeGroupVersion, v1alpha1.SchemeGroupVersion))
}</pre>
<p>而在apiserver包中，在init时会调用上面的Install函数：</p>
<pre class="crayon-plain-tag">var (
	// API注册表
	Scheme = runtime.NewScheme()
	// 编解码器工厂
	Codecs = serializer.NewCodecFactory(Scheme)
)

func init() {
	// 安装sample-apiserver提供的API
	install.Install(Scheme)

	// 将k8s.io/apimachinery/pkg/apis/meta/v1"注册到v1组，为什么？
	// we need to add the options to empty v1
	// TODO fix the server code to avoid this
	metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"})

	// TODO: keep the generic API server from wanting this
	unversioned := schema.GroupVersion{Group: "", Version: "v1"}
	Scheme.AddUnversionedTypes(unversioned,
		&amp;metav1.Status{},
		&amp;metav1.APIVersions{},
		&amp;metav1.APIGroupList{},
		&amp;metav1.APIGroup{},
		&amp;metav1.APIResourceList{},
	)
}</pre>
<p>Codecs是API资源编解码器的工厂，RecommendedOptions需要此工厂：</p>
<pre class="crayon-plain-tag">// ... 
		RecommendedOptions: genericoptions.NewRecommendedOptions(
			defaultEtcdPathPrefix,
			// 得到v1alpha1版本的LegacyCodec，遗留编解码器（runtime.Codec）
			// LegacyCodec编码到指定的API版本，解码（从任何支持的源）到内部形式
			// 此编码器总是编码为JSON
			// 
			// LegacyCodec方法已经废弃，客户端/服务器应该根据MIME类型协商serializer
			// 并调用CodecForVersions
			apiserver.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion),
			genericoptions.NewProcessInfo("wardle-apiserver", "wardle"),
		),

func NewRecommendedOptions(prefix string, codec runtime.Codec, processInfo *ProcessInfo) *RecommendedOptions {
	return &amp;RecommendedOptions{
		//  不过这里的runtime.Codec用于将AP对象转换为JSON并存放到Etcd，而不是发给客户端
		Etcd:           NewEtcdOptions(storagebackend.NewDefaultConfig(prefix, codec)),
	// ...</pre>
<p>APIGroupInfo也需要此工厂：</p>
<pre class="crayon-plain-tag">apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(wardle.GroupName, Scheme, metav1.ParameterCodec, Codecs)

func NewDefaultAPIGroupInfo(group string, scheme *runtime.Scheme, parameterCodec runtime.ParameterCodec, codecs serializer.CodecFactory) APIGroupInfo {
	return APIGroupInfo{
		// ...
		Scheme:                 scheme,
		ParameterCodec:         parameterCodec,
		NegotiatedSerializer:   codecs,
	}
}</pre>
<p>在APIServer实例化期间，Scheme会传递给APIGroupInfo，从而实现资源 - Go类型之间的映射。</p>
<div class="blog_h3"><span class="graybg">Admission控制器</span></div>
<p>sample-server也示例了如何集成自己的Admission控制器到API Server中。</p>
<p>在选项中，需要注册一个Admission初始化器</p>
<pre class="crayon-plain-tag">o.RecommendedOptions.ExtraAdmissionInitializers = func(c *genericapiserver.RecommendedConfig) ([]admission.PluginInitializer, error) {
		client, err := clientset.NewForConfig(c.LoopbackClientConfig)
		if err != nil {
			return nil, err
		}
		informerFactory := informers.NewSharedInformerFactory(client, c.LoopbackClientConfig.Timeout)
		o.SharedInformerFactory = informerFactory
		//                                   初始化函数
		return []admission.PluginInitializer{wardleinitializer.New(informerFactory)}, nil
	}</pre>
<p>初始化器是一个函数，会在选项转为配置的时候执行：</p>
<pre class="crayon-plain-tag">// 调用上面的函数
	if initializers, err := o.ExtraAdmissionInitializers(config); err != nil {
		return err
	// 初始化Admission控制器
	} else if err := o.Admission.ApplyTo(&amp;config.Config, config.SharedInformerFactory, config.ClientConfig, o.FeatureGate, initializers...); err != nil {
		return err
	}</pre>
<p>上面的ApplyTo方法，会组建一个Admission控制器的初始化函数链，并逐个调用，以初始化所有Admission控制器。</p>
<p>此外，在PostStart钩子中，会启动Admission控制器所依赖的SharedInformerFactory：</p>
<pre class="crayon-plain-tag">server.GenericAPIServer.AddPostStartHookOrDie("start-sample-server-informers", func(context genericapiserver.PostStartHookContext) error {
		// 主kube-apiserver的InformerFactory，貌似没什么用
		config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
		// 次级kube-apiserver的InformerFactory，Admission控制器需要使用
		o.SharedInformerFactory.Start(context.StopCh)
		return nil
	})</pre>
<p>我们看一下sample-apiserver的Admission相关代码。</p>
<p>位于pkg/admission/wardleinitializer包中的是Admission初始化器，它能够为任何WantsInternalWardleInformerFactory类型的Admission控制器注入InformerFactory：</p>
<pre class="crayon-plain-tag">type pluginInitializer struct {
	informers informers.SharedInformerFactory
}

var _ admission.PluginInitializer = pluginInitializer{}

// 该函数在ExtraAdmissionInitializers函数中调用
func New(informers informers.SharedInformerFactory) pluginInitializer {
	return pluginInitializer{
		informers: informers,
	}
}

// 该函数在o.Admission.ApplyTo中调用
func (i pluginInitializer) Initialize(plugin admission.Interface) {
	if wants, ok := plugin.(WantsInternalWardleInformerFactory); ok {
		wants.SetInternalWardleInformerFactory(i.informers)
	}
}</pre>
<p>位于pkg/admission/plugin/banflunder包中的是为了的Admission控制器BanFlunder。函数：</p>
<pre class="crayon-plain-tag">func Register(plugins *admission.Plugins) {
	plugins.Register("BanFlunder", func(config io.Reader) (admission.Interface, error) {
		return New()
	})
}</pre>
<p><a id="WardleServerOptions-Complete"></a>会在程序运行的很早期调用，以注册Admission控制器到API Server：</p>
<pre class="crayon-plain-tag">func (o *WardleServerOptions) Complete() error {
	// 注册插件
	banflunder.Register(o.RecommendedOptions.Admission.Plugins)

	// 配置顺序
	o.RecommendedOptions.Admission.RecommendedPluginOrder = append(o.RecommendedOptions.Admission.RecommendedPluginOrder, "BanFlunder")

	return nil
}</pre>
<p>Admission控制器的核心是Admit函数，它可以修改或否决一个API Server请求：</p>
<pre class="crayon-plain-tag">func (d *DisallowFlunder) Admit(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
	// 仅仅对特定类型的API感兴趣
	if a.GetKind().GroupKind() != wardle.Kind("Flunder") {
		return nil
	}

	if !d.WaitForReady() {
		return admission.NewForbidden(a, fmt.Errorf("not yet ready to handle request"))
	}

	// 用于获取元数据
	metaAccessor, err := meta.Accessor(a.GetObject())
	if err != nil {
		return err
	}
	flunderName := metaAccessor.GetName()

	fischers, err := d.lister.List(labels.Everything())
	if err != nil {
		return err
	}

	for _, fischer := range fischers {
		for _, disallowedFlunder := range fischer.DisallowedFlunders {
			if flunderName == disallowedFlunder {
				return errors.NewForbidden(
					a.GetResource().GroupResource(),
					a.GetName(),
					// 拒绝请求
					fmt.Errorf("this name may not be used, please change the resource name"),
				)
			}
		}
	}
	return nil
}</pre>
<div class="blog_h3"><span class="graybg"><a id="request-processing"></a>请求处理过程</span></div>
<p>通过上文的分析，我们知道资源的增删改查是由registry.REST（空壳，实际是genericregistry.Store）负责的，那么HTTP请求是如何传递给它的呢？</p>
<p>回顾一下向APIGroupInfo中添加资源的代码：</p>
<pre class="crayon-plain-tag">//              资源类型          
v1alpha1storage["flunders"] = wardleregistry.RESTInPeace(
	// 创建registry.REST
	flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter)
)


func NewREST(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*registry.REST, error) {
	// 策略，参考下文
	strategy := NewStrategy(scheme)

	store := &amp;genericregistry.Store{
		// 实例化资源的函数
		NewFunc:                  func() runtime.Object {
			// 每次增删改查，都牵涉到结构的创建，因此在此打断点可以拦截所有请求
			return &amp;wardle.Flunder{}
		},
		// 实例化资源列表的函数
		NewListFunc:              func() runtime.Object { return &amp;wardle.FlunderList{} },
		// 判断对象是否可以被该存储处理
		PredicateFunc: func(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
			return storage.SelectionPredicate{
				Label: label,
				Field: field,
				GetAttrs: func(obj runtime.Object) (labels.Set, fields.Set, error) {
					apiserver, ok := obj.(*wardle.Flunder)
					if !ok {
						return nil, nil, fmt.Errorf("given object is not a Flunder")
					}
					return apiserver.ObjectMeta.Labels, SelectableFields(apiserver), nil
				},
			}
		},
		// 资源的复数名称，当上下文中缺少必要的请求信息时使用
		DefaultQualifiedResource: wardle.Resource("flunders"),
		// 增删改的策略
		CreateStrategy: strategy,
		UpdateStrategy: strategy,
		DeleteStrategy: strategy,
	}
	options := &amp;generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
	// 填充默认字段
	if err := store.CompleteWithOptions(options); err != nil {
		return nil, err
	}
	return &amp;registry.REST{store}, nil
}</pre>
<p>为了创建genericregistry.Store，需要两个信息：</p>
<ol>
<li>Scheme，它提供的信息是Go类型和GVK之间的映射关系。其中Kind是根据Go结构的类型名反射得到的</li>
<li>generic.RESTOptionsGetter</li>
</ol>
<p>RESTOptionsGetter用于获得RESTOptions：</p>
<pre class="crayon-plain-tag">type RESTOptionsGetter interface {
	GetRESTOptions(resource schema.GroupResource) (RESTOptions, error)
}</pre>
<p>RESTOptions包含了关于存储的信息，尽管这个职责和名字好像没什么关系：</p>
<pre class="crayon-plain-tag">type RESTOptions struct {
	// 创建一个存储后端所需的配置信息
	StorageConfig *storagebackend.Config
	// 这是一个函数，能够提供storage.Interface和factory.DestroyFunc
	Decorator     StorageDecorator

	EnableGarbageCollection bool
	DeleteCollectionWorkers int
	ResourcePrefix          string
	CountMetricPollPeriod   time.Duration
}

// 
type Config struct {
	// 存储后端的类型，默认etcd3
	Type string
	// 传递给storage.Interface的所有方法的前缀，对应etcd存储前缀
	Prefix string
	// 连接到Etcd服务器的相关信息
	Transport TransportConfig
	// 提示APIServer是否应该支持分页
	Paging bool
	// 负责（反）串行化
	Codec runtime.Codec
	// 在持久化到Etcd之前，该对象输出目标将被转换为的GVK
	Transformer value.Transformer

	CompactionInterval time.Duration
	CountMetricPollPeriod time.Duration
	LeaseManagerConfig etcd3.LeaseManagerConfig
}


// 利用入参函数，构造storage.Interface
type StorageDecorator func(
	config *storagebackend.Config,
	resourcePrefix string,
	keyFunc func(obj runtime.Object) (string, error),
	newFunc func() runtime.Object,
	newListFunc func() runtime.Object,
	getAttrsFunc storage.AttrFunc,
	trigger storage.IndexerFuncs,
	indexers *cache.Indexers) (storage.Interface, factory.DestroyFunc, error)
// CRUD
type Interface interface {
	Versioner() Versioner
	Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error
	Delete(ctx context.Context, key string, out runtime.Object, preconditions *Preconditions, validateDeletion ValidateObjectFunc) error
	Watch(ctx context.Context, key string, resourceVersion string, p SelectionPredicate) (watch.Interface, error)
	WatchList(ctx context.Context, key string, resourceVersion string, p SelectionPredicate) (watch.Interface, error)
	Get(ctx context.Context, key string, resourceVersion string, objPtr runtime.Object, ignoreNotFound bool) error
	GetToList(ctx context.Context, key string, resourceVersion string, p SelectionPredicate, listObj runtime.Object) error
	List(ctx context.Context, key string, resourceVersion string, p SelectionPredicate, listObj runtime.Object) error
	GuaranteedUpdate(
		ctx context.Context, key string, ptrToType runtime.Object, ignoreNotFound bool,
		precondtions *Preconditions, tryUpdate UpdateFunc, suggestion ...runtime.Object) error
	Count(key string) (int64, error)
}
// 这个函数用于一次性销毁任何Create()创建的、当前Storage使用的对象
type DestroyFunc func()</pre>
<p>在这里我们可以注意到storagebackend.Config和Etcd是有耦合的，因此想支持其它存储后端，需要在更早的节点介入。</p>
<p>genericregistry.Store还包含了三个Strategy字段：</p>
<pre class="crayon-plain-tag">type Store struct {
	// ...
	CreateStrategy rest.RESTCreateStrategy
	UpdateStrategy rest.RESTUpdateStrategy
	DeleteStrategy rest.RESTDeleteStrategy
	// ...
}

type RESTCreateStrategy interface {
	runtime.ObjectTyper
	// 用于生成名称
	names.NameGenerator
	// 对象是否必须在命名空间中
	NamespaceScoped() bool
	// 在Validate、Canonicalize之前调用，进行对象的normalize
	// 例如删除不需要持久化的字段、对顺序不敏感的列表字段进行重新排序
	// 不得移除这样的字段：它不能通过校验
	PrepareForCreate(ctx context.Context, obj runtime.Object)
	// 校验，在对象的默认字段被填充后调用
	Validate(ctx context.Context, obj runtime.Object) field.ErrorList
	// 在校验之后，持久化之前调用。正规化对象，通常实现为类型检查，或空函数
	Canonicalize(obj runtime.Object)
}

type RESTUpdateStrategy interface {
	runtime.ObjectTyper
	NamespaceScoped() bool
	// 对象是否可以被PUT请求创建
	AllowCreateOnUpdate() bool
	// 准备更新
	PrepareForUpdate(ctx context.Context, obj, old runtime.Object)
	// 校验
	ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList
	// 正规化
	Canonicalize(obj runtime.Object)
	// 当对象上没有指定版本时，是否允许无条件的更新，也就是不管最新的资源版本（禁用乐观并发控制）
	AllowUnconditionalUpdate() bool
}

type RESTDeleteStrategy interface {
	runtime.ObjectTyper
}

type ObjectTyper interface {
	// 得到对象可能的GVK信息
	ObjectKinds(Object) ([]schema.GroupVersionKind, bool, error)
	// Scheme是否支持指定的GVK
	Recognizes(gvk schema.GroupVersionKind) bool
}</pre>
<p>可以看到，策略能够影响增删改的行为，它能够生成对象名称，校验对象合法性，甚至修改对象。 我们看一下sample-apiserver提供的策略实现：</p>
<pre class="crayon-plain-tag">func NewStrategy(typer runtime.ObjectTyper) flunderStrategy {
	// 简单命名策略：返回请求的basename外加5位字母数字的随即后缀
	return flunderStrategy{typer, names.SimpleNameGenerator}
}

type flunderStrategy struct {
	runtime.ObjectTyper
	names.NameGenerator
}

func (flunderStrategy) NamespaceScoped() bool {
	return true
}

func (flunderStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
}

func (flunderStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
}

func (flunderStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
	flunder := obj.(*wardle.Flunder)
	return validation.ValidateFlunder(flunder)
}

func (flunderStrategy) AllowCreateOnUpdate() bool {
	return false
}

func (flunderStrategy) AllowUnconditionalUpdate() bool {
	return false
}

func (flunderStrategy) Canonicalize(obj runtime.Object) {
}

func (flunderStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
	return field.ErrorList{}
}



package validation

// ValidateFlunder validates a Flunder.
func ValidateFlunder(f *wardle.Flunder) field.ErrorList {
	allErrs := field.ErrorList{}

	allErrs = append(allErrs, ValidateFlunderSpec(&amp;f.Spec, field.NewPath("spec"))...)

	return allErrs
}

// ValidateFlunderSpec validates a FlunderSpec.
func ValidateFlunderSpec(s *wardle.FlunderSpec, fldPath *field.Path) field.ErrorList {
	allErrs := field.ErrorList{}

	if len(s.FlunderReference) != 0 &amp;&amp; len(s.FischerReference) != 0 {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("fischerReference"), s.FischerReference, "cannot be set with flunderReference at the same time"))
	} else if len(s.FlunderReference) != 0 &amp;&amp; s.ReferenceType != wardle.FlunderReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("flunderReference"), s.FlunderReference, "cannot be set if referenceType is not Flunder"))
	} else if len(s.FischerReference) != 0 &amp;&amp; s.ReferenceType != wardle.FischerReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("fischerReference"), s.FischerReference, "cannot be set if referenceType is not Fischer"))
	} else if len(s.FischerReference) == 0 &amp;&amp; s.ReferenceType == wardle.FischerReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("fischerReference"), s.FischerReference, "cannot be empty if referenceType is Fischer"))
	} else if len(s.FlunderReference) == 0 &amp;&amp; s.ReferenceType == wardle.FlunderReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("flunderReference"), s.FlunderReference, "cannot be empty if referenceType is Flunder"))
	}

	if len(s.ReferenceType) != 0 &amp;&amp; s.ReferenceType != wardle.FischerReferenceType &amp;&amp; s.ReferenceType != wardle.FlunderReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("referenceType"), s.ReferenceType, "must be Flunder or Fischer"))
	}

	return allErrs
}</pre>
<p>分析到这里，我们可以猜测到Create请求被genericregistry.Store处理的过程：</p>
<ol>
<li>读取请求体，调用NewFunc反串行化为runtime.Obejct</li>
<li>调用PredicateFunc判断是否能够处理该对象</li>
<li>调用CreateStrategy，校验、正规化对象</li>
<li>调用RESTOptions，存储到Etcd</li>
</ol>
<p>那么，请求是如何传递过来的，上述处理的细节又是怎样的？上文中我们已经定位到关键代码路径，通过断点很容易跟踪到完整处理流程。</p>
<p>在上文分析的GenericAPIServer实例过程中，它的Handler字段是这样创建的：</p>
<pre class="crayon-plain-tag">apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler())

// 处理器链的构建器，注意出入参类型一样。这是因为处理器链是一层层包裹的，而不是链表那样的结构
type HandlerChainBuilderFn func(apiHandler http.Handler) http.Handler

func NewAPIServerHandler(name string, s runtime.NegotiatedSerializer, 
		handlerChainBuilder HandlerChainBuilderFn, notFoundHandler http.Handler) *APIServerHandler {

	// 配置go-restful，go-restful是一个构建REST风格WebService的框架
	nonGoRestfulMux := mux.NewPathRecorderMux(name)
	if notFoundHandler != nil {
		nonGoRestfulMux.NotFoundHandler(notFoundHandler)
	}

	// 容器，是一组WebService的集合
	gorestfulContainer := restful.NewContainer()
	// 容器包含一个用户HTTP请求多路复用的ServeMux
	gorestfulContainer.ServeMux = http.NewServeMux()
	// 路由器
	gorestfulContainer.Router(restful.CurlyRouter{}) // e.g. for proxy/{kind}/{name}/{*}
	// panic处理器
	gorestfulContainer.RecoverHandler(func(panicReason interface{}, httpWriter http.ResponseWriter) {
		logStackOnRecover(s, panicReason, httpWriter)
	})
	// 错误处理器
	gorestfulContainer.ServiceErrorHandler(func(serviceErr restful.ServiceError, request *restful.Request, response *restful.Response) {
		serviceErrorHandler(s, serviceErr, request, response)
	})

	director := director{
		name:               name,
		goRestfulContainer: gorestfulContainer,
		nonGoRestfulMux:    nonGoRestfulMux,
	}

	return &amp;APIServerHandler{
		// 构建处理器链，                 注意传入的director
		FullHandlerChain:   handlerChainBuilder(director),
		GoRestfulContainer: gorestfulContainer,
		NonGoRestfulMux:    nonGoRestfulMux,
		Director:           director,
	}
}


type APIServerHandler struct {
	// 处理器链，接口是http包中标准的：
	// type Handler interface {
	// 	ServeHTTP(ResponseWriter, *Request)
	// }
	// 它组织一系列的过滤器，并在请求通过过滤器链后，调用Director
	FullHandlerChain http.Handler
	// 所有注册的API由此容器处理
	// InstallAPIs使用该字段，其他server不应该直接访问
	GoRestfulContainer *restful.Container
	// 链中最后一个处理器。这个类型包装一个mux对象，并且记录下注册了哪些URL路径
	NonGoRestfulMux *mux.PathRecorderMux

	// Director用于处理fall through和proxy
	Director http.Handler
}</pre>
<p> 这个Handler实现了http.Handler，简单的把请求委托给FullHandlerChain处理：</p>
<pre class="crayon-plain-tag">func (a *APIServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	a.FullHandlerChain.ServeHTTP(w, r)
}</pre>
<p>上面这个函数，就是所有HTTP请求处理的入口点。</p>
<p>FullHandlerChain是handlerChainBuilder()调用得到的：</p>
<pre class="crayon-plain-tag">var c completedConfig
handlerChainBuilder := func(handler http.Handler) http.Handler {
	return c.BuildHandlerChainFunc(handler, c.Config)
}
apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler())</pre>
<p>completedConfig.BuildHandlerChainFunc则来自于它内嵌的Config：</p>
<pre class="crayon-plain-tag">serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs)
func NewRecommendedConfig(codecs serializer.CodecFactory) *RecommendedConfig {
	return &amp;RecommendedConfig{
		Config: *NewConfig(codecs),
	}
}
func NewConfig(codecs serializer.CodecFactory) *Config {
	defaultHealthChecks := []healthz.HealthChecker{healthz.PingHealthz, healthz.LogHealthz}
	return &amp;Config{
		Serializer:                  codecs,
		BuildHandlerChainFunc:       DefaultBuildHandlerChain,
	// ...
}

func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler {
	// 最内层：传入的apiHandler，也就是上文中的director
	// 访问控制
	handler := genericapifilters.WithAuthorization(apiHandler, c.Authorization.Authorizer, c.Serializer)
	// 访问速率控制
	if c.FlowControl != nil {
		handler = genericfilters.WithPriorityAndFairness(handler, c.LongRunningFunc, c.FlowControl)
	} else {
		handler = genericfilters.WithMaxInFlightLimit(handler, c.MaxRequestsInFlight, c.MaxMutatingRequestsInFlight, c.LongRunningFunc)
	}
	// 身份扮演
	handler = genericapifilters.WithImpersonation(handler, c.Authorization.Authorizer, c.Serializer)
	// 审计
	handler = genericapifilters.WithAudit(handler, c.AuditBackend, c.AuditPolicyChecker, c.LongRunningFunc)
	// 处理身份认证失败
	failedHandler := genericapifilters.Unauthorized(c.Serializer, c.Authentication.SupportsBasicAuth)
	failedHandler = genericapifilters.WithFailedAuthenticationAudit(failedHandler, c.AuditBackend, c.AuditPolicyChecker)
	// 身份验证
	handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences)
	// 处理CORS请求
	handler = genericfilters.WithCORS(handler, c.CorsAllowedOriginList, nil, nil, nil, "true")
	// 超时处理
	handler = genericfilters.WithTimeoutForNonLongRunningRequests(handler, c.LongRunningFunc, c.RequestTimeout)
	// 所有长时间运行请求被添加到等待组，用于优雅关机
	handler = genericfilters.WithWaitGroup(handler, c.LongRunningFunc, c.HandlerChainWaitGroup)
	// 将RequestInfo附加到Context对象
	handler = genericapifilters.WithRequestInfo(handler, c.RequestInfoResolver)
	// HTTP Goway处理
	if c.SecureServing != nil &amp;&amp; !c.SecureServing.DisableHTTP2 &amp;&amp; c.GoawayChance &gt; 0 {
		handler = genericfilters.WithProbabilisticGoaway(handler, c.GoawayChance)
	}
	// 设置Cache-Control头为"no-cache, private"，因为所有server被authn/authz保护
	handler = genericapifilters.WithCacheControl(handler)
	// 崩溃恢复
	handler = genericfilters.WithPanicRecovery(handler)
	return handler
}</pre>
<p>我们可以清楚的看到默认的处理器链包含的大量过滤器，以及处理器是一层层包裹而非链表结构。</p>
<p>最后，我们来从头跟踪一下请求处理过程，首先看看处理器链中的过滤器们：</p>
<pre class="crayon-plain-tag">func (a *APIServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	a.FullHandlerChain.ServeHTTP(w, r)
}
// FullHandlerChain是这个类型
type HandlerFunc func(ResponseWriter, *Request)
// 巧妙的设计，实现了
// type Handler interface {
//	ServeHTTP(ResponseWriter, *Request)
// }
// 接口，但是这个实现，直接委托给类型对应的函数
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

// 进入处理器链的最外层
func withPanicRecovery(handler http.Handler, crashHandler func(http.ResponseWriter, *http.Request, interface{})) http.Handler {
	handler = httplog.WithLogging(handler, httplog.DefaultStacktracePred)
	//                      处理函数
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		defer runtime.HandleCrash(func(err interface{}) {
			crashHandler(w, req, err)
		})

		// 转发给内层处理器，内层处理器通过闭包捕获
		handler.ServeHTTP(w, req)
	})
}

// 省略若干中间的处理器...

// 这个处理器值得注意，它将请求信息存放到context中，内层处理器可以直接使用这些信息
// 信息包括：
type RequestInfo struct {
	// 是否针对资源/子资源的请求
	IsResourceRequest bool
	// URL的路径部分
	Path string
	// 小写的HTTP动词
	Verb string
	// API前缀
	APIPrefix  string
	// API组
	APIGroup   string
	// API版本
	APIVersion string
	// 命名空间
	Namespace  string
	// 资源类型名，通常是小写的复数形式，而不是Kind
	Resource string
	// 请求的子资源，子资源是scoped to父资源的另外一个资源，可以具有不同的Kind
	// 例如 /pods对应资源"pods"，对应Kind为"Pod"
	//      /pods/foo/status对应资源"pods"，对应子资源 "status"，对应Kind为"Pod"
	// 然而 /pods/foo/binding对应资源"pods"，对应子资源 "binding", 而对应Kind为"Binding"
	Subresource string
	// 对于某些资源，名字是空的。如果请求指示一个名字（不在请求体中）则填写在该字段
	// Parts are the path parts for the request, always starting with /{resource}/{name}
	Parts []string
}
func WithRequestInfo(handler http.Handler, resolver request.RequestInfoResolver) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		ctx := req.Context()
		info, err := resolver.NewRequestInfo(req)
		if err != nil {
			responsewriters.InternalError(w, req, fmt.Errorf("failed to create RequestInfo: %v", err))
			return
		}

		req = req.WithContext(request.WithRequestInfo(ctx, info))

		handler.ServeHTTP(w, req)
	})
}

// 省略若干中间的处理器...</pre>
<p>遍历所有过滤器后，来到处理器链的最后一环，也就是director。从名字上可以看到，它在整体上负责请求的分发：</p>
<pre class="crayon-plain-tag">func (d director) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	path := req.URL.Path

	// 遍历已经注册的所有WebService，看看有没有负责当前URL路径的
	for _, ws := range d.goRestfulContainer.RegisteredWebServices() {
		switch {
		case ws.RootPath() == "/apis":
			// 如果URL路径是 /apis或/apis/需要特殊处理。通常情况下，应该交由nonGoRestfulMux
			// 但是在启用descovery的情况下，需要直接由goRestfulContainer处理
			if path == "/apis" || path == "/apis/" {
				klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath())
				d.goRestfulContainer.Dispatch(w, req)
				return
			}
		// 如果前缀匹配
		case strings.HasPrefix(path, ws.RootPath()):
			if len(path) == len(ws.RootPath()) || path[len(ws.RootPath())] == '/' {
				klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath())
				// don't use servemux here because gorestful servemuxes get messed up when removing webservices
				// TODO fix gorestful, remove TPRs, or stop using gorestful
				d.goRestfulContainer.Dispatch(w, req)
				return
			}
		}
	}

	// 无法找到匹配，跳过 gorestful 容器
	d.nonGoRestfulMux.ServeHTTP(w, req)
}</pre>
<p> 对于非API资源请求，由PathRecorderMux处理：</p>
<pre class="crayon-plain-tag">func (m *PathRecorderMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	m.mux.Load().(*pathHandler).ServeHTTP(w, r)
}
func (h *pathHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// pathToHandler 记录了所有精确匹配路径的处理器
	//   0 = /healthz/etcd -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   1 = /livez/etcd -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   2 = /metrics -&gt; k8s.io/apiserver/pkg/server/routes.MetricsWithReset.Install.func1
	//   3 = /readyz/shutdown -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   4 = /readyz/ping -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   5 = /debug/pprof/profile -&gt; net/http/pprof.Profile
	//   6 = /healthz/log -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   7 = /livez/log -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   8 = /healthz/ping -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   9 = / -&gt; 
	//   10 = /healthz -&gt; k8s.io/apiserver/pkg/endpoints/metrics.InstrumentHandlerFunc.func1
	//   11 = /debug/flags -&gt; k8s.io/apiserver/pkg/server/routes.DebugFlags.Index-fm
	//   12 = /livez/ping -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   13 = /readyz/log -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   14 = /debug/pprof -&gt; k8s.io/apiserver/pkg/server/routes.redirectTo.func1
	//   15 = /debug/pprof/trace -&gt; net/http/pprof.Trace
	//   16 = /debug/flags/v -&gt; k8s.io/apiserver/pkg/server/routes.StringFlagPutHandler.func1
	//   17 = /readyz/poststarthook/start-sample-server-informers -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   18 = /livez/poststarthook/start-sample-server-informers -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   19 = /openapi/v2 -&gt; github.com/NYTimes/gziphandler.NewGzipLevelAndMinSize.func1.1
	//   20 = /healthz/poststarthook/start-sample-server-informers -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   21 = /readyz/etcd -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   22 = /index.html -&gt; 
	//   23 = /debug/pprof/symbol -&gt; net/http/pprof.Symbol
	//   24 = /livez -&gt; k8s.io/apiserver/pkg/endpoints/metrics.InstrumentHandlerFunc.func1
	//   25 = /readyz -&gt; k8s.io/apiserver/pkg/endpoints/metrics.InstrumentHandlerFunc.func1
	if exactHandler, ok := h.pathToHandler[r.URL.Path]; ok {
		klog.V(5).Infof("%v: %q satisfied by exact match", h.muxName, r.URL.Path)
		exactHandler.ServeHTTP(w, r)
		return
	}

	// 前缀匹配路径的处理器，默认有/debug/flags/ 和 /debug/pprof/
	for _, prefixHandler := range h.prefixHandlers {
		if strings.HasPrefix(r.URL.Path, prefixHandler.prefix) {
			klog.V(5).Infof("%v: %q satisfied by prefix %v", h.muxName, r.URL.Path, prefixHandler.prefix)
			prefixHandler.handler.ServeHTTP(w, r)
			return
		}
	}

	// 找不到处理器，404
	klog.V(5).Infof("%v: %q satisfied by NotFoundHandler", h.muxName, r.URL.Path)
	h.notFoundHandler.ServeHTTP(w, r)
}</pre>
<p>对于API资源请求，例如路径 /apis/wardle.example.com/v1beta1，则由go-restful处理。go-restful框架内部处理细节我们就忽略了，我们主要深入探讨上文中提到的<a href="#InstallAPIGroup">安装APIGroup</a>，每种资源的go-restful Handler都是由它注册的：</p>
<pre class="crayon-plain-tag">func (s *GenericAPIServer) InstallAPIGroup(apiGroupInfo *APIGroupInfo) error {
	return s.InstallAPIGroups(apiGroupInfo)
}
func (s *GenericAPIServer) InstallAPIGroups(apiGroupInfos ...*APIGroupInfo) error {
	// 遍历API组
	for _, apiGroupInfo := range apiGroupInfos {
		//          安装API资源        常量 /apis
		if err := s.installAPIResources(APIGroupPrefix, apiGroupInfo, openAPIModels); err != nil {
			return fmt.Errorf("unable to install api resources: %v", err)
		}

		// setup discovery
		// Install the version handler.
		// Add a handler at /apis/&lt;groupName&gt; to enumerate all versions supported by this group.
		apiVersionsForDiscovery := []metav1.GroupVersionForDiscovery{}
		for _, groupVersion := range apiGroupInfo.PrioritizedVersions {
			// Check the config to make sure that we elide versions that don't have any resources
			if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 {
				continue
			}
			apiVersionsForDiscovery = append(apiVersionsForDiscovery, metav1.GroupVersionForDiscovery{
				GroupVersion: groupVersion.String(),
				Version:      groupVersion.Version,
			})
		}
		preferredVersionForDiscovery := metav1.GroupVersionForDiscovery{
			GroupVersion: apiGroupInfo.PrioritizedVersions[0].String(),
			Version:      apiGroupInfo.PrioritizedVersions[0].Version,
		}
		apiGroup := metav1.APIGroup{
			Name:             apiGroupInfo.PrioritizedVersions[0].Group,
			Versions:         apiVersionsForDiscovery,
			PreferredVersion: preferredVersionForDiscovery,
		}

		s.DiscoveryGroupManager.AddGroup(apiGroup)
		s.Handler.GoRestfulContainer.Add(discovery.NewAPIGroupHandler(s.Serializer, apiGroup).WebService())
	}
	return nil
}

// 安装API资源
func (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *APIGroupInfo, openAPIModels openapiproto.Models) error {
	// 遍历版本
	for _, groupVersion := range apiGroupInfo.PrioritizedVersions {
		// 跳过没有资源的组
		if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 {
			klog.Warningf("Skipping API %v because it has no resources.", groupVersion)
			continue
		}

		apiGroupVersion := s.getAPIGroupVersion(apiGroupInfo, groupVersion, apiPrefix)
		if apiGroupInfo.OptionsExternalVersion != nil {
			apiGroupVersion.OptionsExternalVersion = apiGroupInfo.OptionsExternalVersion
		}
		apiGroupVersion.OpenAPIModels = openAPIModels
		apiGroupVersion.MaxRequestBodyBytes = s.maxRequestBodyBytes
		// 安装为go-restful的Handler
		if err := apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer); err != nil {
			return fmt.Errorf("unable to setup API %v: %v", apiGroupInfo, err)
		}
	}

	return nil
}

// 注册一系列REST Handler（ storage, watch, proxy, redirect）到restful容器
func (g *APIGroupVersion) InstallREST(container *restful.Container) error {
	// 例如/apis/wardle.example.com/v1beta1
	prefix := path.Join(g.Root, g.GroupVersion.Group, g.GroupVersion.Version)
	installer := &amp;APIInstaller{
		group:             g,
		prefix:            prefix,
		minRequestTimeout: g.MinRequestTimeout,
	}
	// 执行API资源处理器的安装
	apiResources, ws, registrationErrors := installer.Install()
	// 执行资源发现处理器的安装
	versionDiscoveryHandler := discovery.NewAPIVersionHandler(g.Serializer, g.GroupVersion, staticLister{apiResources})
	versionDiscoveryHandler.AddToWebService(ws)
	// 添加WebService到容器
	container.Add(ws)
	return utilerrors.NewAggregate(registrationErrors)
}
func (a *APIInstaller) Install() ([]metav1.APIResource, *restful.WebService, []error) {
	var apiResources []metav1.APIResource
	var errors []error
	// 创建一个针对特定GV的WebService
	ws := a.newWebService()

	// Register the paths in a deterministic (sorted) order to get a deterministic swagger spec.
	paths := make([]string, len(a.group.Storage))
	var i int = 0
	for path := range a.group.Storage {
		paths[i] = path
		i++
	}
	sort.Strings(paths)
	for _, path := range paths {
		// 遍历资源，注册处理器，例如flunders
		apiResource, err := a.registerResourceHandlers(path, a.group.Storage[path], ws)
		if err != nil {
			errors = append(errors, fmt.Errorf("error in registering resource: %s, %v", path, err))
		}
		if apiResource != nil {
			apiResources = append(apiResources, *apiResource)
		}
	}
	return apiResources, ws, errors
}
func (a *APIInstaller) newWebService() *restful.WebService {
	ws := new(restful.WebService)
	// 此WebService的路径模板
	ws.Path(a.prefix)
	// a.prefix contains "prefix/group/version"
	ws.Doc("API at " + a.prefix)
	// 向后兼容的考虑，支持没有MIME类型
	ws.Consumes("*/*")
	// 根据API组使用的编解码器来确定响应支持的MIME类型
	//   0 = {string} "application/json"
	//   1 = {string} "application/yaml"
	//   2 = {string} "application/vnd.kubernetes.protobuf"
	mediaTypes, streamMediaTypes := negotiation.MediaTypesForSerializer(a.group.Serializer)
	ws.Produces(append(mediaTypes, streamMediaTypes...)...)
	// 例如 wardle.example.com/v1beta1
	ws.ApiVersion(a.group.GroupVersion.String())
	return ws
}
// 注册资源处理器，过于冗长，仅仅贴片段
func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storage, ws *restful.WebService) (*metav1.APIResource, error) {
	// ...
	// creater就是Handler的核心逻辑所在，它来自rest.Storage，对接Etcd
	creater, isCreater := storage.(rest.Creater)
	// ...
	actions = appendIf(actions, action{"POST", resourcePath, resourceParams, namer, false}, isCreater)
	switch action.Verb {
		case "POST": 
			handler = restfulCreateResource(creater, reqScope, admit)

			route := ws.POST(action.Path).To(handler).
				Doc(doc).
				Param(ws.QueryParameter("pretty", "If 'true', then the output is pretty printed.")).
				Operation("create"+namespaced+kind+strings.Title(subresource)+operationSuffix).
				Produces(append(storageMeta.ProducesMIMETypes(action.Verb), mediaTypes...)...).
				Returns(http.StatusOK, "OK", producedObject).
				// TODO: in some cases, the API may return a v1.Status instead of the versioned object
				// but currently go-restful can't handle multiple different objects being returned.
				Returns(http.StatusCreated, "Created", producedObject).
				Returns(http.StatusAccepted, "Accepted", producedObject).
				Reads(defaultVersionedObject).
				Writes(producedObject)
			if err := AddObjectParams(ws, route, versionedCreateOptions); err != nil {
				return nil, err
			}
			addParams(route, action.Params)
			routes = append(routes, route)
	// ...
}
func restfulCreateResource(r rest.Creater, scope handlers.RequestScope, admit admission.Interface) restful.RouteFunction {
	return func(req *restful.Request, res *restful.Response) {
		handlers.CreateResource(r, &amp;scope, admit)(res.ResponseWriter, req.Request)
	}
}
func CreateResource(r rest.Creater, scope *RequestScope, admission admission.Interface) http.HandlerFunc {
	return createHandler(&amp;namedCreaterAdapter{r}, scope, admission, false)
}
// 创建资源处理器核心
func createHandler(r rest.NamedCreater, scope *RequestScope, admit admission.Interface, includeName bool) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		// For performance tracking purposes.
		trace := utiltrace.New("Create", utiltrace.Field{Key: "url", Value: req.URL.Path}, utiltrace.Field{Key: "user-agent", Value: &amp;lazyTruncatedUserAgent{req}}, utiltrace.Field{Key: "client", Value: &amp;lazyClientIP{req}})
		defer trace.LogIfLong(500 * time.Millisecond)
		// 处理Dryrun
		if isDryRun(req.URL) &amp;&amp; !utilfeature.DefaultFeatureGate.Enabled(features.DryRun) {
			scope.err(errors.NewBadRequest("the dryRun feature is disabled"), w, req)
			return
		}

		// TODO: we either want to remove timeout or document it (if we document, move timeout out of this function and declare it in api_installer)
		timeout := parseTimeout(req.URL.Query().Get("timeout"))
		// 命名空间和资源名字处理
		namespace, name, err := scope.Namer.Name(req)
		if err != nil {
			if includeName {
				// name was required, return
				scope.err(err, w, req)
				return
			}

			// otherwise attempt to look up the namespace
			namespace, err = scope.Namer.Namespace(req)
			if err != nil {
				scope.err(err, w, req)
				return
			}
		}

		ctx, cancel := context.WithTimeout(req.Context(), timeout)
		defer cancel()
		ctx = request.WithNamespace(ctx, namespace)
		// 协商输出MIME类型
		outputMediaType, _, err := negotiation.NegotiateOutputMediaType(req, scope.Serializer, scope)
		if err != nil {
			scope.err(err, w, req)
			return
		}

		gv := scope.Kind.GroupVersion()
		// 协商输入如何反串行化
		s, err := negotiation.NegotiateInputSerializer(req, false, scope.Serializer)
		if err != nil {
			scope.err(err, w, req)
			return
		}
		// 从串行化器得到能将请求解码为特定版本的解码器
		decoder := scope.Serializer.DecoderToVersion(s.Serializer, scope.HubGroupVersion)

		// 读取请求体
		body, err := limitedReadBody(req, scope.MaxRequestBodyBytes)
		if err != nil {
			scope.err(err, w, req)
			return
		}

		options := &amp;metav1.CreateOptions{}
		values := req.URL.Query()
		if err := metainternalversionscheme.ParameterCodec.DecodeParameters(values, scope.MetaGroupVersion, options); err != nil {
			err = errors.NewBadRequest(err.Error())
			scope.err(err, w, req)
			return
		}
		if errs := validation.ValidateCreateOptions(options); len(errs) &gt; 0 {
			err := errors.NewInvalid(schema.GroupKind{Group: metav1.GroupName, Kind: "CreateOptions"}, "", errs)
			scope.err(err, w, req)
			return
		}
		options.TypeMeta.SetGroupVersionKind(metav1.SchemeGroupVersion.WithKind("CreateOptions"))

		defaultGVK := scope.Kind
		// 实例化资源的Go结构
		original := r.New()
		trace.Step("About to convert to expected version")
		// 将请求体解码到Go结构中
		obj, gvk, err := decoder.Decode(body, &amp;defaultGVK, original)
		if err != nil {
			err = transformDecodeError(scope.Typer, err, original, gvk, body)
			scope.err(err, w, req)
			return
		}
		if gvk.GroupVersion() != gv {
			err = errors.NewBadRequest(fmt.Sprintf("the API version in the data (%s) does not match the expected API version (%v)", gvk.GroupVersion().String(), gv.String()))
			scope.err(err, w, req)
			return
		}
		trace.Step("Conversion done")

		// 审计和Admission控制
		ae := request.AuditEventFrom(ctx)
		admit = admission.WithAudit(admit, ae)
		audit.LogRequestObject(ae, obj, scope.Resource, scope.Subresource, scope.Serializer)

		userInfo, _ := request.UserFrom(ctx)

		// On create, get name from new object if unset
		if len(name) == 0 {
			_, name, _ = scope.Namer.ObjectName(obj)
		}

		trace.Step("About to store object in database")
		admissionAttributes := admission.NewAttributesRecord(obj, nil, scope.Kind, namespace, name, scope.Resource, scope.Subresource, admission.Create, options, dryrun.IsDryRun(options.DryRun), userInfo)
		// 构建入库函数
		requestFunc := func() (runtime.Object, error) {
			// 调用rest.Storage进行入库
			return r.Create(
				ctx,
				name,
				obj,
				rest.AdmissionToValidateObjectFunc(admit, admissionAttributes, scope),
				options,
			)
		}
		// finishRequest能够异步执行回调，并且处理响应返回的错误
		result, err := finishRequest(timeout, func() (runtime.Object, error) {
			if scope.FieldManager != nil {
				liveObj, err := scope.Creater.New(scope.Kind)
				if err != nil {
					return nil, fmt.Errorf("failed to create new object (Create for %v): %v", scope.Kind, err)
				}
				obj = scope.FieldManager.UpdateNoErrors(liveObj, obj, managerOrUserAgent(options.FieldManager, req.UserAgent()))
			}
			if mutatingAdmission, ok := admit.(admission.MutationInterface); ok &amp;&amp; mutatingAdmission.Handles(admission.Create) {
				if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil {
					return nil, err
				}
			}
			result, err := requestFunc()
			// If the object wasn't committed to storage because it's serialized size was too large,
			// it is safe to remove managedFields (which can be large) and try again.
			if isTooLargeError(err) {
				if accessor, accessorErr := meta.Accessor(obj); accessorErr == nil {
					accessor.SetManagedFields(nil)
					result, err = requestFunc()
				}
			}
			return result, err
		})
		if err != nil {
			scope.err(err, w, req)
			return
		}
		trace.Step("Object stored in database")

		code := http.StatusCreated
		status, ok := result.(*metav1.Status)
		if ok &amp;&amp; err == nil &amp;&amp; status.Code == 0 {
			status.Code = int32(code)
		}

		transformResponseObject(ctx, scope, trace, req, w, code, outputMediaType, result)
	}
}</pre>
<p>回顾一下请求处理的整体逻辑：</p>
<ol style="list-style-type: undefined;">
<li>GenericAPIServer.Handler就是http.Handler，可以注册给任何HTTP服务器。因此我们想绕开HTTPS的限制应该很容易</li>
<li>GenericAPIServer.Handler是一个层层包裹的处理器链，外层是一系列过滤器，最里面是director</li>
<li>director负责整体的请求分发：
<ol>
<li>对于非API资源请求，分发给nonGoRestfulMux。我们可以利用这个扩展点，扩展任意形式的HTTP接口</li>
<li>对于API资源请求，分发给gorestfulContainer</li>
</ol>
</li>
<li>在GenericAPIServer.InstallAPIGroup中，所有支持的API资源的所有版本，都注册为go-restful的一个WebService</li>
<li>这些WebService的逻辑包括（依赖于rest.Storage）：
<ol>
<li>将请求解码为资源对应的Go结构</li>
<li>将Go结构编码为JSON</li>
<li>将JSON存储到Etcd</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">sample-apiserver小结</span></div>
<p>通过对sample-apiserver的代码分析，我们理解了构建自己的API Server的各种关键要素。</p>
<p>APIServer的核心类型是<pre class="crayon-plain-tag">GenericAPIServer</pre>，它是由<pre class="crayon-plain-tag">genericapiserver.CompletedConfig</pre>的<pre class="crayon-plain-tag">New()</pre>方法生成的。后者则是<pre class="crayon-plain-tag">genericapiserver.RecommendedConfig</pre>的<pre class="crayon-plain-tag">Complete()</pre>方法生成的。而RecommendedConfig又是从<pre class="crayon-plain-tag">genericoptions.RecommendedOptions</pre>得到的。sample-apiserver对Config、Option、Server等对象都做了一层包装，我们不关注这些wrapper。</p>
<p>RecommendedOptions对应了用户提供的各类选项（外加所谓推荐选项，降低使用时的复杂度），例如Etcd地址、Etcd存储前缀、APIServer的基本信息等等。调用RecommendedOptions的<pre class="crayon-plain-tag">ApplyTo</pre>方法，会根据选项，推导出APIServer所需的，完整的配置信息。在这个方法中，甚至会进行自签名证书等重操作，而不是简单的将信息从Option复制给Config。RecommendedOptions会依次调用它的各个字段的ApplyTo方法，从而推导出RecommendedConfig的各个字段。</p>
<p>RecommendedConfig的Complete方法，再一次进行配置信息的推导，主要牵涉到OpenAPI相关的配置。</p>
<p>CompletedConfig的New方法实例化GenericAPIServer，这一步最关键的逻辑是安装API组。API组定义了如何实现GroupVersion中API的增删改查，它将GroupVersion的每种资源映射到registry.REST，后者具有处理REST风格请求的能力，并（默认）存储到Etcd。</p>
<p>GenericAPIServer提供了一些钩子来处理Admission控制器的注册、初始化。以及另外一些钩子来对API Server的生命周期事件做出响应。</p>
<div class="blog_h2"><span class="graybg">sample-apiserver改造</span></div>
<div class="blog_h3"><span class="graybg">解除对kube-apiserver的依赖</span></div>
<p>想实现sample-apiserver的独立运行，RecommendedOptions有三个字段必须处理：Authentication、Authorization、CoreAPI，它们都隐含了对主kube-apiserver的依赖。</p>
<p>Authentication依赖主kube-apiserver，是因为它需要访问TokenReviewInterface，访问kube-system中的ConfigMap。Authorization依赖主kube-apiserver，是因为它需要访问SubjectAccessReviewInterface。CoreAPI则是直接为Config提供了两个字段：ClientConfig、SharedInformerFactory。</p>
<p>将这些字段置空，可以解除对主kube-apiserver的依赖。这样启动sample-apiserver时就不需要提供这三个命令行选项：</p>
<p style="padding-left: 30px;">--kubeconfig=/home/alex/.kube/config<br />--authentication-kubeconfig=/home/alex/.kube/config<br />--authorization-kubeconfig=/home/alex/.kube/config</p>
<p>但是，置空CoreAPI会导致报错：admission depends on a Kubernetes core API shared informer, it cannot be nil。这提示我们不能在不依赖主kube-apiserver的情况下使用Admission控制器这一特性，需要将Admission也置空：</p>
<pre class="crayon-plain-tag">o.RecommendedOptions.Authentication = nil
o.RecommendedOptions.Authorization = nil
o.RecommendedOptions.CoreAPI = nil
o.RecommendedOptions.Admission = nil</pre>
<p>清空上述四个字段后，sample-server还会在PostStart钩子中崩溃：</p>
<pre class="crayon-plain-tag">// panic，这个SharedInformerFactory是CoreAPI选项提供的
config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
// 仅仅Admission控制器使用该InformerFactory
o.SharedInformerFactory.Start(context.StopCh)</pre>
<p>由于注释中给出的原因，这个PostStart钩子已经没有意义，删除即可正常启动服务器。</p>
<div class="blog_h3"><span class="graybg">使用HTTP而非HTTPS</span></div>
<p>GenericAPIServer的<pre class="crayon-plain-tag">Run</pre>方法的默认实现，是调用<pre class="crayon-plain-tag">s.SecureServingInfo.Serve</pre>，因而强制使用HTTPS：</p>
<pre class="crayon-plain-tag">stoppedCh, err = s.SecureServingInfo.Serve(s.Handler, s.ShutdownTimeout, internalStopCh)</pre>
<p>不过，很明显的，我们只需要将s.Handler传递给自己的http.Server即可使用HTTP。</p>
<div class="blog_h3"><span class="graybg">添加任意HTTP接口</span></div>
<p>我们的迁移工具还提供一些非Kubernetes风格的HTTP接口，那么如何集成到APIServer中呢？</p>
<p>在启动服务器之前，可以直接访问<pre class="crayon-plain-tag">GenericAPIServer.Handler.NonGoRestfulMux</pre>，NonGoRestfulMux实现了：</p>
<pre class="crayon-plain-tag">type mux interface {
	Handle(pattern string, handler http.Handler)
}</pre>
<p>调用Handle即可为任何路径注册处理器。</p>
<div class="blog_h1"><span class="graybg">apiserver-builder-alpha</span></div>
<p>通过对sample-apiserver代码的分析，我们了解到构建自己的API Server有大量繁琐的工作需要做。幸运的是，K8S提供了<a href="https://github.com/kubernetes-sigs/apiserver-builder-alpha">apiserver-builder-alpha</a>简化这一过程。</p>
<p>apiserver-builder-alpha是一系列工具和库的集合，它能够：</p>
<ol>
<li>为新的API资源创建Go类型、控制器（基于controller-runtime）、测试用例、文档</li>
<li>构建、（独立、在Minikube或者在K8S中）运行扩展的控制平面组件（APIServer）</li>
<li>让在控制器中watch/update资源更简单</li>
<li>让创建新的资源/子资源更简单</li>
<li>提供大部分合理的默认值</li>
</ol>
<div class="blog_h2"><span class="graybg">安装</span></div>
<p>下载<a href="https://github.com/kubernetes-sigs/apiserver-builder-alpha/releases">压缩包</a>，解压并存放到目录，然后设置环境变量：</p>
<pre class="crayon-plain-tag">export PATH=$HOME/.local/kubernetes/apiserver-builder/bin/:$PATH</pre>
<div class="blog_h2"><span class="graybg">起步</span></div>
<p>你需要在$GOPATH下创建一个项目，创建一个boilerplate.go.txt文件。然后执行：</p>
<pre class="crayon-plain-tag">apiserver-boot init repo --domain cloud.gmem.cc</pre>
<p>该命令会生成如下目录结构：</p>
<pre class="crayon-plain-tag">.
├── bin
├── boilerplate.go.txt
├── BUILD.bazel
├── cmd
│   ├── apiserver
│   │   └── main.go
│   └── manager
│       └── main.go
├── go.mod
├── go.sum
├── pkg
│   ├── apis
│   │   └── doc.go
│   ├── controller
│   │   └── doc.go
│   ├── doc.go
│   ├── openapi
│   │   └── doc.go
│   └── webhook
│       └── webhook.go
├── PROJECT
└── WORKSPAC</pre>
<p> cmd/apiserver/main.go，是APIServer的入口点：</p>
<pre class="crayon-plain-tag">import "sigs.k8s.io/apiserver-builder-alpha/pkg/cmd/server"

func main() {
	version := "v0"

	err := server.StartApiServerWithOptions(&amp;server.StartOptions{
		EtcdPath:         "/registry/cloud.gmem.cc",
		//                无法运行，这个函数不存在
		Apis:             apis.GetAllApiBuilders(),
		Openapidefs:      openapi.GetOpenAPIDefinitions,
		Title:            "Api",
		Version:          version,

		// TweakConfigFuncs []func(apiServer *apiserver.Config) error
		// FlagConfigFuncs []func(*cobra.Command) error
	})
	if err != nil {
		panic(err)
	}
}</pre>
<p>可以看到apiserver-builder-alpha进行了一些封装。</p>
<p>执行下面的命令，添加一个新的API资源：</p>
<pre class="crayon-plain-tag">apiserver-boot create group version resource --group tcm --version v1 --kind Flunder</pre>
<p>最后，执行命令可以在本地启动APIServer：</p>
<pre class="crayon-plain-tag">apiserver-boot run local</pre>
<div class="blog_h2"><span class="graybg">问题 </span></div>
<p>本文提及的工具项目，最初架构是基于CRD，使用kubebuilder进行代码生成。kubebuilder的目录结构和apiserver-builder并不兼容。</p>
<p>此外apiserver-builder项目仍然处于Alpha阶段，并且经过测试，发现生成代码无法运行。为了避免不必要的麻烦，我们不打算使用它。</p>
<div class="blog_h1"><span class="graybg">编写APIServer</span></div>
<p>由于apiserver-builder不成熟，而且我们已经基于kubebuilder完成了大部分开发工作。因此打算基于分析sample-apiserver获得的经验，手工编写一个独立运行、使用HTTP协议的APIServer。</p>
<p>kubebuilder并不会生成zz_generated.openapi.go文件，因为该文件对于CRD没有意义。但是这个文件对于独立API Server是必须的。</p>
<p>我们需要为资源类型所在包添加注解：</p>
<pre class="crayon-plain-tag">// +k8s:openapi-gen=true

package v1</pre>
<p>并调用<a href="https://blog.gmem.cc/openapi#openapi-gen">openapi-gen</a>生成此文件：</p>
<pre class="crayon-plain-tag">openapi-gen  \
	--input-dirs "k8s.io/apimachinery/pkg/apis/meta/v1,k8s.io/apimachinery/pkg/runtime,k8s.io/apimachinery/pkg/version" \
	--input-dirs cloud.gmem.cc/teleport/api/v1    -p cloud.gmem.cc/teleport/api/v1 -O zz_generated.openapi</pre>
<p>下面是完整的quick&amp;dirty的代码：</p>
<pre class="crayon-plain-tag">package main

import (
	v1 "cloud.gmem.cc/teleport/api/v1"
	"context"
	"fmt"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/fields"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/runtime/serializer"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/apimachinery/pkg/util/validation/field"
	"k8s.io/apiserver/pkg/endpoints/openapi"
	"k8s.io/apiserver/pkg/features"
	"k8s.io/apiserver/pkg/registry/generic"
	genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
	"k8s.io/apiserver/pkg/registry/rest"
	genericapiserver "k8s.io/apiserver/pkg/server"
	genericoptions "k8s.io/apiserver/pkg/server/options"
	"k8s.io/apiserver/pkg/storage"
	"k8s.io/apiserver/pkg/storage/names"
	"k8s.io/apiserver/pkg/util/feature"
	utilfeature "k8s.io/apiserver/pkg/util/feature"
	"net"
	"net/http"
	"os"
	"reflect"
	ctrl "sigs.k8s.io/controller-runtime"
)

var (
	setupLog = ctrl.Log.WithName("setup")
)

func main() {

	s := runtime.NewScheme()
	utilruntime.Must(v1.AddToScheme(s))
	gv := v1.GroupVersion
	utilruntime.Must(s.SetVersionPriority(gv))
	metav1.AddToGroupVersion(s, schema.GroupVersion{Version: "v1"})
	unversioned := schema.GroupVersion{Group: "", Version: "v1"}
	s.AddUnversionedTypes(unversioned,
		&amp;metav1.Status{},
		&amp;metav1.APIVersions{},
		&amp;metav1.APIGroupList{},
		&amp;metav1.APIGroup{},
		&amp;metav1.APIResourceList{},
	)

	//  必须注册一个__internal版本，否则报错
	//  failed to prepare current and previous objects: no kind "Flunder" is registered for the internal version of group "tcm.cloud.gmem.cc" in scheme 
	gvi := gv
	gvi.Version = runtime.APIVersionInternal
	s.AddKnownTypes(gvi, &amp;v1.Flunder{}, &amp;v1.FlunderList{})

	codecFactory := serializer.NewCodecFactory(s)
	codec := codecFactory.LegacyCodec(gv)
	options := genericoptions.NewRecommendedOptions(
		"/teleport/cloud.gmem.cc",
		codec,
	)
	options.Etcd.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(gv, schema.GroupKind{Group: gv.Group})
	ips := []net.IP{net.ParseIP("127.0.0.1")}
	if err := options.SecureServing.MaybeDefaultWithSelfSignedCerts("localhost", nil, ips); err != nil {
		setupLog.Error(err, "error creating self-signed certificates")
		os.Exit(1)
	}
	options.Etcd.StorageConfig.Paging = utilfeature.DefaultFeatureGate.Enabled(features.APIListChunking)
	options.Etcd.StorageConfig.Transport.ServerList = []string{"http://etcd.gmem.cc:2379"}

	options.Authentication = nil
	options.Authorization = nil
	options.CoreAPI = nil
	options.Admission = nil
	options.SecureServing.BindPort = 6443

	config := genericapiserver.NewRecommendedConfig(codecFactory)
	config.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(v1.GetOpenAPIDefinitions,
		openapi.NewDefinitionNamer(s))
	config.OpenAPIConfig.Info.Title = "Teleport"
	config.OpenAPIConfig.Info.Version = "1.0"

	feature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{
		string(features.APIPriorityAndFairness): false,
	})

	if err := options.ApplyTo(config); err != nil {
		panic(err)
	}
	completedConfig := config.Complete()
	server, err := completedConfig.New("teleport-apiserver", genericapiserver.NewEmptyDelegate())
	if err != nil {
		panic(err)
	}

	apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(gv.Group, s, metav1.ParameterCodec, codecFactory)
	v1storage := map[string]rest.Storage{}
	resource := v1.ResourceFlunders
	v1storage[resource] = createStore(
		s,
		gv.WithResource(resource).GroupResource(),
		func() runtime.Object { return &amp;v1.Flunder{} },
		func() runtime.Object { return &amp;v1.FlunderList{} },
		completedConfig.RESTOptionsGetter,
	)
	apiGroupInfo.VersionedResourcesStorageMap[gv.Version] = v1storage
	if err := server.InstallAPIGroups(&amp;apiGroupInfo); err != nil {
		panic(err)
	}
	server.AddPostStartHookOrDie("teleport-post-start", func(context genericapiserver.PostStartHookContext) error {
		return nil
	})
	preparedServer := server.PrepareRun()
	http.ListenAndServe(":6080", preparedServer.Handler)
}

func createStore(scheme *runtime.Scheme, gr schema.GroupResource, newFunc, newListFunc func() runtime.Object,
	optsGetter generic.RESTOptionsGetter) rest.Storage {
	attrs := func(obj runtime.Object) (labels.Set, fields.Set, error) {
		typ := reflect.TypeOf(newFunc())
		if reflect.TypeOf(obj) != typ {
			return nil, nil, fmt.Errorf("given object is not a %s", typ.Name())
		}
		oma := obj.(metav1.ObjectMetaAccessor)
		meta := oma.GetObjectMeta()
		return meta.GetLabels(), fields.Set{
			"metadata.name":      meta.GetName(),
			"metadata.namespace": meta.GetNamespace(),
		}, nil
	}
	s := strategy{
		scheme,
		names.SimpleNameGenerator,
	}
	store := &amp;genericregistry.Store{
		NewFunc:     newFunc,
		NewListFunc: newListFunc,
		PredicateFunc: func(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
			return storage.SelectionPredicate{
				Label:    label,
				Field:    field,
				GetAttrs: attrs,
			}
		},
		DefaultQualifiedResource: gr,

		CreateStrategy: s,
		UpdateStrategy: s,
		DeleteStrategy: s,

		TableConvertor: rest.NewDefaultTableConvertor(gr),
	}
	options := &amp;generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrs}
	if err := store.CompleteWithOptions(options); err != nil {
		panic(err)
	}
	return store
}

type strategy struct {
	runtime.ObjectTyper
	names.NameGenerator
}

func (strategy) NamespaceScoped() bool {
	return true
}

func (strategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
}

func (strategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
}

func (strategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
	return field.ErrorList{}
}

func (strategy) AllowCreateOnUpdate() bool {
	return false
}

func (strategy) AllowUnconditionalUpdate() bool {
	return false
}

func (strategy) Canonicalize(obj runtime.Object) {
}

func (strategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
	return field.ErrorList{}
}</pre>
<div class="blog_h1"><span class="graybg">定制存储后端 </span></div>
<p>在安装APIGroup的时候，我们需要为每API组的每个版本的每种资源，指定存储后端：</p>
<pre class="crayon-plain-tag">// 每个组
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(wardle.GroupName, Scheme, metav1.ParameterCodec, Codecs)
// 每个版本
v1alpha1storage := map[stcongring]rest.Storage{}
// 每种资源提供一个rest.Storage
v1alpha1storage["flunders"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))

// 安装APIGroup
s.GenericAPIServer.InstallAPIGroup(&amp;apiGroupInfo)</pre>
<p>默认情况下，使用的是genericregistry.Store，它对接到Etcd。要实现自己的存储后端，实现相关接口即可。</p>
<p>注意：关于存储后端，有很多细节需要处理。</p>
<div class="blog_h2"><span class="graybg">基于文件的存储</span></div>
<p>下面贴一个在文件系统中，以YAML形式存储API资源的例子：</p>
<pre class="crayon-plain-tag">package file

import (
	"bytes"
	"context"
	"fmt"
	"io/ioutil"
	"k8s.io/apimachinery/pkg/util/uuid"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"sync"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/conversion"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/watch"
	genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
	"k8s.io/apiserver/pkg/registry/rest"
)

var _ rest.StandardStorage = &amp;store{}
var _ rest.Scoper = &amp;store{}
var _ rest.Storage = &amp;store{}

// NewStore instantiates a new file storage
func NewStore(groupResource schema.GroupResource, codec runtime.Codec, rootpath string, isNamespaced bool,
	newFunc func() runtime.Object, newListFunc func() runtime.Object, tc rest.TableConvertor) rest.Storage {
	objRoot := filepath.Join(rootpath, groupResource.Group, groupResource.Resource)
	if err := ensureDir(objRoot); err != nil {
		panic(fmt.Sprintf("unable to write data dir: %s", err))
	}
	rest := &amp;store{
		defaultQualifiedResource: groupResource,
		TableConvertor:           tc,
		codec:                    codec,
		objRootPath:              objRoot,
		isNamespaced:             isNamespaced,
		newFunc:                  newFunc,
		newListFunc:              newListFunc,
		watchers:                 make(map[int]*yamlWatch, 10),
	}
	return rest
}

type store struct {
	rest.TableConvertor
	codec        runtime.Codec
	objRootPath  string
	isNamespaced bool

	muWatchers sync.RWMutex
	watchers   map[int]*yamlWatch

	newFunc                  func() runtime.Object
	newListFunc              func() runtime.Object
	defaultQualifiedResource schema.GroupResource
}

func (f *store) notifyWatchers(ev watch.Event) {
	f.muWatchers.RLock()
	for _, w := range f.watchers {
		w.ch &lt;- ev
	}
	f.muWatchers.RUnlock()
}

func (f *store) New() runtime.Object {
	return f.newFunc()
}

func (f *store) NewList() runtime.Object {
	return f.newListFunc()
}

func (f *store) NamespaceScoped() bool {
	return f.isNamespaced
}

func (f *store) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
	return read(f.codec, f.objectFileName(ctx, name), f.newFunc)
}

func (f *store) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) {
	newListObj := f.NewList()
	v, err := getListPrt(newListObj)
	if err != nil {
		return nil, err
	}

	dirname := f.objectDirName(ctx)
	if err := visitDir(dirname, f.newFunc, f.codec, func(path string, obj runtime.Object) {
		appendItem(v, obj)
	}); err != nil {
		return nil, fmt.Errorf("failed walking filepath %v", dirname)
	}
	return newListObj, nil
}

func (f *store) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc,
	options *metav1.CreateOptions) (runtime.Object, error) {
	if createValidation != nil {
		if err := createValidation(ctx, obj); err != nil {
			return nil, err
		}
	}
	if f.isNamespaced {
		ns, ok := genericapirequest.NamespaceFrom(ctx)
		if !ok {
			return nil, apierrors.NewBadRequest("namespace required")
		}
		if err := ensureDir(filepath.Join(f.objRootPath, ns)); err != nil {
			return nil, err
		}
	}

	accessor, err := meta.Accessor(obj)
	if err != nil {
		return nil, err
	}
	if accessor.GetUID() == "" {
		accessor.SetUID(uuid.NewUUID())
	}

	name := accessor.GetName()
	filename := f.objectFileName(ctx, name)
	qualifiedResource := f.qualifiedResourceFromContext(ctx)
	if exists(filename) {
		return nil, apierrors.NewAlreadyExists(qualifiedResource, name)
	}

	if err := write(f.codec, filename, obj); err != nil {
		return nil, apierrors.NewInternalError(err)
	}

	f.notifyWatchers(watch.Event{
		Type:   watch.Added,
		Object: obj,
	})

	return obj, nil
}

func (f *store) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo,
	createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc,
	forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
	isCreate := false
	oldObj, err := f.Get(ctx, name, nil)
	if err != nil {
		if !forceAllowCreate {
			return nil, false, err
		}
		isCreate = true
	}

	if f.isNamespaced {
		// ensures namespace dir
		ns, ok := genericapirequest.NamespaceFrom(ctx)
		if !ok {
			return nil, false, apierrors.NewBadRequest("namespace required")
		}
		if err := ensureDir(filepath.Join(f.objRootPath, ns)); err != nil {
			return nil, false, err
		}
	}

	updatedObj, err := objInfo.UpdatedObject(ctx, oldObj)
	if err != nil {
		return nil, false, err
	}
	filename := f.objectFileName(ctx, name)

	if isCreate {
		if createValidation != nil {
			if err := createValidation(ctx, updatedObj); err != nil {
				return nil, false, err
			}
		}
		if err := write(f.codec, filename, updatedObj); err != nil {
			return nil, false, err
		}
		f.notifyWatchers(watch.Event{
			Type:   watch.Added,
			Object: updatedObj,
		})
		return updatedObj, true, nil
	}

	if updateValidation != nil {
		if err := updateValidation(ctx, updatedObj, oldObj); err != nil {
			return nil, false, err
		}
	}
	if err := write(f.codec, filename, updatedObj); err != nil {
		return nil, false, err
	}
	f.notifyWatchers(watch.Event{
		Type:   watch.Modified,
		Object: updatedObj,
	})
	return updatedObj, false, nil
}

func (f *store) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc,
	options *metav1.DeleteOptions) (runtime.Object, bool, error) {
	filename := f.objectFileName(ctx, name)
	qualifiedResource := f.qualifiedResourceFromContext(ctx)
	if !exists(filename) {
		return nil, false, apierrors.NewNotFound(qualifiedResource, name)
	}

	oldObj, err := f.Get(ctx, name, nil)
	if err != nil {
		return nil, false, err
	}
	if deleteValidation != nil {
		if err := deleteValidation(ctx, oldObj); err != nil {
			return nil, false, err
		}
	}

	if err := os.Remove(filename); err != nil {
		return nil, false, err
	}
	f.notifyWatchers(watch.Event{
		Type:   watch.Deleted,
		Object: oldObj,
	})
	return oldObj, true, nil
}

func (f *store) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc,
	options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) {
	newListObj := f.NewList()
	v, err := getListPrt(newListObj)
	if err != nil {
		return nil, err
	}
	dirname := f.objectDirName(ctx)
	if err := visitDir(dirname, f.newFunc, f.codec, func(path string, obj runtime.Object) {
		_ = os.Remove(path)
		appendItem(v, obj)
	}); err != nil {
		return nil, fmt.Errorf("failed walking filepath %v", dirname)
	}
	return newListObj, nil
}

func (f *store) objectFileName(ctx context.Context, name string) string {
	if f.isNamespaced {
		// FIXME: return error if namespace is not found
		ns, _ := genericapirequest.NamespaceFrom(ctx)
		return filepath.Join(f.objRootPath, ns, name+".yaml")
	}
	return filepath.Join(f.objRootPath, name+".yaml")
}

func (f *store) objectDirName(ctx context.Context) string {
	if f.isNamespaced {
		// FIXME: return error if namespace is not found
		ns, _ := genericapirequest.NamespaceFrom(ctx)
		return filepath.Join(f.objRootPath, ns)
	}
	return filepath.Join(f.objRootPath)
}

func write(encoder runtime.Encoder, filepath string, obj runtime.Object) error {
	buf := new(bytes.Buffer)
	if err := encoder.Encode(obj, buf); err != nil {
		return err
	}
	return ioutil.WriteFile(filepath, buf.Bytes(), 0600)
}

func read(decoder runtime.Decoder, path string, newFunc func() runtime.Object) (runtime.Object, error) {
	content, err := ioutil.ReadFile(filepath.Clean(path))
	if err != nil {
		return nil, err
	}
	newObj := newFunc()
	decodedObj, _, err := decoder.Decode(content, nil, newObj)
	if err != nil {
		return nil, err
	}
	return decodedObj, nil
}

func exists(filepath string) bool {
	_, err := os.Stat(filepath)
	return err == nil
}

func ensureDir(dirname string) error {
	if !exists(dirname) {
		return os.MkdirAll(dirname, 0700)
	}
	return nil
}

func visitDir(dirname string, newFunc func() runtime.Object, codec runtime.Decoder,
	visitFunc func(string, runtime.Object)) error {
	return filepath.Walk(dirname, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		if !strings.HasSuffix(info.Name(), ".yaml") {
			return nil
		}
		newObj, err := read(codec, path, newFunc)
		if err != nil {
			return err
		}
		visitFunc(path, newObj)
		return nil
	})
}

func appendItem(v reflect.Value, obj runtime.Object) {
	v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem()))
}

func getListPrt(listObj runtime.Object) (reflect.Value, error) {
	listPtr, err := meta.GetItemsPtr(listObj)
	if err != nil {
		return reflect.Value{}, err
	}
	v, err := conversion.EnforcePtr(listPtr)
	if err != nil || v.Kind() != reflect.Slice {
		return reflect.Value{}, fmt.Errorf("need ptr to slice: %v", err)
	}
	return v, nil
}

func (f *store) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) {
	yw := &amp;yamlWatch{
		id: len(f.watchers),
		f:  f,
		ch: make(chan watch.Event, 10),
	}
	// On initial watch, send all the existing objects
	list, err := f.List(ctx, options)
	if err != nil {
		return nil, err
	}

	danger := reflect.ValueOf(list).Elem()
	items := danger.FieldByName("Items")

	for i := 0; i &lt; items.Len(); i++ {
		obj := items.Index(i).Addr().Interface().(runtime.Object)
		yw.ch &lt;- watch.Event{
			Type:   watch.Added,
			Object: obj,
		}
	}

	f.muWatchers.Lock()
	f.watchers[yw.id] = yw
	f.muWatchers.Unlock()

	return yw, nil
}

type yamlWatch struct {
	f  *store
	id int
	ch chan watch.Event
}

func (w *yamlWatch) Stop() {
	w.f.muWatchers.Lock()
	delete(w.f.watchers, w.id)
	w.f.muWatchers.Unlock()
}

func (w *yamlWatch) ResultChan() &lt;-chan watch.Event {
	return w.ch
}

func (f *store) ConvertToTable(ctx context.Context, object runtime.Object,
	tableOptions runtime.Object) (*metav1.Table, error) {
	return f.TableConvertor.ConvertToTable(ctx, object, tableOptions)
}
func (f *store) qualifiedResourceFromContext(ctx context.Context) schema.GroupResource {
	if info, ok := genericapirequest.RequestInfoFrom(ctx); ok {
		return schema.GroupResource{Group: info.APIGroup, Resource: info.Resource}
	}
	// some implementations access storage directly and thus the context has no RequestInfo
	return f.defaultQualifiedResource
}</pre>
<p>调用NewStore即可创建一个rest.Storage。前面我们提到过存储后端有很多细节需要处理，对于上面这个样例，它没有：</p>
<ol>
<li>发现正在删除中的资源，并在CRUD时作出适当响应</li>
<li>进行资源合法性校验。genericregistry.Store的做法是，调用strategy进行校验</li>
<li>自动填充某些元数据字段，包括creationTimestamp、selfLink等</li>
</ol>
<div class="blog_h1"><span class="graybg">处理子资源</span></div>
<p>假如K8S中某种资源具有状态子资源。那么当客户端更新状态子资源时，发出的HTTP请求格式为：</p>
<p style="padding-left: 30px;">PUT /apis/cloud.gmem.cc/v1/namespaces/default/flunders/sample/status</p>
<p>它会匹配路由：</p>
<p style="padding-left: 30px;">PUT /apis/cloud.gmem.cc/v1/namespaces/{namespace}/flunders/{name}/status</p>
<p>这个路由是专门为status子资源准备的，和主资源路由不同：</p>
<p style="padding-left: 30px;">PUT /apis/cloud.gmem.cc/v1/namespaces/{namespace}/flunders/{name}</p>
<p>那么，主资源、子资源的处理方式有什么不同？如何影响这种资源处理逻辑呢？</p>
<div class="blog_h2"><span class="graybg">注册子资源</span></div>
<p>InstallAPIGroups时，你只需要简单的为带有 / 的资源名字符串添加一个rest.Storage，就支持子资源了：</p>
<pre class="crayon-plain-tag">v1beta1storage["flunders/status"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))</pre>
<p>你甚至可以直接使用父资源的rest.Storage。但是这样的结果是，客户端请求可以仅更新status，也可以更新整个flunder，一般情况下这是不符合预期的。</p>
<p>上面的代码，还会导致在APIServer中注册类似本章开始处的go-restful路由。</p>
<p>抽取路径变量namespace、name的代码是：</p>
<pre class="crayon-plain-tag">pathParams := pathProcessor.ExtractParameters(route, webService, httpRequest.URL.Path)</pre>
<p>这两个变量识别了当前操控的是什么资源。请求进而会转发给rest.Storage的Update方法：</p>
<pre class="crayon-plain-tag">func (e *Store) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {}</pre>
<p>name参数传递的是资源的名字。当前是否应当（仅）更新子资源，rest.Storage无从知晓。</p>
<div class="blog_h2"><span class="graybg">子资源处理器</span></div>
<p>更新状态子资源的时候，我们通常仅仅允许更新Status字段。要达成这个目的，我们需要为子资源注册独立的rest.Storage。</p>
<pre class="crayon-plain-tag">package store

import (
	"context"
	"k8s.io/apimachinery/pkg/runtime"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/apiserver/pkg/registry/generic/registry"
	"k8s.io/apiserver/pkg/registry/rest"
	"sigs.k8s.io/apiserver-runtime/pkg/builder/resource/util"
)

// CopyStatusFunc copies status from obj to old
type CopyStatusFunc func(src, dst runtime.Object)

// StatusStore decorates a parent storage and only updates
// status subresource when updating
func StatusStore(parentStore rest.StandardStorage, copyStatusFunc CopyStatusFunc) rest.Storage {
	switch pstor := parentStore.(type) {
	case *registry.Store:
		pstor.UpdateStrategy = &amp;statusStrategy{
			RESTUpdateStrategy: pstor.UpdateStrategy,
			copyStatusFunc:     copyStatusFunc,
		}
	}
	return &amp;statusStore{
		StandardStorage: parentStore,
	}
}

var _ rest.Getter = &amp;statusStore{}
var _ rest.Updater = &amp;statusStore{}

type statusStore struct {
	rest.StandardStorage
}

var _ rest.RESTUpdateStrategy = &amp;statusStrategy{}

// statusStrategy defines a default Strategy for the status subresource.
type statusStrategy struct {
	rest.RESTUpdateStrategy
	copyStatusFunc CopyStatusFunc
}

// PrepareForUpdate calls the PrepareForUpdate function on obj if supported, otherwise does nothing.
func (s *statusStrategy) PrepareForUpdate(ctx context.Context, new, old runtime.Object) {
	s.copyStatusFunc(new, old)
	if err := util.DeepCopy(old, new); err != nil {
		utilruntime.HandleError(err)
	}
}</pre>
<p>genericregistry.Store的更新会在一个原子操作的回调函数中进行。在回调中，它会调用Strategy的PrepareForUpdate方法。上面的statusStore的原理就是覆盖此方法，仅仅改变状态子资源。</p>
<div class="blog_h1"><span class="graybg"><a id="multipleversions"></a>多版本化</span></div>
<p>当你的API需要引入破坏性变更时，就要考虑支持多版本化。</p>
<div class="blog_h2"><span class="graybg">API文件布局</span></div>
<p>下面是一个典型的多版本API文件目录的布局：</p>
<pre class="crayon-plain-tag">api
├── doc.go
├── fullvpcmigration_types.go
├── 
├── v1
│   ├── conversion.go
│   ├── doc.go
│   ├── fullvpcmigration_types.go
│   ├── register.go
│   ├── zz_generated.conversion.go
│   ├── zz_generated.deepcopy.go
│   └── zz_generated.openapi.go
├── v2
│   ├── doc.go
│   ├── fullvpcmigration_types.go
│   ├── register.go
│   ├── zz_generated.conversion.go
│   ├── zz_generated.deepcopy.go
│   └── zz_generated.openapi.go
└── zz_generated.deepcopy.go</pre>
<p> API组的根目录（上面的示例项目只有一个组，因此直接将api目录作为组的根目录）下，应该存放__internal版本的资源结构定义，建议将其内容和最新版本保持一致。</p>
<div class="blog_h3"><span class="graybg">doc.go</span></div>
<p>这个文件应当提供包级别的注释，例如：</p>
<pre class="crayon-plain-tag">// +k8s:openapi-gen=true
// +groupName=gmem.cc
// +kubebuilder:object:generate=true

package api</pre>
<div class="blog_h3"><span class="graybg">register.go</span></div>
<p>这个文件用于Scheme的注册。对于__internal版本：</p>
<pre class="crayon-plain-tag">package api

import (
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
)

const (
	GroupName = "gmem.cc"
)

var (
	// GroupVersion is group version used to register these objects
	GroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal}

	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
	// no &amp;scheme.Builder{} here, otherwise vk __internal/WatchEvent will double registered to k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent &amp;
	// k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent, which is illegal
	SchemeBuilder = runtime.NewSchemeBuilder()

	// AddToScheme adds the types in this group-version to the given scheme.
	AddToScheme = SchemeBuilder.AddToScheme
)

// Kind takes an unqualified kind and returns a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
	return GroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
	return GroupVersion.WithResource(resource).GroupResource()
}</pre>
<p>对于普通的版本：</p>
<pre class="crayon-plain-tag">package v2

import (
	"cloud.tencent.com/teleport/api"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
)

var (
	// GroupVersion is group version used to register these objects
	GroupVersion = schema.GroupVersion{Group: api.GroupName, Version: "v2"}

	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
	SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error {
		metav1.AddToGroupVersion(scheme, GroupVersion)
		return nil
	})
	localSchemeBuilder = &amp;SchemeBuilder

	// AddToScheme adds the types in this group-version to the given scheme.
	AddToScheme = SchemeBuilder.AddToScheme
)

// Kind takes an unqualified kind and returns a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
	return GroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
	return GroupVersion.WithResource(resource).GroupResource()
}</pre>
<p>可以看到，普通版本需要将metav1包中的某些结构注册到自己的GroupVersion。</p>
<div class="blog_h3"><span class="graybg">zz_generated.openapi.go</span></div>
<p>这是每个普通版本都需要生成的OpenAPI定义。这些OpenAPI定义必须注册到API Server，否则将会导致kubectl apply等命令报404错误：</p>
<pre class="crayon-plain-tag">$(OPENAPI_GEN)  \
	--input-dirs "k8s.io/apimachinery/pkg/apis/meta/v1,k8s.io/apimachinery/pkg/runtime,k8s.io/apimachinery/pkg/version" \
	--input-dirs cloud.tencent.com/teleport/api/v1 -o ./  -p api/v1 -O zz_generated.openapi

$(OPENAPI_GEN)  \
	--input-dirs cloud.tencent.com/teleport/api/v2 -o ./  -p api/v2 -O zz_generated.openapi</pre>
<div class="blog_h3"><span class="graybg">zz_generated.conversion.go</span></div>
<p>这是每个普通版本都需要生成的From/To __internal版本的类型转换函数。这些转换函数会通过上面的localSchemeBuilder注册到当前GroupVersion：</p>
<pre class="crayon-plain-tag">$(CONVERSION_GEN) -h hack/boilerplate.go.txt --input-dirs cloud.tencent.com/teleport/api/v1 -O zz_generated.conversion
$(CONVERSION_GEN) -h hack/boilerplate.go.txt --input-dirs cloud.tencent.com/teleport/api/v2 -O zz_generated.conversion</pre>
<p>升级API版本的原因，自然是因为结构出现了变动。结构的变动，就意味着新旧版本有特殊的<span style="background-color: #c0c0c0;">类型转换逻辑。这种逻辑显然不可能自动生成，你手工添加的转换代码应该存放在conversion.go中</span>。</p>
<div class="blog_h3"><span class="graybg">zz_generated.deepcopy.go</span></div>
<p>这个文件是__internal版本、普通版本中的资源对应Go结构都需要生成的深拷贝函数。</p>
<div class="blog_h2"><span class="graybg">关于__internal版本</span></div>
<p>如前文所述，每个API资源（的版本），都需要一个rest.Storage，这个Storage会直接负责该API资源版本的GET/CREATE/UPDATE/DELETE/WATCH等操作。</p>
<p>作为默认的，针对Etcd存储后端的rest.Storage的实现genericregistry.Store，它在内部有一个Cacher。此Cacher利用缓存来处理WATCH/LIST请求，避免对Etcd过频的访问。在此Cacher内部，会使用资源的内部版本。</p>
<p>所谓内部版本，就是注册到__internal这个特殊版本号的资源。<pre class="crayon-plain-tag">__internal</pre>这个字面值由常量<pre class="crayon-plain-tag">runtime.APIVersionInternal</pre>提供。我们通常将<span style="background-color: #c0c0c0;">组的根目录下的资源结构体，注册为__internal版本</span>。</p>
<p>有了这种内部版本机制，Cacher就不需要在内存中，存储资源的不同版本。</p>
<p>除此之外，rest.Storage或者它的Strategy所需要的一系列资源生命周期回调函数，接受的参数，都是__internal版本。这意味着：</p>
<ol>
<li>我们不需要为每个版本，编写重复的回调函数</li>
<li>在多版本化的时候，需要将<span style="background-color: #c0c0c0;">这些回调函数的入参都改为__internal版本</span></li>
</ol>
<div class="blog_h2"><span class="graybg">生成和定制转换函数</span></div>
<p>之所以Cacher、生命周期回调函数，以及下文会提到的，kubectl和存储能够自由的选择自己需要的版本，是因为不同版本的API资源之间可以进行转换。</p>
<p>当你复制一份v1资源的代码为v2时，这时可以使用完全自动生成的转换函数。一旦你添加或修改了一个字段，你就需要定制转换函数了。</p>
<p>假设我们将FullVPCMigrationSpec.TeamName字段改为Team，则需要：</p>
<pre class="crayon-plain-tag">// zz_generated.conversion.go中报错的地方，就是你需要实现的函数

func Convert_v1_FullVPCMigrationSpec_To_api_FullVPCMigrationSpec(in *FullVPCMigrationSpec, out *api.FullVPCMigrationSpec, s conversion.Scope) error {
    // 这里编写因为字段变化还需要手工处理的部分
	out.Team = in.TeamName
    // 然后调用自动生成的函数，这个函数和你实现的函数，名字的差异就是auto前缀
	return autoConvert_v1_FullVPCMigrationSpec_To_api_FullVPCMigrationSpec(in, out, s)
}

func Convert_api_FullVPCMigrationSpec_To_v1_FullVPCMigrationSpec(in *api.FullVPCMigrationSpec, out *FullVPCMigrationSpec, s conversion.Scope) error {
	out.TeamName = in.Team
	return autoConvert_api_FullVPCMigrationSpec_To_v1_FullVPCMigrationSpec(in, out, s)
}</pre>
<p>上面两个，是自动生成的转换代码中，缺失的函数，会导致编译错误。你需要自己实现它们。</p>
<p>带有auto前缀的版本，是自动生成的、完成了绝大部分逻辑的转换函数，你需要进行必要的手工处理，然后调用这个auto函数即可。</p>
<p>需要注意，<span style="background-color: #c0c0c0;">转换函数都是在特定版本和__internal版本之间进行的</span>。也就是如果v1需要转换到v2，则需要先转换为__internal，然后在由__internal转换为v2。这种设计也很好理解，不这样做随着版本的增多，转换函数的数量会爆炸式增长。</p>
<p><span style="background-color: #c0c0c0;">类型转换代码必须注册到Scheme</span>，不管是在API Server、kubectl或controller-runtime这样的客户端，都依赖于Scheme。</p>
<div class="blog_h2"><span class="graybg">多版本如何存储</span></div>
<p>不管是存储（串行化），还是读取（反串行化），都依赖于Codec。所谓Codec就是Serializer：</p>
<pre class="crayon-plain-tag">package runtime

type Serializer interface {
	Encoder
	Decoder
}

// Codec is a Serializer that deals with the details of versioning objects. It offers the same
// interface as Serializer, so this is a marker to consumers that care about the version of the objects
// they receive.
type Codec Serializer</pre>
<p>Codec由CodecFactory提供，后者持有Scheme：</p>
<pre class="crayon-plain-tag">serializer.NewCodecFactory(scheme)</pre>
<p>我们已经知道，Scheme包含这些信息：</p>
<ol>
<li>各种Group、Version、Kind，映射到了什么Go结构</li>
<li>Go结构上具有json标签，这些信息决定了结构的串行化格式是怎样的</li>
<li>同一个Group、Kind的不同Version，如何进行相互转换 </li>
</ol>
<p>因此，Codec有能力进行JSON或其它格式的串行化操作，并且在不同版本的Go结构之间进行转换。</p>
<p>对于 genericregistry.Store 来说，存储就是将API资源的Go结构转换为JSON或者ProtoBuf，保存到Etcd，它显然需要Codec的支持。</p>
<p>当启用多版本支持后，你需要将所有版本（prioritizedVersions）作为参数传递给CodecFactory并创建Codec：</p>
<pre class="crayon-plain-tag">prioritizedVersions ：= []schema.GroupVersion{
    {
        Group: "gmem.cc",
        Version: "v2",
    },
    {
        Group: "gmem.cc",
        Version: "v1",
    },
}
codec := codecFactory.LegacyCodec(prioritizedVersions...)

genericOptions.Etcd.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(schema.GroupVersion{
    Group: "gmem.cc",
    Version: "v2",
} )</pre>
<p>并且，<span style="background-color: #c0c0c0;">prioritizedVersions决定了存储一个资源的时候，优先选择的格式</span>。例如fullvpcmigrations有v1,v2版本，因此在存储的时候会使用v2。而jointeamrequests只有v1版本，因此存储的时候只能使用v1。</p>
<p>注意：如果存在一个既有的v1版本的fullvpcmigration，<span style="background-color: #c0c0c0;">在上述配置应用后，第一次对它进行修改，会导致存储格式修改为v2</span>。</p>
<div class="blog_h2"><span class="graybg">多版本的OpenAPI</span></div>
<p>你需要为每个版本生成OpenAPI定义。OpenAPI的定义只是一个map，将所有版本的内容合并即可。</p>
<div class="blog_h2"><span class="graybg">APIServer暴露哪个版本</span></div>
<p>APIServer会暴露所有注册的资源版本。但是，它有一个版本优先级的概念：</p>
<pre class="crayon-plain-tag">apiGroupInfo.PrioritizedVersions = prioritizedVersions</pre>
<p>这个决定了kubectl的时候，优先显示为哪个版本。优选版本也会显示在api-resources子命令的输出：</p>
<pre class="crayon-plain-tag"># kubectl -s http://127.0.0.1:6080 api-resources  
NAME                    SHORTNAMES   APIVERSION                 NAMESPACED   KIND
fullvpcmigrations                    gmem.cc/v2   true         FullVPCMigration
jointeamrequests                     gmem.cc/v1   true         JoinTeamRequest</pre>
<p>kuebctl get命令，默认展示优选版本，但是你也可以强制要求显示为指定版本：</p>
<pre class="crayon-plain-tag">kubectl -s http://127.0.0.1:6080 -n default get fullvpcmigration.v1.gmem.cc
# GET http://127.0.0.1:6080/apis/gmem.cc/v1/namespaces/default/fullvpcmigrations?limit=500

kubectl -s http://127.0.0.1:6080 -n default get fullvpcmigration.v2.gmem.cc
# GET http://127.0.0.1:6080/apis/gmem.cc/v2/namespaces/default/fullvpcmigrations?limit=500</pre>
<p>不管怎样，<span style="background-color: #c0c0c0;">存储为任何版本的fullvpcmigrations都会被查询到</span>。你可以认为在<span style="background-color: #c0c0c0;">客户端视角，选择版本仅仅是选择资源的一种“视图”</span>。</p>
<div class="blog_h2"><span class="graybg">控制器中的版本选择</span></div>
<p>控制器所<span style="background-color: #c0c0c0;">监听的资源版本，必须已经在控制器管理器的Scheme中注册</span>。</p>
<p>你在Reconcile代码中，可以<span style="background-color: #c0c0c0;">用任何已经注册的版本来作为Get操作的容器</span>，类型转换会自动进行。  </p>
<p>建议仅在读取、存储资源状态的时候，用普通版本，其余时候，都用__internal版本。这样你的控制器逻辑，在版本升级后，需要的变更会很少。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/kubernetes-style-apiserver">编写Kubernetes风格的APIServer</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/kubernetes-style-apiserver/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>记录一次KeyDB缓慢的定位过程</title>
		<link>https://blog.gmem.cc/debugging-slow-keydb</link>
		<comments>https://blog.gmem.cc/debugging-slow-keydb#comments</comments>
		<pubDate>Thu, 28 Jan 2021 07:04:58 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[C++]]></category>
		<category><![CDATA[PaaS]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Redis]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=35755</guid>
		<description><![CDATA[<p>环境说明 运行环境 这个问题出现在一套搭建在虚拟机上的Kubernetes 1.18集群上。集群有三个节点： [crayon-69d31392e1165518328852/] KeyDB配置 KeyDB通过StatefulSet管理，一共有三个实例：  [crayon-69d31392e116b024186671/] 这三个实例： 由于反亲和设置，会在每个节点上各运行一个实例 启用Active - Active（--active-replica）模式的多主（--multi-master）复制 ：每个实例都是另外两个的Slave，每个实例都支持读写 故障描述 触发条件 出现一个节点宕机的情况，就可能出现此故障。经过一段时间以后，会出现GET/PUT或者任何其它请求处理缓慢的情况。 故障特征 此故障有两个明显的特征： 故障出现前需要等待的时间，随机性很强，有时甚至测试了数小时都没有发现请求缓慢的情况。常常发生的情况是，宕机后剩下的两个实例，一个很快出现缓慢问题，另外一个却还能运行较长时间 请求处理延缓的时长不定，有时候没有明显延缓，有时候长达10+秒。而且一次缓慢请求后，可以跟着10多次正常速度处理的请求。这个特征提示故障和某种周期性的、长时间占用的锁有关。在锁被释放的间隙，请求可以被快速处理 故障分析 <a class="read-more" href="https://blog.gmem.cc/debugging-slow-keydb">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/debugging-slow-keydb">记录一次KeyDB缓慢的定位过程</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">环境说明</span></div>
<div class="blog_h2"><span class="graybg">运行环境</span></div>
<p>这个问题出现在一套搭建在虚拟机上的Kubernetes 1.18集群上。集群有三个节点：</p>
<pre class="crayon-plain-tag"># kubectl get node -o wide
NAME              STATUS   VERSION   INTERNAL-IP      OS-IMAGE                KERNEL-VERSION              CONTAINER-RUNTIME
192.168.104.51    Ready    v1.18.3   192.168.104.51   CentOS Linux 7 (Core)   3.10.0-862.3.2.el7.x86_64   docker://19.3.9
192.168.104.72    Ready    v1.18.3   192.168.104.72   CentOS Linux 7 (Core)   3.10.0-862.3.2.el7.x86_64   docker://19.3.9
192.168.104.108   Ready    v1.18.3   192.168.104.108  CentOS Linux 7 (Core)   3.10.0-862.3.2.el7.x86_64   docker://19.3.9</pre>
<div class="blog_h2"><span class="graybg">KeyDB配置</span></div>
<p><a href="/keydb-study-note">KeyDB</a>通过StatefulSet管理，一共有三个实例： </p>
<pre class="crayon-plain-tag"># kubectl -n default get pod -o wide -l app.kubernetes.io/name=keydb
NAME             READY   STATUS    RESTARTS     IP             NODE            
keydb-0   1/1     Running   0            172.29.2.63     192.168.104.108 
keydb-1   1/1     Running   0            172.29.1.69     192.168.104.72  
keydb-2   1/1     Running   0            172.29.1.121    192.168.104.51</pre>
<p>这三个实例：</p>
<ol>
<li>由于反亲和设置，会在每个节点上各运行一个实例</li>
<li>启用Active - Active（--active-replica）模式的多主（--multi-master）复制 ：每个实例都是另外两个的Slave，每个实例都支持读写</li>
</ol>
<div class="blog_h1"><span class="graybg">故障描述</span></div>
<div class="blog_h2"><span class="graybg">触发条件</span></div>
<p>出现一个节点宕机的情况，就可能出现此故障。经过一段时间以后，会出现<span style="background-color: #c0c0c0;">GET/PUT或者任何其它请求处理缓慢的情况</span>。</p>
<div class="blog_h2"><span class="graybg">故障特征</span></div>
<p>此故障有两个明显的特征：</p>
<ol>
<li>故障出现前需要等待的时间，随机性很强，有时甚至测试了数小时都没有发现请求缓慢的情况。常常发生的情况是，宕机后剩下的两个实例，一个很快出现缓慢问题，另外一个却还能运行较长时间</li>
<li>请求处理延缓的时长不定，有时候没有明显延缓，有时候长达10+秒。而且一次缓慢请求后，可以跟着10多次正常速度处理的请求。这个特征提示故障和某种<span style="background-color: #c0c0c0;">周期性的、长时间占用的锁</span>有关。在锁被释放的间隙，请求可以被快速处理</li>
</ol>
<div class="blog_h1"><span class="graybg">故障分析</span></div>
<div class="blog_h2"><span class="graybg">触发故障</span></div>
<p>我们将节点192.168.104.108强制关闭，这样实例keydb-0无法访问，另外两个节点无法和它进行Replication。</p>
<p>分别登录另外两个节点，监控GET/SET操作的性能：</p>
<pre class="crayon-plain-tag">kubectl -n default exec -it keydb-1 -- bash -c  \
  'while true; do key=keydb-1-$(date +%s); keydb-cli set $key $key-val; keydb-cli get $key; done'

kubectl -n default exec -it keydb-2 -- bash -c \
 'while true; do key=keydb-2-$(date +%s); keydb-cli set $key $key-val; keydb-cli get $key; done'</pre>
<p>监控Replication相关信息：</p>
<pre class="crayon-plain-tag">watch -- kubectl -n default exec -i keydb-1 -- keydb-cli info replication

watch -- kubectl -n default exec -i keydb-2 -- keydb-cli info replication</pre>
<p>监控KeyDB日志： </p>
<pre class="crayon-plain-tag">kubectl -n default logs  keydb-1 -f

kubectl -n default logs  keydb-2 -f</pre>
<p>经过一段时间，keydb-1请求处理随机延缓的情况出现：</p>
<pre class="crayon-plain-tag">127.0.0.1:6379&gt; set hello world
OK
(1.24s)
127.0.0.1:6379&gt; set hello world
OK
(8.96s)
127.0.0.1:6379&gt; get hello
"world"
(5.99s)
127.0.0.1:6379&gt; get hello
"world"
(9.44s) </pre>
<p>此时keydb-2仍然正常运行，请求处理速度正常</p>
<div class="blog_h2"><span class="graybg">缓慢查询</span></div>
<p>获取keydb-1的慢查询，没有发现有价值的信息。而且延缓的时间没有计算在内：</p>
<pre class="crayon-plain-tag">127.0.0.1:6379&gt; slowlog get 10                                         
1) 1) (integer) 7                                                      
   2) (integer) 1611833042                                             
   3) (integer) 14431               # 最慢的查询才耗时14ms                                            
   4) 1) "set"                                                         
      2) "keydb-1-1611833042"                                          
      3) "keydb-1-1611833042-val"                                      
   5) "127.0.0.1:38488"                                                
   6) ""                                                               
2) 1) (integer) 6                                                      
   2) (integer) 1611831322                                             
   3) (integer) 14486                                                  
   4) 1) "get"                                                         
      2) "keydb-1-1611831312"                                          
   5) "127.0.0.1:51680"                                                
   6) ""  </pre>
<div class="blog_h2"><span class="graybg">日志分析</span></div>
<p>部署KeyDB已经设置<pre class="crayon-plain-tag">--loglevel debug</pre>，以获得尽可能详尽的日志。</p>
<p>由于正在运行不间断执行SET/GET操作的脚本，因此日志量很大而刷屏，但是每隔一段时间就会出现卡顿。下面是keydb-1的日志片段：</p>
<pre class="crayon-plain-tag">7:11:S 28 Jan 2021 08:57:51.233 - Client closed connection
7:11:S 28 Jan 2021 08:57:51.251 - Accepted 127.0.0.1:44224
7:11:S 28 Jan 2021 08:57:51.252 - Client closed connection
7:12:S 28 Jan 2021 08:57:51.276 - Accepted 127.0.0.1:44226
7:11:S 28 Jan 2021 08:57:51.277 - Client closed connection
# 这一行日志之后，卡顿了10s。没有任何日志输出
7:11:S 28 Jan 2021 08:57:51.279 * Connecting to MASTER keydb-0.keydb:6379
7:11:S 28 Jan 2021 08:58:01.290 * Unable to connect to MASTER: Resource temporarily unavailable
7:11:S 28 Jan 2021 08:58:01.290 - Accepted 127.0.0.1:44228
7:11:S 28 Jan 2021 08:58:01.290 - Accepted 127.0.0.1:44264</pre>
<p>从日志信息上可以看到，卡顿前keydb-1正在尝试连接到已经宕机的keydb-0，这个连接尝试被阻塞10秒后报<pre class="crayon-plain-tag">EAGAIN</pre>错误。</p>
<p>阻塞期间SET/GET请求得不到处理，猜测原因包括：</p>
<ol>
<li>连接keydb-0的时候，占用了某种全局的锁，SET/GET请求也需要持有该锁</li>
<li>连接keydb-0、处理SET/GET请求，由同一线程负责</li>
</ol>
<p>第2种猜测应该不大可能，因为KeyDB宣称的优势之一就是，支持多线程处理请求。并且我们设置了参数<pre class="crayon-plain-tag">--server-threads 2</pre>，也就是有两个线程用于处理请求。</p>
<p>EAGAIN这个报错也没有参考价值，因为目前不卡顿的实例keydb-2输出的日志是一样的，只是没有任何卡顿：</p>
<pre class="crayon-plain-tag">7:11:S 28 Jan 2021 08:19:22.624 * Connecting to MASTER keydb-0.keydb:6379
# 仅仅耗时5ms即检测到连接失败
7:11:S 28 Jan 2021 08:19:22.629 * Unable to connect to MASTER: Resource temporarily unavailable</pre>
<div class="blog_h2"><span class="graybg">源码分析</span></div>
<div class="blog_h3"><span class="graybg">复制定时任务</span></div>
<p>我们使用的KeyDB版本是5.3.3，尝试用关键字“Connecting to MASTER”搜索，发现只有一个匹配，位于<pre class="crayon-plain-tag">replicationCron</pre>函数中。从函数名称上就可以看到，它是和复制（Replication）有关的定时任务。</p>
<p>KeyDB启动时会调用<pre class="crayon-plain-tag">initServer</pre>进行初始化，后者会在事件循环中每1ms调度一次<pre class="crayon-plain-tag">serverCron</pre>。serverCron负责后台任务的总体调度，它的一个职责就是，每1s调度一次replicationCron函数。</p>
<p>下面看一下replicationCron的源码：</p>
<pre class="crayon-plain-tag">/* Replication cron function, called 1 time per second. */
void replicationCron(void) {
    static long long replication_cron_loops = 0;
    serverAssert(GlobalLocksAcquired());
    listIter liMaster;
    listNode *lnMaster;
    listRewind(g_pserver-&gt;masters, &amp;liMaster);
    // 遍历当前实例的每一个Master
    while ((lnMaster = listNext(&amp;liMaster)))
    {
        redisMaster *mi = (redisMaster*)listNodeValue(lnMaster);
        std::unique_lock&lt;decltype(mi-&gt;master-&gt;lock)&gt; ulock;
        // 获得              Master的 客户端的 锁
        if (mi-&gt;master != nullptr)
            ulock = decltype(ulock)(mi-&gt;master-&gt;lock);

        /* Non blocking connection timeout? */
        // 如果当前复制状态为：正在连接到Master
        // 或者复制状态处于握手阶段（包含多个状态）且超时了
        if (mi-&gt;masterhost &amp;&amp;
            (mi-&gt;repl_state == REPL_STATE_CONNECTING ||
            slaveIsInHandshakeState(mi)) &amp;&amp;
            (time(NULL)-mi-&gt;repl_transfer_lastio) &gt; g_pserver-&gt;repl_timeout)
        {
            // 那么取消握手 —— 取消进行中的非阻塞连接尝试，或者取消进行中的RDB传输
            serverLog(LL_WARNING,"Timeout connecting to the MASTER...");
            cancelReplicationHandshake(mi);
        }

        /* Bulk transfer I/O timeout? */
        // 如果当前正在接收来自Master的RDB文件且超时了
        if (mi-&gt;masterhost &amp;&amp; mi-&gt;repl_state == REPL_STATE_TRANSFER &amp;&amp;
            (time(NULL)-mi-&gt;repl_transfer_lastio) &gt; g_pserver-&gt;repl_timeout)
        {
            serverLog(LL_WARNING,"Timeout receiving bulk data from MASTER... If the problem persists try to set the 'repl-timeout' parameter in keydb.conf to a larger value.");
            // 那么取消握手
            cancelReplicationHandshake(mi);
        }

        /* Timed out master when we are an already connected replica? */
        // 如果当前复制状态为：已连接。而且超时之前没有活动（正常情况下有心跳维持）
        if (mi-&gt;masterhost &amp;&amp; mi-&gt;master &amp;&amp; mi-&gt;repl_state == REPL_STATE_CONNECTED &amp;&amp;
            (time(NULL)-mi-&gt;master-&gt;lastinteraction) &gt; g_pserver-&gt;repl_timeout)
        {
            // 那么释放掉客户端资源
            serverLog(LL_WARNING,"MASTER timeout: no data nor PING received...");
            if (FCorrectThread(mi-&gt;master))
                freeClient(mi-&gt;master);
            else
                freeClientAsync(mi-&gt;master);
        }

        /* Check if we should connect to a MASTER */
        // 上面几个分支都不会匹配我们的场景，因为keydb-0已经宕机，因此
        // 状态必然是REPL_STATE_CONNECT
        if (mi-&gt;repl_state == REPL_STATE_CONNECT) {
            // 这一行就是卡顿前的日志
            serverLog(LL_NOTICE,"Connecting to MASTER %s:%d",
                mi-&gt;masterhost, mi-&gt;masterport);
            // 发起连接
            if (connectWithMaster(mi) == C_OK) {
                serverLog(LL_NOTICE,"MASTER &lt;-&gt; REPLICA sync started");
            }
        }

        // 每秒钟发送心跳给Master
        if (mi-&gt;masterhost &amp;&amp; mi-&gt;master &amp;&amp;
            !(mi-&gt;master-&gt;flags &amp; CLIENT_PRE_PSYNC))
            replicationSendAck(mi);
    }

    // 后面处理和本实例的Slave有关的逻辑，例如发送心跳。和我们的场景无关，略...
}</pre>
<p>很明显，卡顿是因为调用<pre class="crayon-plain-tag">connectWithMaster</pre>导致的。从代码注释也可以看到，KeyDB期望这个连接操作是非阻塞的，但是不知道为何，在我们的场景中严重的阻塞了。</p>
<p>进一步查看connectWithMaster的代码：</p>
<pre class="crayon-plain-tag">int connectWithMaster(redisMaster *mi) {
    int fd;

    fd = anetTcpNonBlockBestEffortBindConnect(NULL,
        mi-&gt;masterhost,mi-&gt;masterport,NET_FIRST_BIND_ADDR);
    if (fd == -1) {
        int sev = g_pserver-&gt;enable_multimaster ? LL_NOTICE : LL_WARNING;
        // 这一行是卡顿10s后的日志，因此阻塞发生在anetTcpNonBlockBestEffortBindConnect函数中
        serverLog(sev,"Unable to connect to MASTER: %s", strerror(errno));
        return C_ERR;
    }
    // ...
}

int anetTcpNonBlockBestEffortBindConnect(char *err, char *addr, int port,
                                         char *source_addr)
{
    return anetTcpGenericConnect(err,addr,port,source_addr,
            // 非阻塞 + BestEffort绑定
            ANET_CONNECT_NONBLOCK|ANET_CONNECT_BE_BINDING);
}


static int anetTcpGenericConnect(char *err, char *addr, int port,
                                 char *source_addr, int flags)
{
    int s = ANET_ERR, rv;
    char portstr[6];  /* strlen("65535") + 1; */
    struct addrinfo hints, *servinfo, *bservinfo, *p, *b;

    snprintf(portstr,sizeof(portstr),"%d",port);
    memset(&amp;hints,0,sizeof(hints));
    // 不指定地址族，这会触发getaddrinfo同时进行A/AAAA查询
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    // 根据Master的主机名查找得到IP地址信息（addrinfo）列表
    if ((rv = getaddrinfo(addr,portstr,&amp;hints,&amp;servinfo)) != 0) {
        anetSetError(err, "%s", gai_strerror(rv));
        return ANET_ERR;
    }
    // 遍历Master的IP地址列表
    for (p = servinfo; p != NULL; p = p-&gt;ai_next) {
        // 创建套接字，如果socket/connect调用失败，则尝试下一个
        if ((s = socket(p-&gt;ai_family,p-&gt;ai_socktype,p-&gt;ai_protocol)) == -1)
            continue;
        // 设置套接字选项SO_REUSEADDR
        if (anetSetReuseAddr(err,s) == ANET_ERR) 
            goto error;
        // 设置套接字选项 SO_REUSEPORT
        if (flags &amp; ANET_CONNECT_REUSEPORT &amp;&amp; anetSetReusePort(err, s) != ANET_OK)
            goto error;
        // 调用fcntl设置 O_NONBLOCK
        if (flags &amp; ANET_CONNECT_NONBLOCK &amp;&amp; anetNonBlock(err,s) != ANET_OK)
            goto error;
        if (source_addr) {
            int bound = 0;
            /* Using getaddrinfo saves us from self-determining IPv4 vs IPv6 */
            // 解析源地址
            if ((rv = getaddrinfo(source_addr, NULL, &amp;hints, &amp;bservinfo)) != 0)
            {
                anetSetError(err, "%s", gai_strerror(rv));
                goto error;
            }
            for (b = bservinfo; b != NULL; b = b-&gt;ai_next) {
                // 绑定到第一个源地址
                if (bind(s,b-&gt;ai_addr,b-&gt;ai_addrlen) != -1) {
                    bound = 1;
                    break;
                }
            }
            freeaddrinfo(bservinfo);
            if (!bound) {
                // 绑定源地址失败，跳转到Best Effort绑定
                anetSetError(err, "bind: %s", strerror(errno));
                goto error;
            }
        }
        // 发起连接
        if (connect(s,p-&gt;ai_addr,p-&gt;ai_addrlen) == -1) {
            // 我们的场景下套接字是非阻塞的，因此这里会立即返回EINPROGRESS，属于预期行为
            if (errno == EINPROGRESS &amp;&amp; flags &amp; ANET_CONNECT_NONBLOCK)
                goto end;
            // 其它错误均认为失败，尝试连接下一个Master地址
            close(s);
            s = ANET_ERR;
            continue;
        }

        goto end;
    }
    if (p == NULL)
        anetSetError(err, "creating socket: %s", strerror(errno));

error:
    if (s != ANET_ERR) {
        close(s);
        s = ANET_ERR;
    }

end:
    freeaddrinfo(servinfo);

    // 上面指定源地址，绑定失败时跳转到此处。尝试不指定源地址来连接
    if (s == ANET_ERR &amp;&amp; source_addr &amp;&amp; (flags &amp; ANET_CONNECT_BE_BINDING)) {
        return anetTcpGenericConnect(err,addr,port,NULL,flags);
    } else {
        return s;
    }
}</pre>
<p>尽管可以确定connectWithMaster调用的anetTcpGenericConnect就是发生阻塞的地方，但是从代码上看不出什么问题，就是简单的socket、bind，外加一个非阻塞的connect操作。</p>
<div class="blog_h3"><span class="graybg">请求处理逻辑</span></div>
<p>从现象上我们已经看到了，复制定时器卡顿的时候，请求处理也无法进行。通过代码分析，也明确了卡顿期间，复制定时器持有Master的客户端的锁。</p>
<p>那么，关于请求处理（线程？）会和复制定时器产生锁争用的猜测是否正确呢？</p>
<div class="blog_h2"><span class="graybg">单步跟踪</span></div>
<p>为了精确定位阻塞的代码，我们使用GDB进行单步跟踪：</p>
<pre class="crayon-plain-tag">#              需要特权模式，否则无法加载符号表
docker run -it --rm --name gdb --privileged --net=host --pid=host --entrypoint gdb docker.gmem.cc/debug

(gdb) attach 449
(gdb) break replication.cpp:3084
# 连续执行s，以step into anet.c
(gdb) s
# 连续执行n命令
(gdb) n
# 卡顿后，查看变量
# 解析的地址
(gdb) p addr
$2 = 0x7f2f31411281 "keydb-0.keydb"
# getaddrinfo的返回值
(gdb) p rv
$3 = -3</pre>
<p>进入anetTcpGenericConnect后，逐行执行，多次测试，均在<pre class="crayon-plain-tag">anet.c</pre>的291行出现卡顿：</p>
<pre class="crayon-plain-tag">if ((rv = getaddrinfo(addr,portstr,&amp;hints,&amp;servinfo)) != 0) {
    anetSetError(err, "%s", gai_strerror(rv));
    return ANET_ERR;
}</pre>
<p>也就是说，调用getaddrinfo函数耗时可能长达数秒。这是来自glibc的标准函数，用于将主机名解析为IP地址。</p>
<p>调试过程中发现此函数的返回值是-3，我们的场景中，需要解析的地址是keydb-0.keydb，卡顿时函数的返回值是-3，<pre class="crayon-plain-tag">man getaddrinfo</pre>可以了解到此返回值的意义：</p>
<p style="padding-left: 30px;">EAI_AGAIN  The name server returned a temporary failure indication. Try again later.</p>
<p>乍看起来，好像是<a href="/tcp-ip-study-note#dns">DNS</a>服务器，也就是K8S的<a href="/coredns-study-note">CoreDNS</a>存在问题。但无法解释此时keydb-2.keydb没有受到影响？</p>
<div class="blog_h2"><span class="graybg">检查CoreDNS</span></div>
<p>为了确认CoreDNS是否存在问题，我们分别在宿主机上、两个实例的网络命名空间中进行验证：</p>
<pre class="crayon-plain-tag"># nslookup keydb-0.keydb.default.svc.cluster.local 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb.default.svc.cluster.local: NXDOMAIN

# nslookup keydb-0.keydb.svc.cluster.local 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb.svc.cluster.local: NXDOMAIN

# nslookup keydb-0.keydb.cluster.local 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb.cluster.local: NXDOMAIN

# nslookup keydb-0.keydb 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb: SERVFAIL</pre>
<p>反复测试循环测试，没有任何解析缓慢的现象。此外，查看CoreDNS的日志，我们也发现了来自keydb-1.keydb和keydb-2.keydb的查询请求，请求都是通过UDP协议发送的，处理耗时都是亚毫秒级别。</p>
<p>也就是说，从KeyDB实例所在宿主机/命名空间到CoreDNS的网络链路、CoreDNS服务器自身，都没有问题。</p>
<p>这就让人头疼了……难道问题出在getaddrinfo函数内部？或者在单步跟踪时判断错误，问题和DNS无关？为了确认，我们在CoreDNS上动了点手脚，强制将keydb-0.keydb解析到一个不存在的IP地址：</p>
<pre class="crayon-plain-tag">.:53 {
    # ...
    hosts {
        192.168.144.51  keydb-1.keydb
    }
    # ...
}</pre>
<p>结果很快，卡顿的问题就消失了。所以，我们更加怀疑问题出在getaddrinfo函数上了。</p>
<div class="blog_h2"><span class="graybg">调试getaddrinfo</span></div>
<p>查看文件/etc/lsb-release，可以看到KeyDB镜像是基于Ubuntu 18.04.4 LTS构建的，使用的libc6版本是2.27-3ubuntu1。</p>
<p>在launchpad.net找到了它的<a href="http://launchpadlibrarian.net/365856914/libc6-dbg_2.27-3ubuntu1_amd64.deb">调试文件</a>和<a href="http://launchpadlibrarian.net/365856911/glibc-source_2.27-3ubuntu1_all.deb">源码</a>。下载deb包，解压后复制到GDB容器，然后设置一下调试文件目录，就可以step into到glibc的代码进行跟踪了：</p>
<pre class="crayon-plain-tag">ar x libc6-dbg_2.27-3ubuntu1_amd64.deb
tar -xf data.tar.xz 

# 拷贝到我们正在运行GDB的容器
docker cp usr gdb:/root

# 修改调试文件搜索目录
(gdb) set debug-file-directory /root/usr/lib/debug
# 打断点，下面是缓慢的执行路径
(gdb) b anet.c:291
(gdb) b getaddrinfo.c:342
(gdb) b getaddrinfo.c:786  
# (gdb) print fct4
# $2 = (nss_gethostbyname4_r) 0x7f32f97e9a70 &lt;_nss_dns_gethostbyname4_r&gt;
(gdb) b dns-host.c:317
(gdb) b res_query.c:336
(gdb) b res_query.c:495                       # invoke __res_context_querydomain
(gdb) b res_query.c:601                       # invoke __res_context_query    
(gdb) b res_query.c:216                       # invoke __res_context_send
(gdb) b res_send.c:1066 if buflen==45         # send_dg</pre>
<p>通过调试，我们发现getaddrinfo会依次对4个名字进行DNS查询：</p>
<p style="padding-left: 30px;">keydb-0.keydb.default.svc.cluster.local. <br />keydb-0.keydb.svc.cluster.local. <br />keydb-0.keydb.cluster.local.<br />keydb-0.keydb.</p>
<p>CoreDNS的日志显示，所有请求都快速的处理完毕：</p>
<pre class="crayon-plain-tag">4242 "A IN keydb-0.keydb.default.svc.cluster.local. udp 68 false 512" NXDOMAIN qr,aa,rd 161 0.000215337s
38046 "AAAA IN keydb-0.keydb.default.svc.cluster.local. udp 68 false 512" NXDOMAIN qr,aa,rd 161 0.000203934s

23194 "A IN keydb-0.keydb.svc.cluster.local. udp 63 false 512" NXDOMAIN qr,aa,rd 156 0.000301011s
23722 "AAAA IN keydb-0.keydb.svc.cluster.local. udp 63 false 512" NXDOMAIN qr,aa,rd 156 0.000125386s

36552 "A IN keydb-0.keydb.cluster.local. udp 59 false 512" NXDOMAIN qr,aa,rd 152 0.000281247s
217 "AAAA IN keydb-0.keydb.cluster.local. udp 59 false 512" NXDOMAIN qr,aa,rd 152 0.000150689s

6776 "A IN keydb-0.keydb. udp 45 false 512" NOERROR - 0 0.000196686s
6776 "A IN keydb-0.keydb. udp 45 false 512" NOERROR - 0 0.000157011s </pre>
<p>最后一个名字，也就是传递给getaddrinfo的原始请求keydb-0.keydb.的处理过程有以下值得注意的点：</p>
<ol>
<li>从GDB角度来看，<span style="background-color: #c0c0c0;">卡顿就是在解析该名字时出现</span></li>
<li>从CoreDNS日志上看，没有AAAA请求。由于KeyDB<span style="background-color: #c0c0c0;">指定了AF_UNSPEC，getaddrinfo会同时发送并等待A/AAAA应答</span>。可能<span style="background-color: #c0c0c0;">因为某种原因，该名字的AAAA解析过程没有完成，导致getaddrinfo一直等待到超时</span>。作为对比，没有卡顿的keydb-2的A/AAAA查询处理过程都是正常的</li>
<li>其它名字是一次A请求，一次AAAA请求。该名字却是两次A请求，而且，<span style="background-color: #c0c0c0;">第一次A请求日志出现了数秒后，第二次日志才出现</span>。有可能第二次是getaddrinfo没有收到应答而进行的重试</li>
<li>前三个名字分别的错误码是NXDOMAIN，该名字的错误码却是<a href="/tcp-ip-study-note#dns-rtnmsg">NOERROR</a>。通过nslookup/dig查询，错误码却是SERVFAIL，难道是CoreDNS日志有BUG？尽管如此，是否不同的错误码影响了getaddrinfo的行为</li>
</ol>
<div class="blog_h2"><span class="graybg">抓包分析</span></div>
<p>glibc的代码是优化过（<a href="https://stackoverflow.com/questions/30089652/glibc-optimizations-required">也必须优化</a>）的，GDB跟踪起来相当耗时，因此我们打算换一个角度来定位问题。基于上一节的分析，我们相信实例keydb-1.keydb在发送DNS请求的时候存在超时或丢包的情况，可以抓包来证实：</p>
<pre class="crayon-plain-tag"># 进入keydb-1.keydb的网络命名空间
nsenter -t 449 --net
# 抓包
tcpdump -i any -vv -nn udp port 53</pre>
<p>抓包的结果如下： </p>
<p style="padding-left: 30px;"><span style="background-color: #c0c0c0;">对 keydb-0.keydb.default.svc.cluster.local.  的A请求</span><br /><em> 172.29.1.69.42083 &gt; 10.96.0.10.53: [bad udp cksum 0xb829 -&gt; 0x95aa!] 22719+ A? keydb-0.keydb.default.svc.cluster.local. (68)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.42083: [bad udp cksum 0xb886 -&gt; 0x3f57!] 22719 NXDomain*- q: A? keydb-0.keydb.default.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (161)</em><br /><span style="background-color: #c0c0c0;">对keydb-0.keydb.default.svc.cluster.local.  的AAAA请求，注意，仍然使用之前的UDP套接字</span><br /> 172.29.1.69.42083 &gt; 10.96.0.10.53: [bad udp cksum 0xb829 -&gt; 0x4d76!] 41176+ AAAA? keydb-0.keydb.default.svc.cluster.local. (68)<br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.42083: [bad udp cksum 0xb886 -&gt; 0xf722!] 41176 NXDomain*- q: AAAA? keydb-0.keydb.default.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (161)</em><br /><span style="background-color: #c0c0c0;">对 keydb-0.keydb.svc.cluster.local. 的A请求，注意，这里使用了新的UDP套接字</span><br /> <em>172.29.1.69.45508 &gt; 10.96.0.10.53: [bad udp cksum 0xb824 -&gt; 0x3b5e!] 45156+ A? keydb-0.keydb.svc.cluster.local. (63)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.45508: [bad udp cksum 0xb881 -&gt; 0x21ce!] 45156 NXDomain*- q: A? keydb-0.keydb.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (156)</em><br /><span style="background-color: #c0c0c0;">对keydb-0.keydb.svc.cluster.local. 的AAAA请求</span><br /> 172.29.1.69.45508 &gt; 10.96.0.10.53: [bad udp cksum 0xb824 -&gt; 0x8a4e!] 18036+ AAAA? keydb-0.keydb.svc.cluster.local. (63)<br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.45508: [bad udp cksum 0xb881 -&gt; 0x70be!] 18036 NXDomain*- q: AAAA? keydb-0.keydb.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (156)</em><br /><span style="background-color: #c0c0c0;">对 keydb-0.keydb.cluster.local. 的A请求</span><br /><em>172.29.1.69.48243 &gt; 10.96.0.10.53: [bad udp cksum 0xb820 -&gt; 0x5054!] 2718+ A? keydb-0.keydb.cluster.local. (59)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.48243: [bad udp cksum 0xb87d -&gt; 0x36c4!] 2718 NXDomain*- q: A? keydb-0.keydb.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (152)</em><br /><span style="background-color: #c0c0c0;">对 keydb-0.keydb.cluster.local. 的AAAA请求</span><br /> <em>172.29.1.69.48243 &gt; 10.96.0.10.53: [bad udp cksum 0xb820 -&gt; 0x5147!] 61098+ AAAA? keydb-0.keydb.cluster.local. (59)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain，这里开始，我们保留时间戳那一行日志</span><br /><em>14:42:15.028168 IP (tos 0x0, ttl 63, id 44636, offset 0, flags [DF], proto UDP (17), length 180)</em><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.48243: [bad udp cksum 0xb87d -&gt; 0x37b7!] 61098 NXDomain*- q: AAAA? keydb-0.keydb.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (152)</em><br /><span style="background-color: #c0c0c0;">对keydb-0.keydb的A请求，这里还没有出现卡顿</span><br /><em>14:42:15.028328 IP (tos 0x0, ttl 64, id 30583, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 172.29.1.69.47652 &gt; 10.96.0.10.53: [bad udp cksum 0xb812 -&gt; 0x181e!] 26682+ A? keydb-0.keydb. (45)</em><br /><span style="background-color: #c0c0c0;">很快接收到CoreDNS的ServFail应答，抓包和我们nslookup/dig的错误码一致，CoreDNS日志显示的应该不正常</span><br /><span style="background-color: #c0c0c0;">猜测“有可能第二次是getaddrinfo没有收到应答而进行的重试”被排除，至少说没收到应答不是网络层面的原因</span><br /><em>14:42:15.028651 IP (tos 0x0, ttl 63, id 44637, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.47652: [bad udp cksum 0xb812 -&gt; 0x981b!] 26682 ServFail- q: A? keydb-0.keydb. 0/0/0 (45)</em><br /><span style="background-color: #c0c0c0;">再一次对keydb-0.keydb.的A请求，<strong>注意时间戳，刚好5秒之后</strong>，这是默认DNS请求超时。<strong>还是使用之前的套接字</strong></span><br /><em>14:42:20.029271 IP (tos 0x0, ttl 64, id 33006, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 172.29.1.69.47652 &gt; 10.96.0.10.53: [bad udp cksum 0xb812 -&gt; 0x181e!] 26682+ A? keydb-0.keydb. (45)</em><br /><span style="background-color: #c0c0c0;">很快接收到CoreDNS的ServFail应答</span><br /><em>14:42:20.029812 IP (tos 0x0, ttl 63, id 46397, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.47652: [bad udp cksum 0xb812 -&gt; 0x981b!] 26682 ServFail- q: A? keydb-0.keydb. 0/0/0 (45)</em></p>
<p>通过上述分析我们可以相信，keydb-1.keydb容器到CoreDNS之间的DNS通信是没有问题的。但是，getaddrinfo似乎没有收到keydb-0.keydb的第一次应答，并且在超时（5s）之后进行重试</p>
<div class="blog_h2"><span class="graybg">Conntrack竞态条件</span></div>
<p>tcpdump和应用程序之间，还有个netfilter框架。回想起之前阅读过的文章：<a href="/dns-problems-on-k8s">Kubernetes上和DNS相关的问题</a>，conntrack相关的竞态条件可能导致DNS查询5秒超时。遗憾的是，这里的故障和此竞态条件无关：</p>
<ol>
<li>通过<pre class="crayon-plain-tag">conntrack -S</pre>看到的<pre class="crayon-plain-tag">insert_failed</pre>是0</li>
<li>故障一旦出现，就每次都会超时5s，没有竞态条件的随机性</li>
<li>如果是conntrack竞态条件导致，无法解释为什么前面3个名字解析正常，也无法解释为什么CoreDNS中配置一个静态解析故障就消失</li>
</ol>
<div class="blog_h1"><span class="graybg">深入理解</span></div>
<div class="blog_h2"><span class="graybg">getaddrinfo</span></div>
<p>在IPv4中，我们使用<pre class="crayon-plain-tag">gethostbyname</pre>实现主机名到地址的解析。<pre class="crayon-plain-tag">getaddrinfo</pre>也用于地址解析，而且它是协议无关的，既可用于IPv4也可用于IPv6。它的原型如下：</p>
<pre class="crayon-plain-tag">int getaddrinfo(const char* hostname,  // 主机名，可以使用IP地址或者DNS名称
                const char* service,   // 服务名，可以使用端口号或者/etc/services中的服务名
                const struct addrinfo* hints, // 可以NULL，或者一个addrinfo，提示调用者想得到的信息类型
                struct addrinfo** res);  // 解析得到的addrinfo，地址的链表</pre>
<p>此函数返回的是套接字地址信息的链表，地址信息存储在下面的addrinfo结构中。参数<pre class="crayon-plain-tag">hints</pre>会影响getaddrinfo的行为，提示信息同样存放在addrinfo结构中：</p>
<pre class="crayon-plain-tag">struct addrinfo
{
  // 额外的提示标记
  int ai_flags;	
  // 提示需要查询哪些地址族，默认AF_UNSPEC，这意味着同时查询IPv4和IPv6地址
  // 也就是同时发起A/AAAA查询
  int ai_family;
  // 提示偏好的套接字类型，例如SOCK_STREAM|SOCK_DGRAM，默认可以返回任何套接字类型
  int ai_socktype;
  // 提示返回的套接字地址的协议类型
  int ai_protocol;

  // 套接字地址
  socklen_t ai_addrlen;
  struct sockaddr *ai_addr;
  // ...
  // 指向链表的下一条目
  struct addrinfo *ai_next;
};</pre>
<p>很多软件调用getaddrinfo的时候，都会指定AF_UNSPEC（或者不提供hints，效果一样），例如KeyDB。但是，很多运行环境根本没有IPv6支持，这就凭白的给DNS服务器增加了负担。这也是在K8S中查看CoreDNS日志，总是会发现很多AAAA记录的原因。</p>
<div class="blog_h3"><span class="graybg">解析流程概览</span></div>
<p>KeyDB 5.3.3使用的glibc版本是2.27。函数getaddrinfo过于冗长，这里就不贴出来了，大概梳理一下：</p>
<ol>
<li>如果可能，它会通过/var/run/nscd/socket访问DNS缓存服务，我们没有这个服务</li>
<li>初始化NSS的hosts数据库，如果没有在文件中配置，则默认使用<pre class="crayon-plain-tag">hosts: dns [!UNAVAIL=return] files</pre>，我们的环境下配置是<pre class="crayon-plain-tag">hosts: files dns</pre></li>
<li>通过<a href="/linux-faq#nss">NSS</a>进行名字查询，实际上是调用<pre class="crayon-plain-tag">gethostbyname4_r</pre>函数：
<ol>
<li>查找files源，调用<pre class="crayon-plain-tag">_nss_files_gethostbyname4_r</pre>函数，也就是打开/etc/hosts查找。K8S容器中，/etc/hosts中仅仅存在当前Pod的条目，因此files源不会匹配</li>
<li>查找dns源，调用<pre class="crayon-plain-tag">_nss_dns_gethostbyname4_r</pre>函数：
<ol>
<li>读取/etc/resolv.conf构建<pre class="crayon-plain-tag">resolv_context</pre>。我们的环境下，配置文件内容为：<br />
<pre class="crayon-plain-tag">nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5</pre>
</li>
<li>
<p>调用<pre class="crayon-plain-tag">__res_context_search</pre>， 执行DNS查找逻辑。它会<span style="background-color: #c0c0c0;">将上面的search domain作为域名后缀，产生多个名字，逐个尝试。每个名字查询失败时都会重试，重试时尽可能选择不同的DNS服务器。可能同时发起A/AAA查询</span></p>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">DNS搜索逻辑</span></div>
<p>__res_context_search()首先会计算一下，待查找名字中的dot的数量。如果<span style="background-color: #c0c0c0;">名字以dot结尾，或者dot数量大于等于ndots</span>，则直接调用<pre class="crayon-plain-tag">__res_context_querydomain</pre>向DNS服务器发请求，该函数会同时发起A/AAAA查询。</p>
<p>否则，它会根据/etc/resolv.conf中的search domain列表，给待查找的名字加后缀，然后多次向DNS服务器发请求。我们的环境下，待查找名字为keydb-0.keydb，getaddrinfo函数会依次尝试：</p>
<p style="padding-left: 30px;">keydb-0.keydb.default.svc.cluster.local. <br />keydb-0.keydb.svc.cluster.local. <br />keydb-0.keydb.cluster.local.<br />keydb-0.keydb.</p>
<p>需要注意：</p>
<ol>
<li>向DNS服务器发请求，仍然是由__res_context_querydomain()负责</li>
<li>一旦查找成功，就立即返回不再尝试其它search domain</li>
<li><span style="background-color: #c0c0c0;">不加修饰的原始名字，会放在最后尝试</span></li>
</ol>
<p>在K8S中，*.cluster.local一般都由CoreDNS自身负责，处理速度会很快。至于keydb-0.keydb.的处理速度，如果为CoreDNS配置了上游DNS，则处理速度依赖于外部环境。</p>
<p>__res_context_querydomain仅仅是在domain参数不为空的时候，将name和domain连接起来，然后调用<pre class="crayon-plain-tag">__res_context_query</pre>函数。</p>
<div class="blog_h3"><span class="graybg">DNS查询过程</span></div>
<p>__res_context_query负责和DNS服务器的交互，完成<span style="background-color: #c0c0c0;">单个名字的DNS查</span>询。它会调用<pre class="crayon-plain-tag">__res_context_mkquery</pre><span style="background-color: #c0c0c0;">构建一个查询请求（对应DNS报文），然后发送</span>，然后等待应答。这是一个阻塞的过程，KeyDB在期望非阻塞的代码路径下调用getaddrinfo且没有任何缓存机制，同时还加了锁，我觉得是不妥的。这导致DNS缓慢/不可用会极大的影响KeyDB的服务质量。</p>
<p>发送DNS请求的代码在<pre class="crayon-plain-tag">__res_context_send</pre>中，<span style="background-color: #c0c0c0;">重试逻辑</span>发生在该函数中，我们的环境下重试次数为2，这解释了两次keydb-0.keydb. A查询：</p>
<pre class="crayon-plain-tag">//                  重试次数，statp-&gt;retry为2
for (try = 0; try &lt; statp-&gt;retry; try++) {
    // 如果有多个DNS服务器，重试时会轮询它们
    for (unsigned ns_shift = 0; ns_shift &lt; statp-&gt;nscount; ns_shift++)
    {
    unsigned int ns = ns_shift + ns_offset;
    if (ns &gt;= statp-&gt;nscount)
        ns -= statp-&gt;nscount;

    same_ns:
    if (__glibc_unlikely (v_circuit)) {
        // ...
    } else {
        // 使用UDP方式发送请求
        n = send_dg(statp, buf, buflen, buf2, buflen2,
                &amp;ans, &amp;anssiz, &amp;terrno,
                ns, &amp;v_circuit, &amp;gotsomewhere, ansp,
                ansp2, nansp2, resplen2, ansp2_malloced);
        if (n &lt; 0)
            return (-1);
        if (n == 0 &amp;&amp; (buf2 == NULL || *resplen2 == 0))
            // 如果有多个DNS服务器的时候，会尝试下一个
            goto next_ns;
        // ...
    }
    return (resplen);
next_ns: ;
   } /*foreach ns*/
} /*foreach retry*/</pre>
<p>通常情况下，都是通过UDP协议进行DNS查询的，因此会调用<pre class="crayon-plain-tag">send_dg</pre>函数。在我们的场景中，两次尝试均5秒超时（尽管抓包显示应答报文很快就收到），__res_context_send设置错误码ETIMEDOUT，返回-1：</p>
<pre class="crayon-plain-tag">__res_iclose(statp, false);
if (!v_circuit) {
    if (!gotsomewhere)
        __set_errno (ECONNREFUSED);	/* no nameservers found */
    else
        __set_errno (ETIMEDOUT);	/* no answer obtained */
} else
    __set_errno (terrno);
return (-1);</pre>
<p>而它的调用者__res_context_query则在返回值是-1的时候，设置错误码TRY_AGAIN，这就是我们从KeyDB日志上看到报错The name server returned a temporary failure indication的原因：</p>
<pre class="crayon-plain-tag">if (n &lt; 0) {
    RES_SET_H_ERRNO(statp, TRY_AGAIN);
    return (n);
}</pre>
<div class="blog_h3"><span class="graybg">缓慢之源</span></div>
<p>缓慢的根源是send_dg函数，它阻塞了5秒。该函数的原型如下：</p>
<pre class="crayon-plain-tag">// 如果没有错误，返回第一个应答的字节数
// 对于可恢复错误，返回0；对于不可恢复错误，返回负数
static int send_dg(
    // 各种选项、DNS服务器列表、指向DNS服务器的套接字（文件描述符）
    res_state statp,
    // 查询请求1的缓冲区 和 长度
	const u_char *buf, int buflen, 
    // 查询请求2的缓冲区 和 长度
    const u_char *buf2, int buflen2,
    // 收到的第1个应答   和 最大长度
	u_char **ansp,      int *anssizp,
    // 出现错误时，将errno设置到此字段
	int *terrno, 
    // 使用的DNS服务器的序号
    int ns, 
    // 如果由于UDP数据报的限制而导致截断，则v_circuit设置为1，提示调用者使用TCP方式重试
    int *v_circuit, 
    // 提示访问DNS服务器时，是拒绝服务还是超时。如果是超时则设置为1
    int *gotsomewhere,
    // 提示遇到超长应答的时候，是否重新分配缓冲区
    u_char **anscp,
    // 收到的第2个应答 和 最大长度
	u_char **ansp2, int *anssizp2, 
    // 第2个应答的实际长度 是否为第2个应答重新分配了缓冲区
    int *resplen2, int *ansp2_malloced);</pre>
<p>该函数会向指定序号的DNS服务器发送DNS查询。它同时支持IPv4/IPv6查询，你可以传递两个查询请求，分别放在buf和buf2参数中。<span style="background-color: #c0c0c0;">如果提供了两个查询请求，默认使用并行方式发送查询</span>。设置选项<pre class="crayon-plain-tag">RES_SINGLKUP</pre>可以强制串行发送；设置选项<pre class="crayon-plain-tag">RES_SNGLKUPREOP</pre>可以<span style="background-color: #c0c0c0;">强制串行发送，同时总是关闭并重新打开套接字</span>，这样可以和某些行为异常的DNS服务器一起工作。</p>
<p>由于请求可以并行发送，因此应答到达的顺序是不确定的。<span style="background-color: #c0c0c0;">先收到的</span>应答会存放在ansp中，入参最大长度anssizp。入参anscp用于提示，应答过长的时候的处理方式：</p>
<ol>
<li>如果anscp不为空：则自动分配新的缓冲区，并且ansp、anscp都被修改为指向该缓冲区</li>
<li>如果anscp为空：则过长的部分被截断，DNS包头的TC字段被设置为1</li>
</ol>
<p>glibc的2.27-3ubuntu1版本中send_dg的完整实现如下：</p>
<pre class="crayon-plain-tag">static int
send_dg(res_state statp,
	const u_char *buf, int buflen, const u_char *buf2, int buflen2,
	u_char **ansp, int *anssizp,
	int *terrno, int ns, int *v_circuit, int *gotsomewhere, u_char **anscp,
	u_char **ansp2, int *anssizp2, int *resplen2, int *ansp2_malloced)
{
	const HEADER *hp = (HEADER *) buf;
	const HEADER *hp2 = (HEADER *) buf2;
	struct timespec now, timeout, finish;
	struct pollfd pfd[1];
	int ptimeout;
	struct sockaddr_in6 from;
	int resplen = 0;
	int n;

	/*
	 * Compute time for the total operation.
	 */
	int seconds = (statp-&gt;retrans &lt;&lt; ns); // 0. 计算超时
	if (ns &gt; 0)
		seconds /= statp-&gt;nscount;
	if (seconds &lt;= 0)
		seconds = 1;
	bool single_request_reopen = (statp-&gt;options &amp; RES_SNGLKUPREOP) != 0; // 0. 确定是否并行请求
	bool single_request = (((statp-&gt;options &amp; RES_SNGLKUP) != 0)
			       | single_request_reopen);
	int save_gotsomewhere = *gotsomewhere;

	int retval;
 retry_reopen: // tx1. 如果套接字没有创建，则创建， SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC，非阻塞
	retval = reopen (statp, terrno, ns); // tx1. 然后调用一下connect操作，不发数据
	if (retval &lt;= 0)
	  {
	    if (resplen2 != NULL)
	      *resplen2 = 0;
	    return retval;
	  }
 retry:
	evNowTime(&amp;now);
	evConsTime(&amp;timeout, seconds, 0);
	evAddTime(&amp;finish, &amp;now, &amp;timeout);
	int need_recompute = 0;
	int nwritten = 0;
	int recvresp1 = 0;  // 用于标记请求1的应答是否接收到
	/* Skip the second response if there is no second query.
	   To do that we mark the second response as received.  */
	int recvresp2 = buf2 == NULL; // 用于标记请求2的应答是否接收到，如果buf2为空则立即标记为1
	pfd[0].fd = EXT(statp).nssocks[ns];
	pfd[0].events = POLLOUT; // tx2. 准备监听可写事件
 wait:
	if (need_recompute) {
	recompute_resend:
		evNowTime(&amp;now);
		if (evCmpTime(finish, now) &lt;= 0) {
		poll_err_out:
			return close_and_return_error (statp, resplen2);
		}
		evSubTime(&amp;timeout, &amp;finish, &amp;now);
		need_recompute = 0;
	}
	/* Convert struct timespec in milliseconds.  */
	ptimeout = timeout.tv_sec * 1000 + timeout.tv_nsec / 1000000;

	n = 0;
	if (nwritten == 0)
	  n = __poll (pfd, 1, 0); // tx2. 等待套接字可写
	if (__glibc_unlikely (n == 0))       {
		n = __poll (pfd, 1, ptimeout); // rx1. 等待套接字可读，5秒超时
		need_recompute = 1;
	}
	if (n == 0) {
		if (resplen &gt; 1 &amp;&amp; (recvresp1 || (buf2 != NULL &amp;&amp; recvresp2)))
		  { // 处理某些DNS服务器不支持处理并行请求的场景
		    /* There are quite a few broken name servers out
		       there which don't handle two outstanding
		       requests from the same source.  There are also
		       broken firewall settings.  If we time out after
		       having received one answer switch to the mode
		       where we send the second request only once we
		       have received the first answer.  */
		    if (!single_request)
		      {
			statp-&gt;options |= RES_SNGLKUP; // 这里永久改变为串行发送请求。statp是线程本地变量，
			single_request = true;         // KeyDB复制定时任务总是在同一线程中运行
			*gotsomewhere = save_gotsomewhere;
			goto retry;
		      }
		    else if (!single_request_reopen)
		      {
			statp-&gt;options |= RES_SNGLKUPREOP;
			single_request_reopen = true;
			*gotsomewhere = save_gotsomewhere;
			__res_iclose (statp, false);
			goto retry_reopen;
		      }

		    *resplen2 = 1;
		    return resplen;
		  }

		*gotsomewhere = 1;
		if (resplen2 != NULL)
		  *resplen2 = 0;
		return 0;
	}
	if (n &lt; 0) {
		if (errno == EINTR)
			goto recompute_resend;

		goto poll_err_out;
	}
	__set_errno (0);
	if (pfd[0].revents &amp; POLLOUT) { // tx3. 监听到可写事件
#ifndef __ASSUME_SENDMMSG
		static int have_sendmmsg;
#else
# define have_sendmmsg 1
#endif
		if (have_sendmmsg &gt;= 0 &amp;&amp; nwritten == 0 &amp;&amp; buf2 != NULL // 查询请求2不为空
		    &amp;&amp; !single_request) // 且允许并行发送
		  {
		    struct iovec iov[2];
		    struct mmsghdr reqs[2];
		    reqs[0].msg_hdr.msg_name = NULL;
		    reqs[0].msg_hdr.msg_namelen = 0;
		    reqs[0].msg_hdr.msg_iov = &amp;iov[0];
		    reqs[0].msg_hdr.msg_iovlen = 1;
		    iov[0].iov_base = (void *) buf;
		    iov[0].iov_len = buflen;
		    reqs[0].msg_hdr.msg_control = NULL;
		    reqs[0].msg_hdr.msg_controllen = 0;

		    reqs[1].msg_hdr.msg_name = NULL;
		    reqs[1].msg_hdr.msg_namelen = 0;
		    reqs[1].msg_hdr.msg_iov = &amp;iov[1];
		    reqs[1].msg_hdr.msg_iovlen = 1;
		    iov[1].iov_base = (void *) buf2;
		    iov[1].iov_len = buflen2;
		    reqs[1].msg_hdr.msg_control = NULL;
		    reqs[1].msg_hdr.msg_controllen = 0;
            // 发送消息，注意这里同时发送2个查询请求，返回值是实际发送的数量
		    int ndg = __sendmmsg (pfd[0].fd, reqs, 2, MSG_NOSIGNAL);
		    if (__glibc_likely (ndg == 2))
		      {
			if (reqs[0].msg_len != buflen
			    || reqs[1].msg_len != buflen2)
			  goto fail_sendmmsg;

			pfd[0].events = POLLIN;
			nwritten += 2;
		      }
		    else if (ndg == 1 &amp;&amp; reqs[0].msg_len == buflen)
		      goto just_one;
		    else if (ndg &lt; 0 &amp;&amp; (errno == EINTR || errno == EAGAIN))
		      goto recompute_resend;
		    else
		      {
#ifndef __ASSUME_SENDMMSG
			if (__glibc_unlikely (have_sendmmsg == 0))
			  {
			    if (ndg &lt; 0 &amp;&amp; errno == ENOSYS)
			      {
				have_sendmmsg = -1;
				goto try_send;
			      }
			    have_sendmmsg = 1;
			  }
#endif

		      fail_sendmmsg:
			return close_and_return_error (statp, resplen2);
		      }
		  }
		else
		  { // 不支持并行发送
		    ssize_t sr;
#ifndef __ASSUME_SENDMMSG
		  try_send:
#endif
		    if (nwritten != 0)
		      sr = send (pfd[0].fd, buf2, buflen2, MSG_NOSIGNAL);
		    else
		      sr = send (pfd[0].fd, buf, buflen, MSG_NOSIGNAL); // tx4. 发送查询请求1

		    if (sr != (nwritten != 0 ? buflen2 : buflen)) { // 发送长度和缓冲区长度不匹配
		      if (errno == EINTR || errno == EAGAIN) // 如果原因是EINTR或EAGAIN，则尝试重发
			goto recompute_resend;
		      return close_and_return_error (statp, resplen2);
		    }
		  just_one:
		    if (nwritten != 0 || buf2 == NULL || single_request)
		      pfd[0].events = POLLIN;  // 串行模式下，后续只需监听可读时间
		    else
		      pfd[0].events = POLLIN | POLLOUT; // 并行发送，如果实际仅发送1个消息，跳转到这里。后续需要继续写入发送失败的那个消息
		    ++nwritten;
		  }
		goto wait; // tx4. 发送完毕，回到上面的wait分支等待应答
	} else if (pfd[0].revents &amp; POLLIN) { // rx2. 监听到套接字可读
		int *thisanssizp; // 本次读数据到哪个缓冲
		u_char **thisansp;
		int *thisresplenp;

		if ((recvresp1 | recvresp2) == 0 || buf2 == NULL) {
			/* We have not received any responses
			   yet or we only have one response to
			   receive.  */
			thisanssizp = anssizp;
			thisansp = anscp ?: ansp;
			assert (anscp != NULL || ansp2 == NULL);
			thisresplenp = &amp;resplen;
		} else {
			thisanssizp = anssizp2;
			thisansp = ansp2;
			thisresplenp = resplen2;
		}

		if (*thisanssizp &lt; MAXPACKET
		    /* If the current buffer is not the the static
		       user-supplied buffer then we can reallocate
		       it.  */
		    &amp;&amp; (thisansp != NULL &amp;&amp; thisansp != ansp)
#ifdef FIONREAD
		    /* Is the size too small?  */
		    &amp;&amp; (ioctl (pfd[0].fd, FIONREAD, thisresplenp) &lt; 0
			|| *thisanssizp &lt; *thisresplenp)
#endif
                    ) {
			/* Always allocate MAXPACKET, callers expect
			   this specific size.  */
			u_char *newp = malloc (MAXPACKET);
			if (newp != NULL) {
				*thisanssizp = MAXPACKET;
				*thisansp = newp;
				if (thisansp == ansp2)
				  *ansp2_malloced = 1;
			}
		}
		/* We could end up with truncation if anscp was NULL
		   (not allowed to change caller's buffer) and the
		   response buffer size is too small.  This isn't a
		   reliable way to detect truncation because the ioctl
		   may be an inaccurate report of the UDP message size.
		   Therefore we use this only to issue debug output.
		   To do truncation accurately with UDP we need
		   MSG_TRUNC which is only available on Linux.  We
		   can abstract out the Linux-specific feature in the
		   future to detect truncation.  */
		HEADER *anhp = (HEADER *) *thisansp;
		socklen_t fromlen = sizeof(struct sockaddr_in6);
		assert (sizeof(from) &lt;= fromlen);
		*thisresplenp = recvfrom(pfd[0].fd, (char*)*thisansp, // rx3. 读取应答
					 *thisanssizp, 0,
					(struct sockaddr *)&amp;from, &amp;fromlen);
		if (__glibc_unlikely (*thisresplenp &lt;= 0))       {
			if (errno == EINTR || errno == EAGAIN) {
				need_recompute = 1;
				goto wait;  // 如果EINTR|EAGAIN则重新等待
			}
			return close_and_return_error (statp, resplen2);
		}
		*gotsomewhere = 1;
		if (__glibc_unlikely (*thisresplenp &lt; HFIXEDSZ))       { // 消息比报文头长度还小，错误
			/*
			 * Undersized message.
			 */
			*terrno = EMSGSIZE;
			return close_and_return_error (statp, resplen2);
		}
		if ((recvresp1 || hp-&gt;id != anhp-&gt;id)
		    &amp;&amp; (recvresp2 || hp2-&gt;id != anhp-&gt;id)) { // 查询标识符不匹配，可能服务器缓慢，返回之前查询的应答
			/*
			 * response from old query, ignore it.
			 * XXX - potential security hazard could
			 *	 be detected here.
			 */
			goto wait;
		}
		if (!(statp-&gt;options &amp; RES_INSECURE1) &amp;&amp; // 安全性检查type1
		    !res_ourserver_p(statp, &amp;from)) {
			/*
			 * response from wrong server? ignore it.
			 * XXX - potential security hazard could
			 *	 be detected here.
			 */
			goto wait;
		}
		if (!(statp-&gt;options &amp; RES_INSECURE2) // 安全性检查type2
		    &amp;&amp; (recvresp1 || !res_queriesmatch(buf, buf + buflen,
						       *thisansp,
						       *thisansp
						       + *thisanssizp))
		    &amp;&amp; (recvresp2 || !res_queriesmatch(buf2, buf2 + buflen2,
						       *thisansp,
						       *thisansp
						       + *thisanssizp))) {
			/*
			 * response contains wrong query? ignore it.
			 * XXX - potential security hazard could
			 *	 be detected here.
			 */
			goto wait;
		}
		if (anhp-&gt;rcode == SERVFAIL ||
		    anhp-&gt;rcode == NOTIMP ||
		    anhp-&gt;rcode == REFUSED) {  //  rx4. 处理服务器不愿意处理请求的情况
		next_ns:
			if (recvresp1 || (buf2 != NULL &amp;&amp; recvresp2)) {
			  *resplen2 = 0;
			  return resplen;
			}
			if (buf2 != NULL)
			  {
			    /* No data from the first reply.  */
			    resplen = 0;
			    /* We are waiting for a possible second reply.  */
			    if (hp-&gt;id == anhp-&gt;id)
			      recvresp1 = 1;
			    else
			      recvresp2 = 1;

			    goto wait;  // 事件类型仍然是POLLIN，会导致超时
			  }

			/* don't retry if called from dig */
			if (!statp-&gt;pfcode)
			  return close_and_return_error (statp, resplen2);
			__res_iclose(statp, false);
		}
		if (anhp-&gt;rcode == NOERROR &amp;&amp; anhp-&gt;ancount == 0 // rx.4 处理nodata的情况，名字请求，请求的记录类型不存在
		    &amp;&amp; anhp-&gt;aa == 0 &amp;&amp; anhp-&gt;ra == 0 &amp;&amp; anhp-&gt;arcount == 0) {
			goto next_ns;
		}
		if (!(statp-&gt;options &amp; RES_IGNTC) &amp;&amp; anhp-&gt;tc) { // rx.4 处理应答截断的情况
			/*
			 * To get the rest of answer,
			 * use TCP with same server.
			 */
			*v_circuit = 1; // 提示使用TCP重发请求
			__res_iclose(statp, false);
			// XXX if we have received one reply we could
			// XXX use it and not repeat it over TCP...
			if (resplen2 != NULL)
			  *resplen2 = 0;
			return (1);
		}
		/* Mark which reply we received.  */
		if (recvresp1 == 0 &amp;&amp; hp-&gt;id == anhp-&gt;id)
			recvresp1 = 1;
		else
			recvresp2 = 1;
		/* Repeat waiting if we have a second answer to arrive.  */
		if ((recvresp1 &amp; recvresp2) == 0) { // 如果只有一个查询请求，recvresp2一开始就标记为1，因此不会走到这个分支
			if (single_request) { // 如果是串行模式，这里开始处理第2个请求
				pfd[0].events = POLLOUT;
				if (single_request_reopen) {  // 如果需要关闭并重新打开套接字
					__res_iclose (statp, false);
					retval = reopen (statp, terrno, ns);
					if (retval &lt;= 0)
					  {
					    if (resplen2 != NULL)
					      *resplen2 = 0;
					    return retval;
					  }
					pfd[0].fd = EXT(statp).nssocks[ns];
				}
			}
			goto wait;  // 事件类型已经改为POLLOUT，因此不会发生超时
		}
		/* All is well.  We have received both responses (if
		   two responses were requested).  */
		return (resplen); // rx.5 DNS查询完毕
	} else if (pfd[0].revents &amp; (POLLERR | POLLHUP | POLLNVAL)) // poll出现错误
	  /* Something went wrong.  We can stop trying.  */
	  return close_and_return_error (statp, resplen2);
	else {
		/* poll should not have returned &gt; 0 in this case.  */
		abort ();
	}
}</pre>
<p>注释中tx.标注了DNS查询请求发送的基本过程，rx.则标注了DNS查询应答接收的基本过程。调试查询keydb-0.keydb时该函数的行为，发现以下事实：</p>
<ol>
<li>查询时串行发送的，而不是并行。因此正常流程应该是发送A查询，接收A应答，发送AAAA查询，接收AAAA应答</li>
<li>仅执行了1225行，没有执行1223行。也就是说仅仅发送了A查询，没有发送AAA查询</li>
<li>走到了1241行的分支，也就是说，<span style="background-color: #c0c0c0;">A请求的应答报文是接收到的</span>：<br />
<pre class="crayon-plain-tag">// (gdb) i r eax
// eax            0x2d     45   A应答长度45
		*thisresplenp = recvfrom(pfd[0].fd, (char*)*thisansp,
					 *thisanssizp, 0,
					(struct sockaddr *)&amp;from, &amp;fromlen);</pre></p>
<p>由于接收到的应答是servfail，因此走到这个分支：</p>
<pre class="crayon-plain-tag">if (anhp-&gt;rcode == SERVFAIL ||
		    anhp-&gt;rcode == NOTIMP ||
		    anhp-&gt;rcode == REFUSED) {
		next_ns:
			if (recvresp1 || (buf2 != NULL &amp;&amp; recvresp2)) {
			  *resplen2 = 0;
			  return resplen;
			}
			if (buf2 != NULL)
			  {
			    /* No data from the first reply.  */
			    resplen = 0;
			    /* We are waiting for a possible second reply.  */
			    if (hp-&gt;id == anhp-&gt;id)
			      recvresp1 = 1;  // 接收到第一个应答
			    else
			      recvresp2 = 1;
                // 由于同时需要进行A和AAAA查询，这里仅仅接收到A应答（串行发送）
			    goto wait; // 因此需要跳转到这里，等待套接字可写，以发送AAAA请求
			  }</pre>
</li>
<li>
<p>CoreDNS应答A查询SERVFAIL，重新跳转到wait标签：
<pre class="crayon-plain-tag">if (need_recompute) { // 等待A应答的时候，设置了超时 need_recompute，因此再次wait执行这个分支
	recompute_resend:
		evNowTime(&amp;now);
		if (evCmpTime(finish, now) &lt;= 0) {
		poll_err_out: // 如果超时了，直接关闭套接字并返回错误
			return close_and_return_error (statp, resplen2);
		}
		evSubTime(&amp;timeout, &amp;finish, &amp;now);
		need_recompute = 0;
	}
	/* Convert struct timespec in milliseconds.  */
	ptimeout = timeout.tv_sec * 1000 + timeout.tv_nsec / 1000000;

	n = 0;
	if (nwritten == 0)
	  n = __poll (pfd, 1, 0);  // 发送A请求的时候在这里pull，等待套接字可写。timeout 0表示立即返回
	if (__glibc_unlikely (n == 0))       {
		n = __poll (pfd, 1, ptimeout);  // 接收A应答的时候在这里poll，等待套接字可读
		need_recompute = 1; // 发送AAAA请求时，在这里等待套接字可写
	}</pre>
</li>
<li>
<p> 这时，由于nwritten已经被设置为1，因此走带有timeout的poll分支。然后在1110行出现5秒超时，并因为poll返回值是0而导致send_dg函数退出。在一次A请求处理过程中，有两次在1110行poll：
<ol>
<li>第一次是尝试A请求的应答，poll前的pollfd是{fd = 87, events = 1, revents = 4}，之后是{fd = 87, events = 1, revents = 1}</li>
<li>第二次就是因为这个跳转，poll前的pollfd是{fd = 87, events = 1, revents = 1}，超时之后是{fd = 87, events = 1, revents = 0}</li>
</ol>
</li>
</ol>
<p>poll函数原型：<pre class="crayon-plain-tag">int poll(struct pollfd *fds, nfds_t nfds, int timeout);</pre>，它等待文件描述符集合中的某个可用（可执行I/O）。文件描述符集合由参数fds指定，它是pollfd结构的数组：</p>
<pre class="crayon-plain-tag">struct pollfd {
    // 打开文件的描述符
    int   fd;         
    // 输入参数，应用程序感兴趣的事件类型。如果置零则revents中仅能返回POLLHUP,POLLERR,POLLNVAL事件
    short events;
    // 输出参数，内核填充实际发生的事件
    short revents;    
};</pre>
<p>如果文件描述符集中没有任何一个发生了events中指定的事件，则该函数会阻塞，直到超时或者被信号处理器中断。</p>
<p><span style="background-color: #c0c0c0;">事件类型1表示POLLIN，即有数据可读；事件类型4表示POLLOUT</span>，即文件描述符可写。正常情况下该函数返回就绪的（revents非零）文件描述符数量，超时返回0，出现错误则返回-1</p>
<p>第2次在1110行的poll行为难以理解：</p>
<ol>
<li>A的应答已经接收到，而由于进行的是串行发送A/AAAA，此时尚未发送AAAA请求，因此可以<span style="background-color: #c0c0c0;">预期后续不会有可读事件</span></li>
<li>poll时events设置为POLLIN（肯定会导致超时），难道不是应该设置为POLLOUT，尝试发送AAAA请求或重试A请求么？</li>
</ol>
<p>为了进行对照，我们由调试了没有发生缓慢问题的keydb-2.keydb。它在第2次执行1110行的poll时没有超时，pollfd的状态是{fd = 88, events = 1, revents = 1}。连续两次poll到可读事件，这提示进行了并行A/AAAA查询。检查变量single_request_reopen、single_request果然都是false，从CoreDNS日志上也可以看到A/AAAA</p>
<p>可能的情况是，keydb-1.keydb最初是并行发送A/AAAA查询的，后来由于某种原因，改为串行发送，从而导致出现5秒超时相关的缓慢现象。根源应该还是在glibc中，因为KeyDB调用getaddrinfo的方式是固定的。</p>
<p>回顾一下send_dg的代码，可以发现<pre class="crayon-plain-tag">statp-&gt;options</pre>决定了是否进行串行发送，statp是<pre class="crayon-plain-tag">resolv_context</pre>的一个字段，后者则是一个线程本地变量。如果某次并行发送请求后，可以接收到第一个应答，而在继续等待第二个应答时出现超时（1113行），则send_dg函数会修改statp-&gt;options，改为串行发送，<span style="background-color: #c0c0c0;">这个修改具有全局性影响</span>，以后KeyDB的复制定时任务（总是由同一线程执行）调用getaddrinfo，都会使用串行方式发送请求。</p>
<p>改变为串行方式后，由于CoreDNS应答keydb-0.keydb.以SERVFAIL，导致跳转到wait标签（1363行），进而执行了一次必然超时的poll调用。CoreDNS应答其它（加了search domain后缀的）域名以NXDOMAIN，则不会导致超时的poll调用，<span style="background-color: #c0c0c0;">因为会在1396行修改事件类型为POLLOUT</span>。</p>
<div class="blog_h1"><span class="graybg">解决方案</span></div>
<p>触发本文中的glibc缺陷，需要满足以下条件：</p>
<ol>
<li>出现某个KeyDB节点宕机的情况，并且没有修复。这会导致复制定时任务反复执行DNS查询，从而可能触发缺陷</li>
<li>某个DNS查询的应答UDP包丢失，导致当前线程串行发送DNS请求。由于UDP本身的不可靠性，随着程序不断运行，最终会发生</li>
<li>DNS服务器返回SERVFAIL、NOTIMP或者REFUSED应答</li>
</ol>
<p>第1、2个条件都是随机性的，我们没法干预，只有从第3个条件入手。作为最快速的解决方案，只需要配置KeyDB，使用全限定域名来指定replicaof即可。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/debugging-slow-keydb">记录一次KeyDB缓慢的定位过程</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/debugging-slow-keydb/feed</wfw:commentRss>
		<slash:comments>1</slash:comments>
		</item>
		<item>
		<title>eBPF学习笔记</title>
		<link>https://blog.gmem.cc/ebpf</link>
		<comments>https://blog.gmem.cc/ebpf#comments</comments>
		<pubDate>Fri, 22 Jan 2021 09:29:27 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[C]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[eBPF]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=37035</guid>
		<description><![CDATA[<p>简介 BPF，即Berkeley Packet Filter，是一个古老的网络封包过滤机制。它允许从用户空间注入一段简短的字节码到内核来定制封包处理逻辑。Linux从2.5开始移植了BPF，tcpdump就是基于BPF的应用。 所谓eBPF（extended BPF），则是从3.18引入的，对BPF的改造和功能增强： 使用类似于X86的体系结构，eBPF设计了一个通用的RISC指令集，支持11个64bit寄存器（32bit子寄存器）r0-r10，使用512字节的栈空间 引入了JIT编译，取代了BPF解释器。eBPF程序直接被编译为目标体系结构的机器码 和网络子系统进行了解耦。它的数据模型是通用的，eBPF程序可以挂钩到[crayon-69d31392e2f5f230491408-i/]或[crayon-69d31392e2f65669788986-i/] 使用Maps来存储全局数据，这是一种通用的键值存储。可用作不同eBPF程序、eBPF和用户空间程序的状态共享 助手函数（Helper Functions），这些函数供eBPF程序调用，可以实现封包改写、Checksum计算、封包克隆等能力 尾调用（Tail Calls），可以用于将程序控制权从一个eBPF转移给另外一个。老版本的eBPF对程序长度有4096字节的限制，通过尾调用可以规避 用于Pin对象（Maps、eBPF程序）的伪文件系统 支持将eBPF Offload给智能硬件的基础设施 以上增强，让eBPF不仅仅限于网络封包处理，当前eBPF的应用领域包括： 网络封包处理：XDP、TC、socket progs、kcm、calico、cilium等 内核跟踪和性能监控：KProbes、UProbes、TracePoints 安全领域：Secomp、landlock等。例如阻止部分类型的系统调用 <a class="read-more" href="https://blog.gmem.cc/ebpf">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/ebpf">eBPF学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">简介</span></div>
<p>BPF，即Berkeley Packet Filter，是一个古老的网络封包过滤机制。它允许从用户空间<span style="background-color: #c0c0c0;">注入一段简短的字节码到内核</span>来定制封包处理逻辑。Linux从2.5开始移植了BPF，tcpdump就是基于BPF的应用。</p>
<p>所谓eBPF（extended BPF），则是从3.18引入的，对BPF的改造和功能增强：</p>
<ol>
<li>使用类似于X86的体系结构，eBPF设计了一个通用的RISC指令集，支持11个64bit寄存器（32bit子寄存器）r0-r10，使用512字节的栈空间</li>
<li><span style="background-color: #c0c0c0;">引入了JIT编译</span>，取代了BPF解释器。eBPF程序直接被编译为目标体系结构的机器码</li>
<li>和网络子系统进行了解耦。它的数据模型是通用的，eBPF程序可以挂钩到<pre class="crayon-plain-tag">Kprobe</pre>或<pre class="crayon-plain-tag">Tracepoint</pre></li>
<li>使用Maps来存储全局数据，这是一种通用的键值存储。可用作不同eBPF程序、eBPF和用户空间程序的<span style="background-color: #c0c0c0;">状态共享</span></li>
<li>助手函数（Helper Functions），这些函数供eBPF程序调用，可以实现封包改写、Checksum计算、封包克隆等能力</li>
<li>尾调用（Tail Calls），可以用于将程序控制权从一个eBPF转移给另外一个。老版本的eBPF对程序长度有4096字节的限制，通过尾调用可以规避</li>
<li>用于Pin对象（Maps、eBPF程序）的<span style="background-color: #c0c0c0;">伪文件系统</span></li>
<li>支持将eBPF Offload给智能硬件的基础设施</li>
</ol>
<p>以上增强，让eBPF不仅仅限于网络封包处理，当前eBPF的应用领域包括：</p>
<ol>
<li>网络封包处理：XDP、TC、socket progs、kcm、calico、cilium等</li>
<li>内核跟踪和性能监控：KProbes、UProbes、TracePoints</li>
<li>安全领域：Secomp、landlock等。例如阻止部分类型的系统调用</li>
</ol>
<p>现在BPF一般都是指eBPF，而老的BPF一般称为cBPF（classic BPF）。</p>
<p>性能是eBPF的另外一个优势，由于<span style="background-color: #c0c0c0;">所有代码都在内核空间运行，避免了复制数据到用户空间、上下文切换等开销</span>。甚至编译过程都在尽可能的优化，例如助手函数会被内联到eBPF程序中，避免函数调用的开销。</p>
<p>用户提供的代码在内核中运行，安全性需要得到保证。eBPF校验器会对字节码进行各方面的检查，确保它不会导致内核崩溃或锁死。</p>
<p>eBPF具有非常好的灵活性、动态性，可以随时的注入、卸载，不需要重启内核或者中断网络连接。</p>
<p>eBPF程序可以在不同体系结构之间移植。</p>
<div class="blog_h1"><span class="graybg">eBPF基础</span></div>
<div class="blog_h2"><span class="graybg">BPF架构</span></div>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2021/05/ebpf-arch.jpg"><img class=" wp-image-37049 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2021/05/ebpf-arch.jpg" alt="ebpf-arch" width="995" height="543" /></a>如上图所示，eBPF应用程序，从开发到运行的典型流程如下：</p>
<ol>
<li>
<p>利用Clang，将C语言开发的代码编译为eBPF object文件</p>
</li>
<li>
<p>在用户空间将eBPF object文件载入内核。载入前，可能对object文件进行各种修改。这一步骤，可能通过iproute2之类的BPF ELF loader完成，也可能通过自定义的控制程序完成</p>
</li>
<li>
<p>BPF Verifier在VM中进行安全性校验</p>
</li>
<li>
<p>JIT编译器将字节码编译为机器码，返回BPF程序的文件描述符</p>
</li>
<li>使用文件描述符将BPF程序挂钩到某个子系统（例如networking）的挂钩点。子系统有可能将BPF程序offload给硬件（例如智能网卡）</li>
<li>
<p>用户空间通过eBPF Map和内核空间交换数据，获知eBPF程序的执行结果</p>
</li>
</ol>
<div class="blog_h2"><span class="graybg">挂钩点</span></div>
<p>eBPF程序以事件驱动的方式执行，具体来说，就是在<span style="background-color: #c0c0c0;">内核的代码路径上，存在大量挂钩点</span>（Hook Point）。eBPF程序会注册到某些挂钩点，当内核运行到挂钩点后，就执行eBPF程序。</p>
<p>挂钩点主要包括以下几类：</p>
<ol>
<li>网络事件，例如封包到达</li>
<li>Kprobes / Uprobes</li>
<li>系统调用</li>
<li>函数的入口/退出点</li>
</ol>
<div class="blog_h2"><span class="graybg">BPF Verifier</span></div>
<p>在加载之后，BPF校验器负责验证eBPF程序是否安全，它会模拟所有的执行路径，并且：</p>
<ol>
<li>检查程序控制流，发现循环</li>
<li>检测越界的跳转、不可达指令</li>
<li>跟踪Context的访问、栈移除</li>
<li>检查unpriviledged的指针泄漏</li>
<li>检查助手函数调用参数</li>
</ol>
<div class="blog_h2"><span class="graybg">BPF JITs</span></div>
<p>在校验之后，eBPF程序被JIT编译器编译为Native代码。</p>
<div class="blog_h2"><span class="graybg">BPF Maps</span></div>
<p>键值对形式的存储，通过文件描述符来定位，值是不透明的Blob（任意数据）。用于跨越多次调用共享数据，或者与用户空间应用程序共享数据。</p>
<p>一个eBPF程序可以<span style="background-color: #c0c0c0;">直接访问最多64个Map</span>，多个eBPF程序可以共享同一Map。</p>
<div class="blog_h2"><span class="graybg">Pinning</span></div>
<p>BPF Maps和程序都是内核资源，仅能通过文件描述符访问到。文件描述符对应了内核中的匿名inodes。</p>
<p>用户空间程序可以使用大部分基于文件描述符的API，但是<span style="background-color: #c0c0c0;">文件描述符是限制在进程的生命周期内的</span>，这导致Map难以被共享。比较显著的例子是iproute2，当tc或XDP加载eBPF程序之后，自身会立刻退出。这导致无法从用户空间访问Map。</p>
<p>为了解决上面的问题，引入了一个最小化的、<span style="background-color: #c0c0c0;">内核空间中的BPF文件系统。BPF程序和Map会被pin到一个被称为object pinning的进程</span>。bpf系统调用有两个命令BPF_OBJ_PIN、BPF_OBJ_GET分别用于钉住、取回对象。</p>
<p>tc这样的工具就是利用Pinning在ingress/egress端共享Map。</p>
<div class="blog_h2"><span class="graybg">尾调用</span></div>
<p>尾调用允许一个BPF程序调用另外一个，这种调用<span style="background-color: #c0c0c0;">没有函数调用那样的开销</span>。其实现方式是long jump，重用当前stack frame。</p>
<p>注意：只用相同类型的BPF程序才能相互尾调用。</p>
<p>要使用尾调用，需要一个BPF_MAP_TYPE_PROG_ARRAY类型的Map，其内容目前必须由用户空间产生，值是需要被尾调用的BPF程序的文件描述符。通过助手函数bpf_tail_call触发尾调用，内核会将此调用内联到一个特殊的BPF指令。</p>
<div class="blog_h2"><span class="graybg">BPF-BPF调用</span></div>
<p>BPF - BPF调用是一个新添加的特性。在此特性引入之前，典型的BPF C程序需要将所有可重用的代码声明为always_inline的，这样才能确保LLVM生成的object包含所有函数。这会导致函数在每个object文件中都反复（只要它被调用超过一次）出现，增加体积。</p>
<pre class="crayon-plain-tag">#include &lt;linux/bpf.h&gt;

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline                         \
   inline __attribute__((always_inline))
#endif

// 总是内联
static __inline int foo(void)
{
    return XDP_DROP;
}

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return foo();
}

char __license[] __section("license") = "GPL";</pre>
<p>总是需要内联的原因是BPF的Loader/Verifier/Interpreter/JITs不支持函数调用。但是从<span style="background-color: #c0c0c0;">内核4.16和LLVM 6.0开始，此限制消除</span>，BPF程序不再总是需要always_inline。上面程序的__inline可以去掉了。</p>
<p>目前x86_64/arm64的JIT编译器支持BPF to BPF调用，这是很重要的性能优化，因为它大大简化了生成的object文件的尺寸，对CPU指令缓存更加友好。</p>
<p>JIT编译器<span style="background-color: #c0c0c0;">为每个函数生成独立的映像</span>（Image），并且<span style="background-color: #c0c0c0;">在JIT的最后一个步骤中修复映像中的函数调用地址</span>。</p>
<p>到5.9为止，你不能同时使用BPF-BPF调用（BPF子程序）和尾调用。从5.10开始，可以混合使用，但是仍然存在一些限制。此外，<span style="background-color: #c0c0c0;">混合使用两者可能导致内核栈溢出</span>，原因是尾调用在跳转之前仅会unwind当前栈帧。</p>
<div class="blog_h2"><span class="graybg">Offloading</span></div>
<p>BPF网络程序，特别是tc和XDP，提供了将BPF代码offload给NIC执行的特性。这个特性需要驱动的支持。</p>
<div class="blog_h2"><span class="graybg">BPF前端工具</span></div>
<p>能够加载BPF程序的前端工具有很多，包括bcc、perf、iproute2等。内核也在tools/lib/bpf目录下提供了用户空间库，被perf用来加载BPF追踪应用程序到内核。这是一个通用的库，你也可以直接调用它。BCC是面向追踪的工具箱。内核在samples/bpf下也提供了一些BPF示例，这些示例解析Object文件，并且直接通过系统调用将其载入内核。</p>
<p>基于不同前端工具，实现BPF程序的语法、语义（例如对于段名的约定）有所不同。</p>
<div class="blog_h2"><span class="graybg">相关sysctl</span></div>
<div class="blog_h3"><span class="graybg">/proc/sys/net/core/bpf_jit_enable</span></div>
<p>启用或禁用BPF JIT编译器：</p>
<p style="padding-left: 30px;">0 仅用，仅仅使用解释器，默认值<br />1 启用JIT编译器<br />2 启用JIT编译器并且生成debugging trace到内核日志</p>
<p>设置为2，可以使用bpf_jit_disasm处理debugging trace</p>
<div class="blog_h3"><span class="graybg">/proc/sys/net/core/bpf_jit_harden</span></div>
<p>启用或禁用JIT加固，加固和性能是对立的，但是可以缓和JIT spraying：</p>
<p style="padding-left: 30px;">0 禁用JIT加固，默认值<br />1 对非特权用户启用<br />2 对所有用户启用</p>
<div class="blog_h3"><span class="graybg">/proc/sys/net/core/bpf_jit_kallsyms</span></div>
<p>启用或禁用JITed的程序的内核符号导出（导出到/proc/kallsyms），这样可以和perf工具一起使用，还能够让内核对BPF程序的地址感知（用于stack unwinding）：</p>
<p style="padding-left: 30px;">0 启用<br />1 仅对特权用户启用</p>
<div class="blog_h3"><span class="graybg">/proc/sys/kernel/unprivileged_bpf_disabled</span></div>
<p>是否启用非特权的bpf系统调用。默认启用，一旦禁用，重启前无法恢复启用状态。不会影响seccomp等不使用bpf2系统调用的cBPF程序：</p>
<p style="padding-left: 30px;">0 启用<br />1 禁用</p>
<div class="blog_h2"><span class="graybg">助手函数</span></div>
<p>eBPF程序可以调用助手函数，完成各种任务，例如：</p>
<ol>
<li>在Map中搜索、更新、删除键值对</li>
<li>生成伪随机数</li>
<li>读写隧道元数据</li>
<li>尾调用 —— 将eBPF程序链在一起</li>
<li>执行套接字相关操作，例如绑定、查询Cookies、重定向封包</li>
<li>打印调试信息</li>
<li>获取系统启动到现在的时间</li>
</ol>
<p>助手函数是定义在内核中的，有一个白名单，决定哪些内核函数可以被eBPF程序调用。</p>
<p>根据eBPF的约定，<span style="background-color: #c0c0c0;">助手函数的参数数量不超过5</span>。</p>
<p>编译后，助手函数的代码是内联到eBPF程序中的，因而不存在函数调用的开销（栈帧处理开销、CPU流水线预取指令失效开销）。</p>
<p>返回int的类型的助手函数，通常操作成功返回0，否则返回负数。如果不是如此，会特别说明。</p>
<p>助手函数不可以随意调用，不同类型的eBPF程序，可以调用不同的助手函数子集。</p>
<div class="blog_h1"><span class="graybg">iproute2</span></div>
<p>iproute2提供的BPF前端，主要用来载入BPF网络程序，这些程序的类型包括XDP、tc、lwt。只要是为iproute2编写的BPF程序，共享统一的加载逻辑。</p>
<div class="blog_h2"><span class="graybg">XDP</span></div>
<div class="blog_h3"><span class="graybg">加载XDP程序</span></div>
<p>编译好的XDP类型（BPF_PROG_TYPE_XDP）的BPF程序 ，可以使用如下命令载入到支持XDP的网络设备：</p>
<pre class="crayon-plain-tag">ip link set dev eth0 xdp obj prog.o</pre>
<p>上述命令假设程序位于名为prog的段中。如果不使用默认段名，则需要指定sec参数：</p>
<pre class="crayon-plain-tag"># 如果程序放在foobar段
ip link set dev em1 xdp obj prog.o sec foobar</pre>
<p>如果程序没有标注段，也就是位于默认的.text段，则也可以用上面的命令加载。</p>
<p>如果已经存在挂钩到网络设备的XDP程序，默认情况下命令会报错，可以用-force参数强制替换：</p>
<pre class="crayon-plain-tag">ip -force link set dev em1 xdp obj prog.o</pre>
<p>大多数支持XDP的驱动，能够原子的替换XDP程序，而不会引起流量中断。出于性能的考虑同时只能有一个XDP程序挂钩， 可以利用前文提到的尾调用来组织多个XDP程序。</p>
<p>如果网络设备挂钩了XDP程序，则ip link命令会显示xdp标记和程序的ID。使用bpftool传入ID可以查看更多细节信息。</p>
<div class="blog_h3"><span class="graybg">卸载XDP程序</span></div>
<pre class="crayon-plain-tag">ip link set dev eth0 xdp off</pre>
<div class="blog_h3"><span class="graybg">XDP操作模式</span></div>
<p>iproute2实现了XDP所支持的三种操作模式：</p>
<ol>
<li>xdpdrv：即native XDP，<span style="background-color: #c0c0c0;">BPF程序在驱动的接收路径的最早时刻被调用</span>。这是正常的XDP模式，上游内核的所有主要10G/40G+网络驱动（包括virtio）都实现了XDP支持，也就是可使用该模式</li>
<li>xdpoffload：由智能网卡的驱动（例如Netronome的nfp驱动）实现，将整个XDP程序offload到硬件中，网卡每接收到封包都会执行XDP程序。该模式比native XDP的性能更高，缺点是，并非所有助手函数、Map类型可用。</li>
<li>xdpgeneric：即generic XDP，作为尚不支持native XDP的驱动的试验台。挂钩点比native XDP晚很多，已经进入网络栈的主接收路径，生成了skb对象，因此性能比native XDP差很多，不会用于生产环境</li>
</ol>
<p>在切换驱动的XDP模式时，驱动通常需要重新配置它的接收/发送Rings，以保证接收到的封包线性的（linearly）存放到单个内存页中。</p>
<p>调用ip link set dev xxx xdp命令时，内核会首先尝试在native XDP模式下载入，如果驱动不支持，则自动使用generic XDP模式。要强制使用native XDP，则可以使用：</p>
<pre class="crayon-plain-tag">#                           强制使用native XDP
ip -force link set dev eth0 xdpdrv obj prog.o</pre>
<p>使用类似的方式可以强制使用xdpgeneric、xdpoffload。</p>
<p><span style="background-color: #c0c0c0;">切换操作模式目前不能原子的进行，但是在单个操作模式下替换XDP程序则可以</span>。</p>
<p>使用<pre class="crayon-plain-tag">verb</pre>选项，可以显示详尽的BPF校验日志：</p>
<pre class="crayon-plain-tag">ip link set dev eth0 xdp obj xdp-example.o verb</pre>
<p>除了从文件加载BPF程序，也可以直接从BPF伪文件系统中得到程序并使用：</p>
<pre class="crayon-plain-tag">ip link set dev eth0 xdp pinned /sys/fs/bpf/prog
#                               m:表示BPF文件系统的挂载点，默认/sys/fs/bpf/
ip link set dev eth0 xdp pinned m:prog</pre>
<div class="blog_h2"><span class="graybg">tc</span></div>
<p>对于为tc设计的BPF程序（BPF_PROG_TYPE_SCHED_CLS、BPF_PROG_TYPE_SCHED_ACT），可以使用tc命令加载并挂钩到网络设备。和XDP不同，tc程序没有对驱动的依赖。</p>
<p><pre class="crayon-plain-tag">clsact</pre>是4.1引入了一个特殊的dummy qdisc，它持有classifier和action，但是不能执行实际的queueing。要挂钩BPF classifier，clsact是必须启用的：</p>
<pre class="crayon-plain-tag">tc qdisc add dev eth0 clsact</pre>
<p>clsact提供了两个特殊的钩子<pre class="crayon-plain-tag">ingress</pre>、<pre class="crayon-plain-tag">egress</pre>，对应了BPF classifier可用的两个挂钩点。这两个钩子位于网络数据路径的中心位置，任何封包都必须经过</p>
<p>下面的命令，将BPF程序挂钩到eth0的ingress路径上：</p>
<pre class="crayon-plain-tag">tc filter add dev eth0 ingress bpf da obj prog.o</pre>
<p>下面的命令将BPF程序挂钩到eth0的egress路径上：</p>
<pre class="crayon-plain-tag">tc filter add dev eth0 egress bpf da obj prog.o </pre>
<p>ingress钩子在内核中由<pre class="crayon-plain-tag">__netif_receive_skb_core() -&gt; sch_handle_ingress()</pre>调用。</p>
<p>egress钩子在内核中由<pre class="crayon-plain-tag">__dev_queue_xmit() -&gt; sch_handle_egress()</pre>调用。</p>
<p><span style="background-color: #c0c0c0;">clsact是以无锁方式处理的</span>，<span style="background-color: #c0c0c0;">支持挂钩到虚拟的、没有队列概念的网络设备，例如veth</span>。</p>
<p><pre class="crayon-plain-tag">da</pre>即direct-action模式，这是推荐的模式，应当总是在命令中指定。da模式表示BPF classifier不需要调用外部的tc action模块，因为BPF程序会将封包修改、转发或者其它动作都完成，这正是BPF性能优势所在。</p>
<p>类似XDP，如果不使用默认的段名，需要用sec选项：</p>
<pre class="crayon-plain-tag">tc filter add dev eth0 egress bpf da obj prog.o sec foobar</pre>
<p>已经挂钩到设备的tc程序的列表，可以用下面的命令查看：</p>
<pre class="crayon-plain-tag">tc filter show dev em1 ingress
filter protocol all pref 49152 bpf

     # 针对的L3协议    优先级      分类器类型  分类器句柄
filter protocol all    pref 49152 bpf        handle 0x1 
  # 从prog.o的ingress段加载了程序
  prog.o:[ingress] 
  # BPF程序运行在da模式
  direct-action 
  # 程序ID是全局范围唯一的BPF程序标识符，可以被bpftool使用
  id 1 
  # 程序指令流的哈希，哈希可以用来关联到Object文件，perf报告栈追踪的时候使用此哈希
  tag c5f7825e5dac396f


tc filter show dev em1 egress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 prog.o:[egress] direct-action id 2 tag b2fd5adc0f262714</pre>
<p>tc可以挂钩多个BPF程序，这和XDP不同，它提供了多个其它的、可以链接在一起的classifier。尽管如此，单个da模式的BPF程序可以满足所有封包操作需求，它可以直接返回action断言，例如<pre class="crayon-plain-tag">TC_ACT_OK</pre>, <pre class="crayon-plain-tag">TC_ACT_SHOT</pre>。使用单个BPF程序是推荐的用法。</p>
<p>除非打算自动替换挂钩的BPF程序，建议初次挂钩时明确的指定pref和handle，这样，在后续手工替换的时候就不需要查询获取pref、handle：</p>
<pre class="crayon-plain-tag">tc filter add dev eth0 ingress pref 1 handle 1 bpf da obj prog.o sec foobar</pre>
<p>使用下面的命令原子的替换BPF程序：</p>
<pre class="crayon-plain-tag">tc filter replace dev eth0 ingress pref 1 handle 1 bpf da obj prog.o sec foobar</pre>
<p>要移除所有以及挂钩的BPF程序，执行：</p>
<pre class="crayon-plain-tag">tc filter del dev eth0 ingress
tc filter del dev eth0 egress</pre>
<p>要从网络设备上移除整个clsact qdisc，可以：</p>
<pre class="crayon-plain-tag">tc qdisc del dev eth0 clsact</pre>
<p>类似于XDP程序，tc程序也支持offload给职能网卡。你需要首先启用hw-tc-offload：</p>
<pre class="crayon-plain-tag">ethtool -K eth0 hw-tc-offload on</pre>
<p>然后再启用clsact并挂钩BPF程序。XDP和tc的offloading不能同时开启。</p>
<div class="blog_h2"><span class="graybg">netdevsim</span></div>
<p>内核提供了一个dummy驱动netdevsim，它实现了XDP/tc BPF的offloading接口，用于测试目的。</p>
<p>下面的命令可以启用netdevsim设备：</p>
<pre class="crayon-plain-tag">modprobe netdevsim
echo "1 1" &gt; /sys/bus/netdevsim/new_device
devlink dev
# netdevsim/netdevsim1
devlink port
# netdevsim/netdevsim1/0: type eth netdev eth0 flavour physical
ip l
# 4: eth0: &lt;BROADCAST,NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
#     link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff</pre>
<div class="blog_h1"><span class="graybg">XDP</span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>在网络封包处理方面，出现过一种提升性能的技术 —— 内核旁路（Kernel Bypass ）：完全在用户空间实现网络驱动和整个网络栈，避免上下文切换、内核网络层次、中断处理。具体实现包括Intel的DPDK （Data Plane Development Kit）、Cisco的VPP等。</p>
<p>内核旁路技术的缺点是：</p>
<ol>
<li>作为硬件资源的抽象层，内核是经过良好测试和验证的。在用户空间重新实现驱动，稳定性、可复用性欠佳</li>
<li>实现网络栈也是困难的</li>
<li>作为一个沙盒，网络处理程序难以和内核其它部分集成/交互</li>
<li>无法使用内核提供的安全层</li>
</ol>
<p><span style="background-color: #c0c0c0;">eXpress Data Path，为内核提供了一个基于eBPF的、高性能的、可编程的、运行在驱动层的封包处理框架</span>，它提升性能的思路和内核旁路技术相反 —— 完全在内核空间实现封包处理逻辑，例如过滤、映射、路由等。XDP通过在网络接收路径的最早期挂钩eBPF程序来实现高速封包过滤。最早期意味着：NIC驱动刚刚从receiver rings接收到封包，任何高成本操作，例如分配skb并将封包推入网络栈，尚未进行。</p>
<p>XDP的起源来自于对DDoS攻击的防范。Cloudflare依赖（leverages heavily on）iptables进行封包过滤，在配置相当好的服务器上，可以处理1Mpps的流量。但是当出现DDoS攻击时，流量会高达3Mpps，这会导致Linux系统overflooded by IRQ请求，直到系统变得不稳定。</p>
<p>由于Cloudflare希望继续使用iptables以及其它内核网络栈功能，它不考虑使用DPDK这样的完全控制硬件的方案，而是使用了所谓部分内核旁路（partial kernel bypass），NIC的一部分队列继续附到内核，另外一部分队列则附到一个用户空间应用程序，此程序决定封包是否应该被丢弃。通过在网络栈的最底部就决定是否应该丢弃封包，需要经由内核网络子系统的封包数量大大减少了。</p>
<p><a href="https://blog.cloudflare.com/single-rx-queue-kernel-bypass-with-netmap/">Cloudflare利用了Netmap工具包</a>实现部分内核旁路。但是这个思路可以延伸为，在内核网络栈中增加一个Checkpoint，这个点应该离NIC接收到封包的时刻尽可能的近。这个Checkpoint将把封包交给用户编写的程序，决定是应该丢弃，还是继续正常处理路径。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2021/05/xdp-packet-processing.png"><img class="wp-image-37127 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2021/05/xdp-packet-processing.png" alt="xdp-packet-processing" width="1068" height="584" /></a></p>
<p>XDP对应的BPF程序类型是：BPF_PROG_TYPE_XDP。XDP程序可以读写封包，调用助手函数解析封包、计算Checksum，这些操作都不会牵涉系统调用的开销（都在内核空间执行）。</p>
<p>尽管XDP的基本用途是，尽早的决定封包是否应该丢弃。但是，由于网络函数无非是读、写、转发、丢弃等原语的组合，XDP可以用来实现任何网络功能。</p>
<p>XDP的主要优势包括：</p>
<ol>
<li>可以使用各种内核基础设施，例如路由表、套接字、网络栈</li>
<li>运行在内核中，使用和内核其它部分一致的安全模型</li>
<li>运行在内核中，不需要跨越用户/内核空间边界，能够灵活的转发封包给其它内核实体，例如命名空间、网络栈</li>
<li>支持动态替换XDP程序，不会引起网络中断</li>
<li>保证封包的线性（linearly）布局，封包位于单个DMAed内存页中，访问起来很方便</li>
<li>保证封包有256字节可用的额外headroom，可以用于（使用助手函数<pre class="crayon-plain-tag">bpf_xdp_adjust_head</pre>、<pre class="crayon-plain-tag">bpf_xdp_adjust_meta</pre>）添加自定义的封装包头</li>
</ol>
<p>从内核4.8+开始，主要发行版中XDP可用，大部分10G+网络驱动支持XDP。</p>
<div class="blog_h2"><span class="graybg">应用场景</span></div>
<div class="blog_h3"><span class="graybg">DDoS缓解</span></div>
<p>XDP的高性能特征，让它非常适合实现DDoS攻击缓解，以及一般性防火墙。</p>
<div class="blog_h3"><span class="graybg">封包转发</span></div>
<p>BPF程序可以对封包进行任意的修改，甚至是通过助手函数任意的增减headroom大小实现封装/解封装。</p>
<p>处理完的封包通过XDP_REDIRECT动作即可转发封包给其它NIC，或者转发给其它CPU（利用BPF的cpumap）</p>
<div class="blog_h3"><span class="graybg">负载均衡</span></div>
<p>使用XDP_TX动作，hairpinned LB可以将修改后的封包从接收它的网卡发送回去。</p>
<div class="blog_h3"><span class="graybg">流量采样和监控</span></div>
<p>XDP支持将部分或截断的封包内容存放到无锁的per-CPU的内存映射ring buffer中。此ring buffer由Linux perf基础设施提供，可以被用户空间访问。</p>
<div class="blog_h2"><span class="graybg">编程接口</span></div>
<div class="blog_h3"><span class="graybg">xdp_buff</span></div>
<p>在XDP中，代表当前封包的结构是：</p>
<pre class="crayon-plain-tag">struct xdp_buff {
    // 内存页中，封包数据的开始点指针
    void *data;
    // 内存页中，封包数据的结束点指针
    void *data_end;
    // 最初和和data指向同一位置。后续可以被bpf_xdp_adjust_meta()调整，向data_hard_start方向移动
    // 可以用于为元数据提供空间。这种元数据对于正常的内核网络栈是不可见的，但是能够被tc BPF程序读取，
    // 因为元数据会从XDP传送到skb中
    // data_meta可以仅仅适用于在尾调用之间传递信息，类似于可被tc访问的skb-&gt;cb[]
    void *data_meta;
    // XDP支持headroom，这个字段给出页中，此封包可以使用的，最小的地址
    // 如果封包被封装，则需要调用bpf_xdp_adjust_head()，将data向data_hard_start方向移动
    // 解封装时，也可以使用bpf_xdp_adjust_head()移动指针
    void *data_hard_start;
    // 提供一些额外的per receive queue元数据，这些元数据在ring setup time生成
    struct xdp_rxq_info *rxq;
};

// 接收队列信息
struct xdp_rxq_info {
    struct net_device *dev;
    u32 queue_index;
    u32 reg_state;
} ____cacheline_aligned; // 缓存线（默认一般是64KB），CPU以缓存线为单位读取内存到CPU高速缓存</pre>
<p>它通过BPF context传递给XDP程序。</p>
<div class="blog_h3"><span class="graybg">xdp_action</span></div>
<pre class="crayon-plain-tag">enum xdp_action {
    // 提示BPF出现错误，和DROP的区别仅仅是会发送一个trace_xdp_exception追踪点
    XDP_ABORTED = 0,
    // 应当在驱动层丢弃封包，不必再浪费额外资源。对于DDos缓和、一般性防火墙很有用
    XDP_DROP,
    // 允许封包通过，进入网络栈进行常规处理
    // 处理此封包的CPU后续将分配skb，将封包信息填充进去，然后传递给GRO引擎
    XDP_PASS,
    // 将封包从接收到的网络接口发送回去，可用于实现hairpinned LB
    XDP_TX,
    // 重定向封包给另外一个NIC
    XDP_REDIRECT,
};</pre>
<p>这个枚举是XDP程序需要返回的断言，告知驱动应该如何处理封包。</p>
<div class="blog_h1"><span class="graybg"><a id="tc"></a>tc</span></div>
<p>关于tc的基础知识，参考<a href="/tc">基于tc的网络QoS管理</a>。</p>
<div class="blog_h2"><span class="graybg">tc程序简介</span></div>
<p>BPF可以和内核的tc层一起工作。tc程序和XDP程序有以下不同：</p>
<ol>
<li>tc程序的BPF输入上下文是skb_buff，而非xdp_buff。在XDP之后，内核会解析封包，存入skb_buff。解析的开销导致tc程序的性能远低于XDP，但是，tc程序可以访问skb的mark, pkt_type, protocol, priority, queue_mapping, napi_id, cb[]数组, hash, tc_classid, tc_index等字段，以及VLAN元数据、XDP传送来的自定义元数据。BPF上下文<pre class="crayon-plain-tag">struct __sk_buff</pre>定义在<pre class="crayon-plain-tag">linux/bpf.h</pre></li>
<li>tc程序可以挂钩到ingress/egress网络路径上，XDP则仅仅能挂钩到ingress路径</li>
<li>tc程序对驱动层没有依赖，可以挂钩到任何类型的网络设备。除非启用tc BPF程序的offloading</li>
</ol>
<p>尽管tc程序的挂钩点没有XDP那么早，但是仍然是<span style="background-color: #c0c0c0;">在内核网络路径的早期。它在GRO运行之后，任何协议处理之前执行</span>。iptables PREROUTING、nftables ingress hook等工具也在相同的位置挂钩。</p>
<div class="blog_h2"><span class="graybg">tc程序工作方式</span></div>
<p>工作在tc层的BPF程序，是从一个名为<pre class="crayon-plain-tag">cls_bpf</pre>的过滤器运行的。tc程序<span style="background-color: #c0c0c0;">不但可以读取skb的元数据、封包内容，还能够对封包进行任意修改，甚至使用action verdict终止tc处理过程</span>。</p>
<p>过滤器<span style="background-color: #c0c0c0;">cls_bpf可以挂钩1-N个BPF程序</span>，当有多个BPF程序情况下，前面的程序返回verdict <pre class="crayon-plain-tag">TC_ACT_UNSPEC</pre>会导致继续执行后面的BPF程序。使用多个BPF程序的缺点是，需要反复解析封包，导致性能降低。</p>
<p>cls_bpf有一个direct-action（da）模式，这样BPF程序能够直接返回action verdict，决定封包命运，结束tc处理流水线。</p>
<p>tc BPF程序也支持在运行时动态更新，而不会中断任何网络流量。</p>
<p>cls_bpf可以挂钩到的ingress/egress钩子，均被一个伪（不在qdisc树形结构中）排队规则<pre class="crayon-plain-tag">sch_clsact</pre>管理。对于ingress qdisc来说，sche_clsact可以作为一个drop-in的替代品。对于在<pre class="crayon-plain-tag">__dev_queue_xmit()</pre>中执行的egress钩子，需要强调，sche_clsact不在内核的root qdisc锁下运行。因此，<span style="background-color: #c0c0c0;">不管是ingress/egress，使用sche_clsact时tc BPF程序都是以无锁方式执行的，这和典型的qdisc完全不同</span>。此外需要注意，<span style="background-color: #c0c0c0;">sch_clsact执行期间不会发生抢占</span>。</p>
<p>典型情况下，egress方向会有附到网络设备的qdisc，例如sch_htb、sch_fq，它们其中有些是classful qdisc。classful qdisc会通过<pre class="crayon-plain-tag">tcf_classify()</pre>调用分类器，cls_bpf也可以被挂到这种qdisc上。这时，BPF程序在root qdisc下运行，可能面临锁争用问题。</p>
<p>为了达到最大性能（减少锁争用），可以考虑这样的用法：使用sch_clsact + cls_bpf，在root qdisc锁之外，完成任务繁重的封包分类工作，并且设置skb-&gt;mark或skb-&gt;priority。然后，由运行在root qdisc锁下的sch_htb快速的根据skb字段完成分类、塑形操作。</p>
<p>sch_clsact + cls_bpf组合使用时，如果cls_bpf是da模式、只包含单个BPF程序、且位于ingress网络路径，则支持offload给智能网卡。</p>
<div class="blog_h2"><span class="graybg">编程接口</span></div>
<div class="blog_h3"><span class="graybg">__sk_buff</span></div>
<p>在tc BPF程序中，代表当前封包的结构是<pre class="crayon-plain-tag">__sk_buff</pre>，这种结构叫UAPI（user space API of the kernel），可以访问内核<pre class="crayon-plain-tag">sk_buff</pre>结构的某些字段。</p>
<pre class="crayon-plain-tag">struct __sk_buff {
	__u32 len;
	__u32 pkt_type;
	__u32 mark;
	__u32 queue_mapping;
	__u32 protocol;
	__u32 vlan_present;
	__u32 vlan_tci;
	__u32 vlan_proto;
	__u32 priority;
	__u32 ingress_ifindex;
	__u32 ifindex;
	__u32 tc_index;
	__u32 cb[5];
	__u32 hash;
	__u32 tc_classid;
	__u32 data;
	__u32 data_end;
	__u32 napi_id;

	/* Accessed by BPF_PROG_TYPE_sk_skb types from here to ... */
	__u32 family;
	__u32 remote_ip4;	/* Stored in network byte order */
	__u32 local_ip4;	/* Stored in network byte order */
	__u32 remote_ip6[4];	/* Stored in network byte order */
	__u32 local_ip6[4];	/* Stored in network byte order */
	__u32 remote_port;	/* Stored in network byte order */
	__u32 local_port;	/* stored in host byte order */
	/* ... here. */

	__u32 data_meta;
	__bpf_md_ptr(struct bpf_flow_keys *, flow_keys);
	__u64 tstamp;
	__u32 wire_len;
	__u32 gso_segs;
	__bpf_md_ptr(struct bpf_sock *, sk);
};</pre>
<div class="blog_h3"><span class="graybg">verdicts</span></div>
<p>tc ingress/egress钩子能够返回的verdict定义在：</p>
<pre class="crayon-plain-tag">// 未指定，如果有多个BPF程序，会继续运行下一个。如果没有更多BPF程序
// 则提示内核在没有任何side-effect的情况下继续处理skb
#define TC_ACT_UNSPEC         (-1)
// 从tc BPF程序角度，TC_ACT_OK、TC_ACT_RECLASSIFY等价
#define TC_ACT_OK               0
// 提示内核丢弃封包，在ingress方向，网络栈上层无法看到封包；在egress方向，封包不会被发出
#define TC_ACT_SHOT             2
// 从tc BPF程序角度，TC_ACT_STOLEN、TC_ACT_QUEUED、TC_ACT_TRAP等价
// 类似于TC_ACT_SHOT，区别：
//   TC_ACT_SHOT导致内核通过kfree_skb()释放封包并返回NET_XMIT_DROP作为即时的反馈
//   TC_ACT_STOLEN导致内核通过consume_skb()释放封包，并且返回NET_XMIT_SUCCESS，
//      效果是上层以为封包是成功发送的
#define TC_ACT_STOLEN           4
// 利用助手函数bpf_redirect()，重定向封包到相同/不同设备的ingress/egress路径
#define TC_ACT_REDIRECT         7 </pre>
<div class="blog_h2"><span class="graybg">应用场景</span></div>
<div class="blog_h3"><span class="graybg">容器网络策略</span></div>
<p>对于容器来说，容器网络命名空间和初始网络命名空间通过一对veth连接。我们可以在宿主机端实现网络策略：</p>
<ol>
<li>主机侧的egress，对应容器的ingress</li>
<li>主机侧的ingress，对应容器的egress</li>
</ol>
<p>将tc BPF程序挂钩到宿主机veth的egress/ingress钩子即可。</p>
<p>对于veth这样的虚拟设备，XDP是不适合的。因为内核在虚拟设备这里，单纯在一个skb上操作，<span style="background-color: #c0c0c0;">XDP由于一些限制，无法和克隆的skb一起工作</span>。克隆skb在内核TCP/IP栈被大量使用，用来存放重传的数据段，这里XDP钩子会被直接跳过。此外，XDP需要线性化（放到单个页）整个skb，这也导致严重的性能影响。</p>
<div class="blog_h3"><span class="graybg">转发和负载均衡</span></div>
<p>容器工作负载的东西向流量是主要的目标场景。</p>
<p>不像XDP仅作用在ingress上，tc BPF可以在某些场景下，应用到容器egress方向。在对容器透明的前提下，可以利用BPF在egress进行NAT和LB处理，利用bpf_redirection助手函数，<span style="background-color: #c0c0c0;">BPF可以将封包转到任何接口的ingress/egress路径，不需要网桥之类的设备辅助</span>。</p>
<div class="blog_h3"><span class="graybg">流采样和监控</span></div>
<p>类似XDP，流采样和监控可以通过高性能、无锁的per-CPU内存映射的perf ring buffer实现，依此tc BPF程序可以调用助手函数<pre class="crayon-plain-tag">bpf_skb_event_output()</pre>推送定制数据、完整/截断的封包内容到用户空间。</p>
<p>由于tc BPF程序可以同时挂到ingress/egress，因此可以为任何节点实现双向的监控。</p>
<p>BPF程序可以预先做一些聚合，而不是把所有东西推送到用户空间。</p>
<div class="blog_h3"><span class="graybg">预处理封包调度</span></div>
<p>如上文所提到，sch_clsact的egress钩子，即<pre class="crayon-plain-tag">sch_handle_egress()</pre>，在获取内核qdisc root锁之前运行。这种无锁的特征让tc BPF程序适合执行较重（耗时）的分类任务，并将分类结果设置到skb的某些字段，在后续交由常规的、有锁的qdisc进行塑形和重排。</p>
<div class="blog_h1"><span class="graybg">开发环境</span></div>
<div class="blog_h2"><span class="graybg">内核和工具</span></div>
<p>本节介绍如何创建完整的BPF开发环境。尽管手工构建iproute2和Linux内核是非必须的（主流发行版已经内置），但是测试最新特性、或者需要贡献BPF补丁到内核、iproute2时则需要。</p>
<p>安装构建需要的软件：</p>
<pre class="crayon-plain-tag">sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev \
  clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex \
  graphviz</pre>
<div class="blog_h3"><span class="graybg">构建内核</span></div>
<p>BPF相关的新特性在内核的net-next分支上开发，最后的BPF fixes则在net分支上。</p>
<p>注意打开以下内核配置项：</p>
<pre class="crayon-plain-tag">CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_NET_SCH_INGRESS=m
CONFIG_NET_CLS_BPF=m
CONFIG_NET_CLS_ACT=y
# 如果目标体系结构支持JIT，自动y
CONFIG_BPF_JIT=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_TEST_BPF=m</pre>
<p>从新编译的内核启动后，运行BPF自我测试套件，应该全部通过：</p>
<pre class="crayon-plain-tag">cd tools/testing/selftests/bpf/
make
sudo ./test_verifier</pre>
<div class="blog_h3"><span class="graybg">构建iproute2</span></div>
<p>iproute2具有独立的Git仓库：</p>
<pre class="crayon-plain-tag">git clone https://git.kernel.org/pub/scm/network/iproute2/iproute2.git</pre>
<p>master分支对应内核的net分支，net-next分支对应内核的net-next分支。</p>
<p>执行下面的命令编译：</p>
<pre class="crayon-plain-tag">cd iproute2/
./configure --prefix=/usr
# 确保输出：
# ELF support: yes
# 这样iproute2才能处理LLVM BPF后端产生的ELF文件
sudo make install</pre>
<div class="blog_h3"><span class="graybg">构建bpftool</span></div>
<p>bpftools是BPF程序、Map的调试、introspection工具。它位于内核源码树的tools/bpf/bpftool/</p>
<pre class="crayon-plain-tag">cd tools/bpf/bpftool/
make
sudo make install</pre>
<div class="blog_h3"><span class="graybg">构建libbpf</span></div>
<div class="blog_h2"><span class="graybg">工具链</span></div>
<p>LLVM 3.7+是当前唯一提供BPF后端的编译套件，GCC目前不支持。主流发行版默认启用了LLVM的BPF后端支持，因此直接安装clang和llvm包足够将C编译为BPF Object文件。</p>
<p>通过下面的命令确认你的LLVM支持BPF目标：</p>
<pre class="crayon-plain-tag">llc --version
LLVM (http://llvm.org/):
  LLVM version 10.0.0
  
  Optimized build.
  Default target: x86_64-pc-linux-gnu
  Host CPU: skylake

  Registered Targets:
    ...
    # 默认情况下，bpf目标使用编译它的CPU的端序
    bpf        - BPF (host endian)
    # 这两个目标用于交叉编译
    bpfeb      - BPF (big endian)
    bpfel      - BPF (little endian)</pre>
<div class="blog_h3"><span class="graybg">编译命令</span></div>
<p>对于下面这个最简单的XDP Drop程序：</p>
<pre class="crayon-plain-tag">#include &lt;linux/bpf.h&gt;

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return XDP_DROP;
}

char __license[] __section("license") = "GPL";</pre>
<p>可以这样编译：</p>
<pre class="crayon-plain-tag">clang -O2 -Wall -target bpf -c xdp-example.c -o xdp-example.o
# 加载，需要4.12或更高版本
# ip link set dev em1 xdp obj xdp-example.o</pre>
<div class="blog_h1"><span class="graybg">BPF CO-RE</span></div>
<p>所谓CO-RE是指一次编译，到处运行（Compile Once – Run Everywhere）。</p>
<p>编写可移植性（能够跨越不同内核版本运行）的BPF程序是一项挑战，BPF CO-RE能够辅助这一过程。</p>
<div class="blog_h2"><span class="graybg">问题</span></div>
<p>BPF程序本质上是一段直接插入内核的代码，它可以访问（被允许访问的）所有内核内部状态。BPF程序无法控制所运行内核的内存布局：</p>
<ol>
<li>不同内核版本，结构体字段的顺序可能不同，某些字段可能在新版本被内嵌到子结构中，字段的类型可能出现不兼容的变更</li>
<li>根据内核构建时的配置不同，某些字段可能被注释掉</li>
</ol>
<p>并不是所有BPF程序都需要访问内核数据结构，例如opensnoop，它依赖 kprobes/tracepoints 来追踪程序打开了那些文件，仅仅需要捕获一些系统调用参数，而系统调用提供稳定的ABI，不会随着内核版本而变化。</p>
<p>不幸的是，opensnoop这样的程序是少数派，不访问内核数据结构也限制了程序的能力。</p>
<p><span style="background-color: #c0c0c0;">内核中的BPF机制提供了一个有限集合的稳定接口</span>，BPF程序可以依赖这套结构，不用担心跨内核的兼容性问题。尽管底层结构会发生变化，但是BPF提供的抽象保持稳定。这种接口的一个例子是<pre class="crayon-plain-tag">__sk_buff</pre>，它是内核数据结构<pre class="crayon-plain-tag">sk_buff</pre>的稳定接口，可以访问sk_buff的一部分重要字段。对__sk_buff字段的访问，会被透明的转写（可以需要多次pointer chasing）为对sk_buff对应字段的访问。类似__sk_buff的、针对特定类型BPF程序的接口抽象还有不少，如果你编写这些类型的BPF程序，可能（如果接口可以满足需求）不用担心可移植性问题。</p>
<p>一旦有了读取内核内部原始状态的需求，例如读取<pre class="crayon-plain-tag">struct task_struct</pre>的某个字段，可移植性问题就出现了。如果某些内核为此结构添加了额外字段，如何保证读取的不是垃圾数据？如果出现了字段重命名，例如4.7将task_struct的fs字段重命名为fsbase，又该怎么办？这些可移植性问题都让你不能简单的使用开发机上的内核头文件来构建BPF程序。</p>
<div class="blog_h2"><span class="graybg">BCC方案</span></div>
<p>解决可移植性问题的一个方案是BCC，使用BCC时，BPF内核程序的C源码作为字符串嵌入在你的用户空间程序（控制程序）中，当控制程序被部署到目标环境后，BCC会调用内嵌的Clang/LLVM，拉取本地内核头文件，执行即席的BPF程序构建。如果字段可能在某些环境下compiled-out，你只需要在源码中使用相应的#ifdef/#else。BCC方案有一些关键的缺点：</p>
<ol>
<li>Clang/LLVM组合非常大，你需要将其部署到所有运行BPF程序的机器</li>
<li>Clang/LLVM在编译时很消耗资源，如果在程序启动时编译BPF代码，可能会生产环境的工作负载产生不利影响</li>
<li>目标机器上可能不包含匹配的内核头文件</li>
<li>开发和测试的迭代变得痛苦，可能在运行时得到很多编译错误</li>
<li>太多的magic，难以定位错误，你需要记住命名约定、自动为tracepoint生成的结构、依赖代码重写来读取内核数据/获取kprobe参数</li>
<li>读写BPF Map时需要编写半面向对象的C代码，和内核中发生的不完全匹配</li>
<li>仍然需要在用户空间编写大量的样板代码</li>
</ol>
<div class="blog_h2"><span class="graybg">CO-RE原理</span></div>
<p>BPF CO-RE将软件栈所有层次 —— 内核、用户空间BPF loader库（libbpf）、编译器（Clang）——的必要功能/数据片段整合到一起，来降低编写可移植性BPF程序的难度。CO-RE需要下列组件的谨慎集成和协作：</p>
<ol>
<li>BTF类型信息：允许捕获关于内核、BPF程序的类型/代码的关键信息</li>
<li>Clang为BPF程序C代码提供了express the intent和记录relocation信息的手段</li>
<li>BPF loader（libbpf）根据内核的BTF和BPF程序，调整编译后的BPF代码，使其适合在目标内核上运行</li>
<li>对于BPF CO-RE不可知的内核，提供了一些高级的BPF特性，满足高级场景</li>
</ol>
<div class="blog_h3"><span class="graybg">BTF</span></div>
<p>即BPF Type Format，类似于DWARF调试信息，但是没有那么generic和verbose。它是一种空间高效的、紧凑的、有足够表达能力的格式，足以描述C程序的所有类型信息。由于它的简单性和BPF去重算法，对比DWARF，BTF能够缩小100x的尺寸。现在，在运行时总是保留BTF信息是常见做法，它对应内核选项<pre class="crayon-plain-tag">CONFIG_DEBUG_INFO_BTF=y</pre>，在Ubuntu 20.10开始默认开启。</p>
<p>BTF能够用来增强BPF verifier的能力，能够允许BPF代码直接访问内核内存，不需要<pre class="crayon-plain-tag">bpf_probe_read()</pre>。</p>
<p>对于CO-RE来说，更重要的是，内核通过<pre class="crayon-plain-tag">/sys/kernel/btf/vmlinux</pre>暴露了权威的、自描述的BTF信息。执行下面的命令，你可以得到一个可编译的C头文件：</p>
<pre class="crayon-plain-tag">bpftool btf dump file /sys/kernel/btf/vmlinux format c</pre>
<p>此文件通常命名为<pre class="crayon-plain-tag">vmlinux.h</pre>，其中包含了所有的内核类型信息，甚至包含那些不会通过kernel-devel包暴露的信息。</p>
<div class="blog_h3"><span class="graybg">编译器支持</span></div>
<p>为了启用CO-RE，并且让BPF loader（libbpf）来为正在运行的（目标）内核调整BPF程序，Clang被扩展，增加了一些built-ins。</p>
<p>这些built-ins会发出（emit）BTF relocations，BTF relocations是BPF程序需要读取什么信息的高层描述。假设程序需要访问task_struct-&gt;pid，Clang会将其记录：需要访问pid_t类型的、名为pid、位于task_struct结构中的字段。这样，即使字段顺序调整，甚至pid字段被放入一个内嵌的匿名结构体/联合体中，BPF程序仍然能够正确访问到pid字段。</p>
<p>能够捕获（进而重定位）的信息不单单是字段偏移量，还包括字段是否存在、字段的size。甚至对于位域（bitfield）字段，也能够捕获足够多的信息，让对它的访问能够被重定位。</p>
<div class="blog_h3"><span class="graybg">BPF loader</span></div>
<p>BPF loader在加载程序时，会利用前述的（构建机的）内核BTF信息、Clang重定位信息，并读取当前内核的BTF信息，对BPF程序（ELF object文件）进行裁减（custom tailored） —— 解析和匹配所有类型、字段，更新字段偏移量，以及其它可重定位数据 —— 确保程序在当前内核上能够正确运行。</p>
<div class="blog_h3"><span class="graybg">内核</span></div>
<p>要支持CO-RE，内核不需要更多的改变（除了开启CONFIG_DEBUG_INFO_BTF）。被BPF loader（libbpf）处理过的BPF程序，对于内核来说，和在本机编译的BPF程序是完全等价的。</p>
<div class="blog_h2"><span class="graybg">CO-RE现状</span></div>
<p>截至2021年，BPF CO-RE是很成熟的技术，在大量生产环境下运行。</p>
<p>由于引入了BPF CO-RE，超过25个BCC工具被转换为libbpf +BPF CO-RE方式编写。由于越来越多的Linux发行版（Ubuntu 20.10、RHEL 8.2+）默认开启BTF，BPF CO-RE相关工具的适用面变得越来越广，可以替代笨重的、基于python的BCC工具。</p>
<p>BPF CO-RE在不同BPF应用领域被广泛接受，包括追踪、性能监控、安全/审计，甚至网络BPF程序。</p>
<p>要使用BPF CO-RE，可以考虑从脚手架项目libbpf-bootstrap开始。</p>
<div class="blog_h2"><span class="graybg">使用CO-RE</span></div>
<div class="blog_h3"><span class="graybg">解除内核依赖</span></div>
<p>为了避免依赖于系统头文件，可以生成包含所有内核类型的vmlinux.h：</p>
<pre class="crayon-plain-tag">bpftool btf dump file /sys/kernel/btf/vmlinux format c &gt; vmlinux.h</pre>
<p>这样你的代码中就不需要包含各种内核头文件、也不必须安装kernel-devel包了：</p>
<pre class="crayon-plain-tag">#include &lt;linux/sched.h&gt;
#include &lt;linux/fs.h&gt;</pre>
<p>由于BTF（以及DWARF）不会记录宏信息，因此某些常用的宏可能没有包含在vmlinux.h中，好在其中大部分可以通过bpf_helpers.h访问。</p>
<div class="blog_h3"><span class="graybg">读取内核结构</span></div>
<p>使用BCC时，你可以直接访问：</p>
<pre class="crayon-plain-tag">pid_t pid = task-&gt;pid;</pre>
<p>BCC会自动将其重写为对<pre class="crayon-plain-tag">bpf_probe_read()</pre>的调用。</p>
<p>使用CO-RE的时候，由于没有BCC这种代码重写机制，为了打到同样效果，你可能需要：</p>
<ol>
<li>libbpf + BPF_PROG_TYPE_TRACING：如果编写的是这类程序，你可以直接写：<br />
<pre class="crayon-plain-tag">pid_t pid = task-&gt;pid;</pre></p>
<p>而不需要bpf_probe_read()调用。要实现可移植性，则需要将上述代码包围到 <pre class="crayon-plain-tag">__builtin_preserve_access_index</pre>中：</p>
<pre class="crayon-plain-tag">pid_t pid = __builtin_preserve_access_index(({ task-&gt;pid; }));</pre>
</li>
<li>对于其它类型BPF程序，你不能直接访问结构字段，而需要：<br />
<pre class="crayon-plain-tag">pid_t pid; bpf_probe_read(&amp;pid, sizeof(pid), &amp;task-&gt;pid);</pre>
<p>要实现可移植性，则需要：</p>
<pre class="crayon-plain-tag">pid_t pid; bpf_core_read(&amp;pid, sizeof(pid), &amp;task-&gt;pid);
// 或者
bpf_probe_read(&amp;pid, sizeof(pid), __builtin_preserve_access_index(&amp;task-&gt;pid));</pre>
</li>
</ol>
<p>进行pointer chasing时，使用bpf_probe_read()/bpf_core_read()会变得痛苦：
<pre class="crayon-plain-tag">u64 inode = task-&gt;mm-&gt;exe_file-&gt;f_inode-&gt;i_ino;</pre>
<p>你需要逐步的分配指针临时变量，逐步读取字段，非常麻烦。 幸运的是，CO-RE提供了助手宏：</p>
<pre class="crayon-plain-tag">u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);

// 或者
u64 inode;
BPF_CORE_READ_INTO(&amp;inode, task, mm, exe_file, f_inode, i_ino);</pre>
<p>类似的，和<pre class="crayon-plain-tag">bpf_probe_read_str()</pre>对应的CO-RE函数是<pre class="crayon-plain-tag">bpf_core_read_str()</pre>，以及助手宏<pre class="crayon-plain-tag">BPF_CORE_READ_STR_INTO()</pre>。</p>
<p>要检查字段是否在目标内核存在，可以使用<pre class="crayon-plain-tag">bpf_core_field_exists()</pre>宏：</p>
<pre class="crayon-plain-tag">pid_t pid = bpf_core_field_exists(task-&gt;pid) ? BPF_CORE_READ(task, pid) : -1;</pre>
<p>某些内部的、非UAPI的内核枚举值，可能跨内核版本时发生变动，甚至依赖于特定的内核配置（例如cgroup_subsys_id），这导致硬编码任何值都是不可靠的。使用Enum relocation宏<pre class="crayon-plain-tag">bpf_core_enum_value_exists()</pre>和<pre class="crayon-plain-tag">bpf_core_enum_value()</pre>，可以检查特定枚举值是否存在，并捕获它的值。Enum relocation重定向的一个重要用途是检测BPF助手函数是否存在，如果不存在则使用旧版本的替代物。</p>
<p>要捕获（如果不确定某个字段是否在别的内核版本中发生了类型变更）字段的size，可以使用 <pre class="crayon-plain-tag">bpf_core_field_size()</pre>：</p>
<pre class="crayon-plain-tag">u32 comm_sz = bpf_core_field_size(task-&gt;comm);</pre>
<p>位域字段的读取，可以使用：</p>
<pre class="crayon-plain-tag">struct tcp_sock *s = ...;

// 读取s-&gt;is_cwnd_limited对应的位域字段
bool is_cwnd_limited = BPF_CORE_READ_BITFIELD(s, is_cwnd_limited);

// 或者
u64 is_cwnd_limited;
BPF_CORE_READ_BITFIELD_PROBED(s, is_cwnd_limited, &amp;is_cwnd_limited);</pre>
<div class="blog_h3"><span class="graybg">内核版本和配置差异</span></div>
<p>某些情况下，内核之间不是简单的结构性差异：</p>
<ol>
<li>同一含义的字段可能被重命名</li>
<li>字段的含义可能改变，例如从4.6开始，task_struct.utime/stime从原先的以jiffies为单位改为纳秒为单位</li>
</ol>
<p>内核配置的差异，可能会出现某些内核下无法读取字段的情况。</p>
<p>CO-RE提供处理这些问题的辅助机制是libbpf提供的extern Kconfig variables和struct flavors。</p>
<p>BPF程序可以定义具有知名名称的变量，例如LINUX_KERNEL_VERSION；或者一个匹配内核Kconfig键的变量，例如CONFIG_HZ。libbpf能够自动设置这些外部变量为匹配当前内核的值，BPF verifier也会跟踪这些变量，并进行高级的流分析和dead code消除。</p>
<pre class="crayon-plain-tag">//                              声明外部Kconfig变量
extern u32 LINUX_KERNEL_VERSION __kconfig;
extern u32 CONFIG_HZ __kconfig;

u64 utime_ns;

if (LINUX_KERNEL_VERSION &gt;= KERNEL_VERSION(4, 11, 0))
    utime_ns = BPF_CORE_READ(task, utime);
else
    /* convert jiffies to nanoseconds */
    utime_ns = BPF_CORE_READ(task, utime) * (1000000000UL / CONFIG_HZ);</pre>
<p>struct flavors则用于解决内核存在不兼容类型的情况。实际上就是为不同版本的内核定义不同的结构：</p>
<pre class="crayon-plain-tag">// 新版本内核使用此结构
struct thread_struct {
    ...
    u64 fsbase;
    ...
};

// 4.6或者更低版本使用此结构
// 三下划线及其后面的部分，被认为是结构的一个flavor，flavor部分会被libbpf忽略，
// 这意味着在进行relocation时thread_struct___v46仍然对应着运行中的内核的thread_struct结构

struct thread_struct___v46 { /* ___v46 is a "flavor" part */
    ...
    u64 fs;
    ...
};

extern int LINUX_KERNEL_VERSION __kconfig;
...

struct thread_struct *thr = ...;
u64 fsbase;
if (LINUX_KERNEL_VERSION &gt; KERNEL_VERSION(4, 6, 0))
    // 强制转型为flavor，从而抽取需要的字段
    fsbase = BPF_CORE_READ((struct thread_struct___v46 *)thr, fs);
else
    fsbase = BPF_CORE_READ(thr, fsbase);</pre>
<p>如果没有struct flavors，你就不能编写可移植的BPF程序。你只能通过#ifdef条件编译为多个BPF object文件，然后在控制程序中，判断当前内核版本，然后选择加载匹配的BPF object。</p>
<div class="blog_h3"><span class="graybg">根据用户配置修改行为</span></div>
<p>即使知道目标内核版本、配置，BPF程序可能仍然不知道如何从内核中读取需要的数据。这种情况下，可以通过用户空间的控制程序进行精确的判断，然后通过BPF Map传递一个配置信息给BPF程序，BPF程序根据此配置信息改变自己的行为。这种做法的缺点是：</p>
<ol>
<li>BPF程序每次都需要读取Map的成本，对于高性能BPF程序不可忽略</li>
<li>配置信息即使在BPF程序启动后是不可变的（没有代码去改它），但是对于BPF verifier来说，仍然是一个黑盒。BPF verifier不能根据配置信息来裁减掉dead code，或者进行其它高级的代码分析。这样，为新版本内核（假设这个版本引入了新的助手函数）编写的分支，在旧版本内核上无法被裁减，从而可能破坏程序（无法通过校验，以为助手函数不存在）</li>
</ol>
<p>解决上述缺点的方法是使用只读全局变量。变量的值由控制程序加载BPF object文件后设置（修改ELF文件）。这不会带来Map查询的成本，BPF verifer会将其此变量作为常量看待，从而裁减dead code。</p>
<pre class="crayon-plain-tag">/* global read-only variables, set up by control app */
const bool use_fancy_helper;
const u32 fallback_value;

...

u32 value;
if (use_fancy_helper)
    value = bpf_fancy_helper(ctx);
else
    value = bpf_default_helper(ctx) * fallback_value;</pre>
<p>通过BPF skeleton，可以很容易的从用户空间修改ELF文件。</p>
<div class="blog_h3"><span class="graybg">编译BPF程序</span></div>
<p>利用上文提到的BPF CO-RE提供的多种能力，编写好代码后，你需要用Clang 10+版本，编译得到BPF object文件。</p>
<div class="blog_h3"><span class="graybg">生成BPF skeleton</span></div>
<p>从编译好的BPF object文件，可以利用<pre class="crayon-plain-tag">bpftool gen skeleton</pre>自动生成BPF skeleton。</p>
<div class="blog_h3"><span class="graybg">编写控制程序</span></div>
<p>将BPF skeleton（头文件）包含到你的用户空间控制程序中，获得打开、加载、挂钩BPF程序，以及修改BPF对象等能力。</p>
<div class="blog_h1"><span class="graybg">eBPF编程</span></div>
<div class="blog_h2"><span class="graybg">C编程要点</span></div>
<p>使用C语言编写eBPF程序，需要注意：</p>
<ol>
<li>可以访问助手函数、上下文对象</li>
<li>程序的入口点通过段来指定，而非main函数</li>
<li>在对象文件中包含多个入口点是允许的</li>
<li>所有库函数调用被内联，因而运行时不存在函数调用的开销</li>
<li>没有全局变量（5.5-）</li>
<li>没有循环</li>
<li>没有常量</li>
<li>LLVM的内置函数一般是可用的、并且被内联</li>
<li>栈空间大小限制为512字节</li>
</ol>
<div class="blog_h3"><span class="graybg">内联一切</span></div>
<p>除非使用支持BPF-BPF调用的4.16+内核（和LLVM6.0+），所有函数都需要被内联，没有函数调用（老版本LLVM上）或共享库调用。</p>
<p>BPF程序不能使用共享库，公共代码可以放在头文件中，被主程序include。尽管不能使用共享库，但是通过include头文件来使用静态内联函数、宏定义是很常见的。</p>
<p>为了确保这一点，需要为所有作为库使用的函数标注__inline：</p>
<pre class="crayon-plain-tag">#include &lt;linux/bpf.h&gt;

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline                         \
   // 使用always_inline，因为仅仅使用inline编译器仍然可能在
   // 代码过大的情况下不内联，从而导致在ELF文件中生成一个relocation entry
   // iproute2这样的ELF loader不能解析BPF程序
   // 仅仅BPF Map是ELF loader能够处理的relication entry
   inline __attribute__((always_inline))
#endif

static __inline int foo(void)
{
    return XDP_DROP;
}

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return foo();
}

char __license[] __section("license") = "GPL";</pre>
<p>从4.16开始支持在BPF程序中使用BPF-BPF函数调用，libbpf v0.2+也对此特性提供的完整的支持，确保代码的relocations/adjustment正确进行。你可以去掉<pre class="crayon-plain-tag">__always_inline</pre>，甚至用<pre class="crayon-plain-tag">__noinline</pre>强制不得进行内联。</p>
<p>非内联的global函数从5.5+开始被支持，但是和static函数比起来具有不同的语义以及校验约束。</p>
<div class="blog_h3"><span class="graybg">每个程序一个段</span></div>
<p>BPF的C程序依赖段（section）注解。典型的C程序被结构化为三个或更多的段，BPF ELF loader<span style="background-color: #c0c0c0;">通过段的名字来抽取、准备相关的信息</span>，以便载入程序和Map。</p>
<p>例如，iproute2使用maps和license作为默认的段名称，来获取创建Map所需元数据，以及BPF程序的License信息。加载时，License信息也被推入内核，这样某些仅仅在GPL协议下暴露的函数（例如bpf_ktime_get_ns和bpf_probe_read）允许被调用，确保BPF程序的License兼容性。</p>
<p>其它的段名，都专用于BPF程序代码。下面的代码使用了ingress/egress两个段，可以被tc加载并挂钩到网络设备的ingress/egress钩子：</p>
<pre class="crayon-plain-tag">#include &lt;linux/bpf.h&gt;
#include &lt;linux/pkt_cls.h&gt;
#include &lt;stdint.h&gt;
#include &lt;iproute2/bpf_elf.h&gt;

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline                         \
   inline __attribute__((always_inline))
#endif

// 共享的Map是全局变量，因此访问它的时候需要同步
#ifndef lock_xadd
# define lock_xadd(ptr, val)              \
   ((void)__sync_fetch_and_add(ptr, val))
#endif

// 这个宏用于将BPF助手函数映射到C代码
// 函数map_lookup_elem被映射到定义uapi/linux/bpf.h在中的枚举值 BPF_FUNC_map_lookup_elem
// 在载入内核后，Verifier会检查传入的参数是否为期望的类型，并且将对助手函数的调用指向真实函数的调用
#ifndef BPF_FUNC
# define BPF_FUNC(NAME, ...)              \
   (*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME
#endif

static void *BPF_FUNC(map_lookup_elem, void *map, const void *key);
// static void (*map_lookup_elem)(void *map, const void *key) = (void *)BPF_FUNC_map_lookup_elem;




// 这个是共享的Map，类型struct bpf_elf_map是iproute2定义的
// iproute2提供了公共的BPF ELF loader，因此struct bpf_elf_map对于XDP和tc程序来说是一样的
//                         必须放在maps段，这样loader才能发现
//                         可以定义多个Map，都必须放在maps段
struct bpf_elf_map acc_map __section("maps") = {
    .type           = BPF_MAP_TYPE_ARRAY,
    .size_key       = sizeof(uint32_t),
    .size_value     = sizeof(uint32_t),
    // 该Map被Pin到PIN_GLOBAL_NS，这意味着Map将被tc钉为BPF伪文件系统中的位于
    // /sys/fs/bpf/tc/globals/目录下的节点。对于此acc_map，节点路径为
    // /sys/fs/bpf/tc/globals/acc_map
    // global是跨越多个Object文件的全局命名空间。如果不同BPF程序中均有名为acc_map
    // 的Map映射到PIN_GLOBAL_NS，这这些程序会共享统一Map。仅仅第一个载入的BPF程序会触发
    // Map的创建，后续载入的程序直接使用

    // 如果取值PIN_NONE则不会映射为BPF文件系统中的节点，当tc退出后，无法从用户空间访问Map
    .pinning        = PIN_GLOBAL_NS,
    .max_elem       = 2,
};

// 这个是共享的内联函数
static __inline int account_data(struct __sk_buff *skb, uint32_t dir)
{
    uint32_t *bytes;
    // 将Map传递给助手函数
    bytes = map_lookup_elem(&amp;acc_map, &amp;dir);
    if (bytes)
            lock_xadd(bytes, skb-&gt;len);

    return TC_ACT_OK;
}

// 两个段，都会调用account_data往Map中写入数据
__section("ingress")
int tc_ingress(struct __sk_buff *skb)
{
    return account_data(skb, 0);
}

__section("egress")
int tc_egress(struct __sk_buff *skb)
{
    return account_data(skb, 1);
}

char __license[] __section("license") = "GPL";</pre>
<p>使用下面的命令编译：</p>
<pre class="crayon-plain-tag">clang -O2 -Wall -target bpf -c tc-example.c -o tc-example.o</pre>
<p>利用tc加载该程序：</p>
<pre class="crayon-plain-tag">tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf da obj tc-example.o sec ingress
tc filter add dev eth0 egress bpf da obj tc-example.o sec egress

tc filter show dev eth0 ingress
# filter protocol all pref 49152 bpf
# filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

tc filter show dev em1 egress
# filter protocol all pref 49152 bpf
# filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[egress] direct-action id 2 tag b2fd5adc0f262714

mount | grep bpf
# sysfs on /sys/fs/bpf type sysfs (rw,nosuid,nodev,noexec,relatime,seclabel)
# bpf on /sys/fs/bpf type bpf (rw,relatime,mode=0700)

tree /sys/fs/bpf/
# /sys/fs/bpf/
# +-- ip -&gt; /sys/fs/bpf/tc/
# +-- tc
# |   +-- globals
# |       +-- acc_map
# +-- xdp -&gt; /sys/fs/bpf/tc/</pre>
<p>一旦有封包通过eth0接口，则acc_map中的计数器值就会增加。</p>
<div class="blog_h3"><span class="graybg">没有全局变量</span></div>
<p>除非内核版本在5.5+以上，BPF程序中没有普通C程序中的全局变量。5.5+的全局变量底层仍然是基于BPF Map实现的。</p>
<p>作为变通方案，可以使用BPF_MAP_TYPE_PERCPU_ARRAY类型的Map，这种Map为每个CPU核心存储一个任意大小的值。由于<span style="background-color: #c0c0c0;">BPF程序在运行过程中绝不会被抢占</span>，在此Map中初始化一个临时的缓冲区（例如为了突破栈的大小限制）用作全局变量是安全的。在<span style="background-color: #c0c0c0;">发生尾调用的情况下也不会发生抢占，Map的内容不会消失</span>。</p>
<p>对于任何需要跨越多次BPF程序运行保存的状态，都使用普通BPF Map即可。</p>
<div class="blog_h3"><span class="graybg">没有常量字符串或数组</span></div>
<p>由于一切内联、不支持全局变量，定义<pre class="crayon-plain-tag">const</pre>的字符串或其它数组都是不被支持的，生成在ELF文件中的relocation entry会被BPF ELF loaders拒绝。</p>
<p>打印调试消息可以利用助手函数trace_printk：</p>
<pre class="crayon-plain-tag">static void BPF_FUNC(trace_printk, const char *fmt, int fmt_size, ...);

#ifndef printk
# define printk(fmt, ...)                                      \
    ({                                                         \
        char ____fmt[] = fmt;                                  \
        trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
    })
#endif


// 调用
printk("skb len:%u\n", skb-&gt;len);

// 在用于空间使用下面的命令查看打印的消息
// tc exec bpf dbg</pre>
<p>不建议在生产环境使用trace_printk()助手函数，因为类似于<pre class="crayon-plain-tag">"skb len:%u\n"</pre>这样的字符串必须在每次调用时载入到BPF Stack，此外助手函数最多支持5个参数。</p>
<p>对于网络应用，可以使用<pre class="crayon-plain-tag">skb_event_output()</pre>或者<pre class="crayon-plain-tag">xdp_event_output()</pre>代替，它们允许从BPF程序传递自定义的结构体，外加一个可选的packet sample到perf event ring buffer。</p>
<div class="blog_h3"><span class="graybg">使用LLVM内置函数</span></div>
<p>除了助手函数之外，BPF程序不能发起任何函数调用，因此公共库代码需要实现为内联函数。此外LLVM提供的一些builtins可以被使用，并且总是保证被内联：</p>
<pre class="crayon-plain-tag">#ifndef memset
# define memset(dest, chr, n)   __builtin_memset((dest), (chr), (n))
#endif

#ifndef memcpy
# define memcpy(dest, src, n)   __builtin_memcpy((dest), (src), (n))
#endif

#ifndef memmove
# define memmove(dest, src, n)  __builtin_memmove((dest), (src), (n))
#endif</pre>
<p>内置函数memcmp()存在一些边缘场景，会导致不发生inline，因此在LLVM解决此问题之前不推荐使用。</p>
<div class="blog_h3"><span class="graybg">尚不支持循环</span></div>
<p>BPF Verifier会检查程序代码，确保其不包含循环，目的是确保程序总是能停止。</p>
<p>只有非常特殊形式的循环被允许：</p>
<pre class="crayon-plain-tag">// 使用该指令，内核5.3+不再需要
#pragma unroll
    // 循环的upper buounds是常量
    for (i = 0; i &lt; IPV6_MAX_HEADERS; i++) {
        // ...
    }</pre>
<p>另外一种比较刁钻的实现循环的方式是，自我尾调用， 使用一个BPF_MAP_TYPE_PERCPU_ARRAY作为变量存储。这种方式的循环次数是动态的，但是最多迭代34次（初始程序，加上最多33次尾调用）。</p>
<div class="blog_h3"><span class="graybg">使用尾调用</span></div>
<p>尾调用提供了一种灵活的、在运行时原子的修改程序行为的方式，它的做法是从一个BPF程序跳转到另外一个，同时保留当前栈帧。尾调用没有return的概念，当前程序直接被替换掉。</p>
<p>发起尾调用时必须使用BPF_MAP_TYPE_PROG_ARRAY类型的Map，传递目标BPF程序所在索引。</p>
<p>使用尾调用可以非常灵活的对程序进行“分区”。例如挂钩到XDP或tc的根BPF程序可以发起对索引为0的BPF程序的尾调用，后者执行流量采样，然后跳转到BPF程序1，在此应用防火墙策略。封包在此被丢弃，或进一步尾调用2来处理，BPF程序2修改封包并将其从网络接口发出。</p>
<pre class="crayon-plain-tag">#ifndef __stringify
# define __stringify(X)   #X
#endif

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __section_tail
# define __section_tail(ID, KEY)          \
   __section(__stringify(ID) "/" __stringify(KEY))
#endif

#ifndef BPF_FUNC
# define BPF_FUNC(NAME, ...)              \
   (*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME
#endif

#define BPF_JMP_MAP_ID   1

static void BPF_FUNC(tail_call, struct __sk_buff *skb, void *map,
                     uint32_t index);

// 创建一个eBPF程序数组并且钉到BPF文件系统的全局命名空间下的/jmp_map节点
struct bpf_elf_map jmp_map __section("maps") = {
    .type           = BPF_MAP_TYPE_PROG_ARRAY,
    .id             = BPF_JMP_MAP_ID,
    .size_key       = sizeof(uint32_t),
    .size_value     = sizeof(uint32_t),
    .pinning        = PIN_GLOBAL_NS,
    .max_elem       = 1,
};

// iproute2的BPF ELF loader能够识别标记为__section_tail()的块，将其存放到某个程序数组中
// 第一个参数ID用于决定存放到哪个数组，第二个参数用于决定存放到数组的哪个索引
// 不仅仅是tc，任何iproute2支持的BPF程序类型（XDP，lwt等）都可以使用这种标记
__section_tail(BPF_JMP_MAP_ID, 0)
int looper(struct __sk_buff *skb)
{
    printk("skb cb: %u\n", skb-&gt;cb[0]++);
    tail_call(skb, &amp;jmp_map, 0);
    return TC_ACT_OK;
}

// 主程序
__section("prog")
int entry(struct __sk_buff *skb)
{
    skb-&gt;cb[0] = 0;
    // 发起尾调用
    tail_call(skb, &amp;jmp_map, 0);
    return TC_ACT_OK;
}

char __license[] __section("license") = "GPL";</pre>
<p>钉在BPF伪文件系统的BPF程序数组，可被用户空间程序查询或修改，tc也提供了更新BPF程序的命令：</p>
<pre class="crayon-plain-tag">#                   更换globals/jmp_map的 0索引元素
#                                             用new.o的 foo段代替
tc exec bpf graft m:globals/jmp_map key 0 obj new.o sec foo</pre>
<div class="blog_h3"><span class="graybg">受限的栈空间</span></div>
<p>BPF程序的栈空间仅有512字节，因此编码时需要小心。要使用一个较大的缓冲区，可以从BPF_MAP_TYPE_PERCPU_ARRAY类型的Map分配。</p>
<div class="blog_h3"><span class="graybg">去除字节对齐补白</span></div>
<p>现代编译器默认情况下会进行字节边界对齐 —— 结构体成员被对齐到是它们长度的整数倍的内存边界，空出的部分自动补白：</p>
<pre class="crayon-plain-tag">struct called_info {
    u64 start;  // 8-byte
    u64 end;    // 8-byte
    u32 sector; // 4-byte
}; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info
// 0x0(0)                   0x8(8)
//  ↓________________________↓
//  |        start (8)       |
//  |________________________|
//  |         end  (8)       |
//  |________________________|
//  |  sector(4) |  PADDING  | &lt;= address aligned to 8
//  |____________|___________|     with 4-byte PADDING.</pre>
<p>由于字节对齐的原因，结构体的大小通常比期望的大。</p>
<p>BPF Verifier会检查栈的边界，确保程序不会越界（512字节）访问，或者访问未初始化的栈区域。使用带补白的结构作为Map的值，可能导致在<pre class="crayon-plain-tag">bpf_prog_load()</pre>时报invalid indirect read from stack错。</p>
<p>你需要使用pack指令移除补白：</p>
<pre class="crayon-plain-tag">#pragma pack(4)
struct called_info {
    u64 start;  // 8-byte
    u64 end;    // 8-byte
    u32 sector; // 4-byte
}; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 20-byte

// Actual compiled composition of packed struct called_info
// 0x0(0)                   0x8(8)
//  ↓________________________↓
//  |        start (8)       |
//  |________________________|
//  |         end  (8)       |
//  |________________________|
//  |  sector(4) |             &lt;= address aligned to 4
//  |____________|                 with no PADDING.</pre>
<p>移除字节对齐补白后，会导致CPU内存访问效率的降低，在某些体系结构下，不对齐的访问（unaligned access）可能被Verifier拒绝。</p>
<p>所以，最优的方式是人工添加仅用于实现字节对齐的pad字段：</p>
<pre class="crayon-plain-tag">struct called_info {
    u64 start;  // 8-byte
    u64 end;    // 8-byte
    u32 sector; // 4-byte
    u32 pad;    // 4-byte
}; // size of 24-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info with explicit padding
// 0x0(0)                   0x8(8)
//  ↓________________________↓
//  |        start (8)       |
//  |________________________|
//  |         end  (8)       |
//  |________________________|
//  |  sector(4) |  pad (4)  | &lt;= address aligned to 8
//  |____________|___________|     with explicit PADDING.</pre>
<div class="blog_h3"><span class="graybg">无效引用问题</span></div>
<p>诸如bpf_skb_store_bytes之类的助手函数，会导致封包的size发生变化。由于Verifier无法在运行时跟踪这种变化，因此一旦调用了这类助手函数，对数据的引用（指针）立刻会被Verifer无效化（invalidated）：</p>
<pre class="crayon-plain-tag">struct iphdr *ip4 = (struct iphdr *) skb-&gt;data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &amp;new_saddr, 4, 0);

// Verifier会拒绝下面的代码，因为此处ip4这个指针已经无效了，不能解引用
if (ip4-&gt;protocol == IPPROTO_TCP) {
    // do something
}</pre>
<p>解决办法是重新获取引用：</p>
<pre class="crayon-plain-tag">struct iphdr *ip4 = (struct iphdr *) skb-&gt;data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &amp;new_saddr, 4, 0);

// 重新获取引用
ip4 = (struct iphdr *) skb-&gt;data + ETH_HLEN;

if (ip4-&gt;protocol == IPPROTO_TCP) {
    // do something
}</pre>
<div class="blog_h2"><span class="graybg">bpftool</span></div>
<p>bpftool是内核提供（tools/bpf/bpftool/）的，主要的BPF内省（introspection）和调试工具。它支持：</p>
<ol>
<li>Dump出当前加载到系统中的所有BPF程序，以及Maps</li>
<li>列出指定程序使用的Maps</li>
<li>Dump中Map的所有键值对</li>
<li>对Map键值对进行增删改查</li>
<li>将Map或程序钉到BPF伪文件系统</li>
</ol>
<p>指定bpftool操作的目标时，可以使用ID，或者目标在BPF伪文件系统中的路径。</p>
<div class="blog_h3"><span class="graybg">查看程序和Map</span></div>
<p>列出所有程序：</p>
<pre class="crayon-plain-tag">bpftool prog</pre>
<p>输出为JSON：</p>
<pre class="crayon-plain-tag"># 对于所有子命令，都支持输出JSON
bpftool prog --json --pretty</pre>
<p>列出所有Map：</p>
<pre class="crayon-plain-tag">bpftool map</pre>
<p>查看特定程序：</p>
<pre class="crayon-plain-tag">bpftool prog show id 406
#    程序类型为sched_cls，即BPF_PROG_TYPE_SCHED_CLS
406: sched_cls  tag e0362f5bd9163a0a
#    加载此程序的用户和时间
     loaded_at Apr 09/16:24  uid 0
#    指令序列长度为11144字节
#                   JIT编译后的映像为7721字节
#                               程序本身（不包含Map）占用空间122888字节
#                                                使用的Maps列表
     xlated 11144B  jited 7721B  memlock 12288B  map_ids 18,20,8,5,6,14</pre>
<div class="blog_h3"><span class="graybg">Dump程序指令</span></div>
<p>使用下面的命令可以dump出BPF程序的指令：</p>
<pre class="crayon-plain-tag"># bpftool prog dump xlated id 406
 0: (b7) r7 = 0
 1: (63) *(u32 *)(r1 +60) = r7
 2: (63) *(u32 *)(r1 +56) = r7
 3: (63) *(u32 *)(r1 +52) = r7
[...]
47: (bf) r4 = r10
48: (07) r4 += -40
49: (79) r6 = *(u64 *)(r10 -104)
50: (bf) r1 = r6
51: (18) r2 = map[id:18]                    # &lt;-- 使用ID为18的Map
53: (b7) r5 = 32
54: (85) call bpf_skb_event_output#5656112  # &lt;-- 调用助手函数
55: (69) r1 = *(u16 *)(r6 +192)
[...]</pre>
<p>使用下面的命令可以dump出程序JIT后的汇编指令：</p>
<pre class="crayon-plain-tag"># bpftool prog dump jited id 406
 0:        push   %rbp
 1:        mov    %rsp,%rbp
 4:        sub    $0x228,%rsp
 b:        sub    $0x28,%rbp
 f:        mov    %rbx,0x0(%rbp)
13:        mov    %r13,0x8(%rbp)
17:        mov    %r14,0x10(%rbp)
1b:        mov    %r15,0x18(%rbp)
1f:        xor    %eax,%eax
21:        mov    %rax,0x20(%rbp)
25:        mov    0x80(%rdi),%r9d</pre>
<div class="blog_h3"><span class="graybg">Dump Map</span></div>
<p>Dump整个Map：</p>
<pre class="crayon-plain-tag">bpftool map dump id 5</pre>
<div class="blog_h2"><span class="graybg">libbpf</span></div>
<p>libbpf是一个C/C++库，作为内核的一部分进行维护，位于tools/lib/bpf目录下。内核自带的eBPF代码样例均依赖于此库。libbpf提供了一个eBPF loader，用于处理LLVM生成的ELF文件，将其载入内核。libbpf中的一部分特性源自BCC，它也包含了一些额外的功能，例如全局变量、BPF Skeletons。</p>
<div class="blog_h2"><span class="graybg">BPF系统调用</span></div>
<p>为了支持eBPF相关操作，例如载入eBPF程序、挂钩到特定事件、创建和访问eBPF Map，Linux中引入了一个新的系统调用<pre class="crayon-plain-tag">bpf</pre>。</p>
<p>该系统调用的签名如下：</p>
<pre class="crayon-plain-tag">//      命令      用于内核和用户空间的数据交互  attr的字节数
int bpf(int cmd, union bpf_attr *attr,      unsigned int size);
// cmd有很多，要么和eBPF程序交互、要么和eBPF Map交互，或者同时和两者交互</pre>
<div class="blog_h3"><span class="graybg">BPF_PROG_LOAD</span></div>
<p>该命令用于载入eBPF程序。载入的时候需要指明程序的类型。程序类型决定了以下事项：</p>
<ol>
<li>程序在何处挂钩</li>
<li>校验器允许程序调用哪些助手函数</li>
<li>是否允许直接访问网络封包数据</li>
<li>传递给程序的<span style="background-color: #c0c0c0;">第一个参数的对象的类型</span></li>
</ol>
<p>可以看到，程序类型规定了eBPF程序的API接口。某些时候，定义一个新的程序类型，仅仅是为了限制可调用函数的列表，例如BPF_PROG_TYPE_CGROUP_SKB、BPF_PROG_TYPE_SOCKET_FILTER</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_CREATE</span></div>
<p>用于创建eBPF Maps，参考下文。</p>
<div class="blog_h2"><span class="graybg">BPF程序类型</span></div>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_SOCKET_FILTER</span></div>
<p>网络封包过滤器</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_KPROBE</span></div>
<p>挂钩到一个KProbe，BPF程序在某个内核函数被调用时触发</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_SCHED_CLS</span></div>
<p>网络流量控制（TC）的分类器（classifier）</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_SCHED_ACT</span></div>
<p>网络流量控制（TC）动作（action）</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_TRACEPOINT</span></div>
<p>挂钩到一个Tracepoint，当执行到内核特定的代码路径时触发BPF程序</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_XDP</span></div>
<p>在设备驱动接收路径上运行的网络封包过滤器</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_PERF_EVENT</span></div>
<p>决定是否应当触发一个Perf Event Handler</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_CGROUP_SKB</span></div>
<p>为控制组提供的网络封包过滤器</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_CGROUP_SOCK</span></div>
<p>同上，但是允许修改套接字选项</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_LWT_*</span></div>
<p>用于轻量级隧道（lightweight tunnels ）的网络封包过滤器</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_SOCK_OPS</span></div>
<p>用于设置套接字选项</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_TYPE_SK_SKB</span></div>
<p>用于在套接字之间转发封包</p>
<div class="blog_h3"><span class="graybg">BPF_PROG_CGROUP_DEVICE</span></div>
<p>决定是否允许一个设备操作</p>
<div class="blog_h2"><span class="graybg">BPF Map</span></div>
<p>部分Map专供特定的助手函数使用，以实现特殊任务。</p>
<div class="blog_h3"><span class="graybg">定义Map</span></div>
<p>要在BPF程序中定义一个Map，需要用到如下结构：</p>
<pre class="crayon-plain-tag">struct bpf_map_def {
        unsigned int type; // Map类型
        unsigned int key_size; // 键的长度
        unsigned int value_size; // 值的长度
        unsigned int max_entries; // 最大键值对数量
        unsigned int map_flags; // 标记
        unsigned int inner_map_idx;
        unsigned int numa_node;
};</pre>
<p>下面是一个例子：</p>
<pre class="crayon-plain-tag">struct bpf_map_def SEC("maps") my_map = {
        .type = BPF_MAP_TYPE_ARRAY,
        .key_size = sizeof(int),
        .value_size = sizeof(u64),
        .max_entries = MAX_CPU,
};</pre>
<p>在加载阶段，<pre class="crayon-plain-tag">bpf_load.c</pre>会扫描BPF object的ELF头以发现Map定义，并调用tools/lib/bpf/bpf.c中的<pre class="crayon-plain-tag">bpf_create_map_node()</pre> 或 <pre class="crayon-plain-tag">bpf_create_map_in_map_node()</pre>来创建Map。这两个函数实际上是调用<pre class="crayon-plain-tag">bpf</pre>系统调用的<pre class="crayon-plain-tag">BPF_MAP_CREATE</pre>命令。</p>
<p>除非你在编写lwt或tc等类型的BPF程序，你都应该使用上面的方式来定义Map。tc之类的程序使用iproute2作为loader，可以使用下面的结构来定义Map：</p>
<pre class="crayon-plain-tag">#define PIN_GLOBAL_NS           2

struct bpf_elf_map {
        __u32 type;
        __u32 size_key;
        __u32 size_value;
        __u32 max_elem;
        __u32 flags;
        __u32 id;
        __u32 pinning;
};    

struct bpf_elf_map SEC("maps") tun_iface = {
        .type = BPF_MAP_TYPE_ARRAY,
        .size_key = sizeof(int),
        .size_value = sizeof(int),
        .pinning = PIN_GLOBAL_NS,
        .max_elem = 1,

};</pre>
<div class="blog_h3"><span class="graybg">钉住Map</span></div>
<p>所谓Pinning是指通过文件系统路径来暴露Map。对于tc之类的iproute2加载的程序，可以使用这些宏：</p>
<pre class="crayon-plain-tag">#define PIN_NONE        0
#define PIN_OBJECT_NS        1
// 钉到/sys/fs/bpf/tc/globals/ 
#define PIN_GLOBAL_NS        2</pre>
<p>其它程序，你可以手工调用libbpf钉住Map： <pre class="crayon-plain-tag">bpf_obj_pin(fd, path)</pre>。其它程序可以调用<pre class="crayon-plain-tag">mapfd = bpf_obj_get(pinned_file_path);</pre>获得Map的文件描述符。</p>
<div class="blog_h3"><span class="graybg">操控Map</span></div>
<p>检查头文件<pre class="crayon-plain-tag">linux/bpf_types.h</pre>你会发现，不同的Map的操作，是由<pre class="crayon-plain-tag">bpf_map_ops</pre>结构所引用的不同函数指针实现的。</p>
<pre class="crayon-plain-tag">BPF_MAP_TYPE(BPF_MAP_TYPE_ARRAY, array_map_ops)
BPF_MAP_TYPE(BPF_MAP_TYPE_PERCPU_ARRAY, percpu_array_map_ops)</pre>
<p>不过对于BPF开发者来说，所有Map都可以在eBPF或用户空间程序中，通过<pre class="crayon-plain-tag">bpf_map_lookup_elem()</pre>和<pre class="crayon-plain-tag">bpf_map_update_elem()</pre>函数访问。</p>
<div class="blog_h2"><span class="graybg">Map类型</span></div>
<p>所有的Map类型定义在枚举<pre class="crayon-plain-tag">bpf_map_type</pre>中。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_HASH</span></div>
<p>哈希表。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_ARRAY</span></div>
<p>Array Map，为快速查找优化。</p>
<p>键是数组索引值（4字节，64bit），不支持删除键值。所有其它Array Map都具有此特征。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_PROG_ARRAY</span></div>
<p>存放对应eBPF程序的文件描述符的数组。用于实现<pre class="crayon-plain-tag">bpf_tail_call()</pre>需要的跳转表（jump tables）。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_PERCPU_ARRAY</span></div>
<p>per-CPU Array Map，也就是每个CPU对应一个值。<span style="background-color: #c0c0c0;">键仍然是数字，值则是和CPU个数相同的数组</span>：</p>
<pre class="crayon-plain-tag">long values[nr_cpus];
ret = bpf_map_lookup_elem(map_fd, &amp;next_key, values);
if (ret) {
    perror("Error looking up stat");
    continue;
}
for (i = 0; i &lt; nr_cpus; i++) {
    sum += values[i];
} </pre>
<p>此Map可用于代替栈上变量，分配大的缓冲区，以解决栈空间仅512字节的问题。亦可用作全局变量，以解决较旧版本内核中BPF程序没有原生全局变量支持的问题</p>
<p>由于值是per-CPU的，而执行中的BPF程序不会被抢占。因此只要正确编码（仅访问当前CPU的值），就不会产生竞态条件。对于<span style="background-color: #c0c0c0;">会被频繁执行的代码路径，一般会考虑per-CPU的Map</span>。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_PERF_EVENT_ARRAY</span></div>
<p>即perfbuf，per-CPU的缓冲区。在内核空间，供<pre class="crayon-plain-tag">bpf_perf_event_output()</pre>函数使用，调用该函数，可以输出指定类型的结构到缓冲区。</p>
<p>在用户空间，可以进行epoll，当有数据输出时会得到通知。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_RINGBUF</span></div>
<p>即ringbuf，perfbuf的继任，所有CPU共享的一个环形缓冲区。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_CGROUP_ARRAY</span></div>
<p>在用户空间，存放cgroup的文件描述符。</p>
<p>在内核空间，调用<pre class="crayon-plain-tag">bpf_skb_under_cgroup()</pre>来检查skb是否和Map中指定索引的cgroup关联。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_PERCPU_HASH</span></div>
<p>per-CPU的哈希表</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_LRU_HASH</span></div>
<p>使用LRU算法的哈希表</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_LRU_PERCPU_HASH</span></div>
<p>per-CPU的使用LRU算法的哈希表</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_LPM_TRIE</span></div>
<p>最长前缀匹配的字典树（<a href="/trie">trie</a>），可用于IP地址范围匹配。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_STACK_TRACE</span></div>
<p>存储栈追踪信息。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_ARRAY_OF_MAPS</span></div>
<p>Map的数组。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_HASH_OF_MAPS</span></div>
<p>Map的Map。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_DEVICE_MAP</span></div>
<p>存储和查找网络设备引用。</p>
<div class="blog_h3"><span class="graybg">BPF_MAP_TYPE_SOCKMAP</span></div>
<p>存储和查找套接字，允许基于助手函数，实现套接字重定向。</p>
<div class="blog_h2"><span class="graybg">助手函数</span></div>
<p>助手函数由libbpf库提供，定义在<pre class="crayon-plain-tag">bpf_helpers.h</pre>中。</p>
<div class="blog_h3"><span class="graybg">bpf_map_lookup_elem</span></div>
<p>签名： <pre class="crayon-plain-tag">void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)</pre></p>
<p>查找eBPF中和一个Key关联的条目。如果找不到条目，返回NULL</p>
<div class="blog_h3"><span class="graybg">bpf_map_update_elem</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_map_update_elem(struct bpf_map *map, const void *key, const void *value, u64 flags)</pre></p>
<p>添加或修改一个Key关联的值。flags可以是以下之一：</p>
<ol>
<li>BPF_NOEXIST 键值必须不存在，即执行添加操作。不能和BPF_MAP_TYPE_ARRAY、BPF_MAP_TYPE_PERCPU_ARRAY联用</li>
<li>BPF_EXIST，键值必须存在，即执行更新操作</li>
<li>BPF_ANY，更新或修改</li>
</ol>
<div class="blog_h3"><span class="graybg">bpf_map_delete_elem</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_map_delete_elem(struct bpf_map *map, const void *key)</pre></p>
<p>从eBPF Map中删除条目</p>
<div class="blog_h3"><span class="graybg">bpf_probe_read</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_probe_read(void *dst, u32 size, const void *src)</pre></p>
<p>对于Tracing用途的eBPF程序，可以安全的从src读取size字节存储到dst</p>
<div class="blog_h3"><span class="graybg">bpf_ktime_get_ns</span></div>
<p>签名：<pre class="crayon-plain-tag">u64 bpf_ktime_get_ns(void)</pre></p>
<p>读取系统从启动到现在的纳秒数，返回ktime</p>
<div class="blog_h3"><span class="graybg">bpf_trace_printk</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_trace_printk(const char *fmt, u32 fmt_size, ...)</pre></p>
<p>类似于printk()的调试工具，从DebugFS打印由fmt所定义的格式化字符串到<pre class="crayon-plain-tag">/sys/kernel/debug/tracing/trace</pre>。最多支持3个额外的u64参数。</p>
<p>每当此函数被调用，它都会打印一行到trace，格式取决于配置<pre class="crayon-plain-tag">/sys/kernel/debug/tracing/trace_options</pre>。默认格式如下：</p>
<pre class="crayon-plain-tag"># 当前任务的名字
#      当前任务的PID
#            当前CPU序号
#                  每个字符表示一个选项
#                       时间戳
#                                      BPF使用的指令寄存器的Fake值
telnet-470   [001] .N.. 419421.045894: 0x00000001: &lt;formatted msg&gt;</pre>
<p>可以使用的格式化占位符：<pre class="crayon-plain-tag">%d, %i, %u, %x, %ld, %li, %lu, %lx, %lld, %lli, %llu, %llx, %p, %s</pre>。不支持长度、补白等修饰符。</p>
<p>该函数比较缓慢，应该仅用于调试目的。</p>
<div class="blog_h3"><span class="graybg">bpf_get_prandom_u32</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_get_prandom_u32(void)</pre></p>
<p>获得一个伪随机数。</p>
<div class="blog_h3"><span class="graybg">bpf_get_smp_processor_id</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_get_smp_processor_id(void)</pre></p>
<p>得到SMP处理器ID，需要注意，所有eBPF都在禁止抢占的情况下运行，这意味着在eBPF程序的执行过程中，此ID不会改变。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_store_bytes</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_store_bytes(struct sk_buff *skb, u32 offset, const void *from, u32 len, u64 flags)</pre></p>
<p>存储缓冲区from的len字节到，skb所关联的封包的offset位置。flags是以下位域的组合：</p>
<ol>
<li>BPF_F_RECOMPUTE_CSUM：自动重新计算修改后的封包的Checksum</li>
<li>BPF_F_INVALIDATE_HASH：重置<pre class="crayon-plain-tag">skb-&gt;hash</pre> <pre class="crayon-plain-tag">skb-&gt;swhash</pre> <pre class="crayon-plain-tag">skb-&gt;l4hash</pre>为0</li>
</ol>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_load_bytes</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_load_bytes(const struct sk_buff *skb, u32 offset, void *to, u32 len)</pre></p>
<p>从skb中的offset位置读取len长的数据，存放到to缓冲区。</p>
<p>从4.7开始，该函数的功能基本被直接封包访问（direct packet access）代替 —— <pre class="crayon-plain-tag">skb-&gt;data</pre>和<pre class="crayon-plain-tag">skb-&gt;data_end</pre>给出了封包数据的位置。如果希望一次性读取大量数据到eBPF，仍然可以使用该函数。</p>
<div class="blog_h3"><span class="graybg">bpf_l3_csum_replace</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_l3_csum_replace(struct sk_buff *skb, u32 offset, u64 from, u64 to, u64 size)</pre></p>
<p>重新计算L3（IP）的Checksum。计算是增量进行的，因此助手函数必须知道被修改的头字段的前值（from）、修改后的值（to），以及被修改字段的字节数（size，2或4）。你亦可将from和size设置为0，并将字段修改前后的差存放到to。offset用于指示封包的IP Checksum的位置</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_l4_csum_replace</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_l4_csum_replace(struct sk_buff *skb, u32 offset, u64 from, u64 to, u64 flags)</pre></p>
<p>重新计算L4（TCP/UDP/ICMP）的Checksum。计算是增量进行的，因此助手函数必须知道被修改的头字段的前值（from）、修改后的值（to），以及被修改字段的字节数（存放在flags的低4bit，2或4）。你亦可将from和flags低4bit设置为0，并将字段修改前后的差存放到to。offset用于指示封包的IP Checksum的位置。</p>
<p>flags的高位用于存放以下标记：</p>
<ol>
<li>BPF_F_MARK_MANGLED_0，如果Checksum是null，则不去修改它，除非设置了BPF_F_MARK_ENFORCE</li>
<li>CSUM_MANGLED_0，对于导致Checksum为null的更新操作，设置此标记</li>
<li>BPF_F_PSEUDO_HDR，提示使用pseudo-header来计算Checksum</li>
</ol>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_tail_call</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index)</pre></p>
<p>这是一个特殊的助手函数，用于触发尾调用 —— 跳转到另外一个eBPF程序。新程序将使用一样的栈帧，但是被调用者不能访问调用者在栈上存储的值，以及寄存器。</p>
<p>使用场景包括：</p>
<ol>
<li>突破eBPF程序长度限制</li>
<li>在不同条件下进行跳转（到子程序）</li>
</ol>
<p>出于安全原因，可以连续执行的尾调用次数是受限制的。限制定义在内核宏MAX_TAIL_CALL_CNT中，默认32，无法被用户空间访问</p>
<p>当调用发生后，程序尝试跳转到prog_array_map（BPF_MAP_TYPE_PROG_ARRAY类型的Map）的index索引处的eBPF程序，并且将当前ctx传递给它。</p>
<p>如果调用成功，则当前程序被替换掉，不存在函数调用返回。如果调用失败，则不产生任何作用，当前程序继续运行后续指令。失败的原因包括：</p>
<ol>
<li>指定的index不存在eBPF程序</li>
<li>当前尾调用链的长度超过限制</li>
</ol>
<div class="blog_h3"><span class="graybg">bpf_clone_redirect</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_clone_redirect(struct sk_buff *skb, u32 ifindex, u64 flags)</pre></p>
<p>克隆skb关联的封包，并且重定向到由ifindx所指向的网络设备。入站/出站路径都可以用于重定向。标记BPF_F_INGRESS用于确定是重定向到入站（ingress）还是出站（egress）路径，如果该标记存在则入站。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_redirect</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_redirect(u32 ifindex, u64 flags)</pre></p>
<p>重定向封包到ifindex所指向的网络设备。类似于bpf_clone_redirect，但是不会进行封包克隆，因而性能较好。缺点是，redirect操作实际上是在eBPF程序返回后的某个代码路径上发生的。</p>
<p>除了XDP之外，入站/出站路径都可以用于重定向。标记BPF_F_INGRESS用于指定是ingress还是egress。当前XDP仅仅支持重定向到egress接口，不支持设置flag</p>
<p>对于XDP，成功返回XDP_REDIRECT，出错返回XDP_ABORTED。对于其它eBPF程序，成功返回TC_ACT_REDIRECT，出错返回TC_ACT_SHOT</p>
<div class="blog_h3"><span class="graybg">bpf_redirect_map</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_redirect_map(struct bpf_map *map, u32 key, u64 flags)</pre></p>
<p>将封包重定向到map的key键指向的endpoint。根据map的类型，它的值可能指向：</p>
<ol>
<li>网络设备，用于转发封包到其它ports</li>
<li>CPU，用于重定向XDP帧给其它CPU，仅仅支持Native（驱动层支持的） XDP</li>
</ol>
<p>flags必须置零。</p>
<p>当重定向给网络设备时，该函数比bpf_redirect性能更好。这是由一系列底层实现细节之一决定的，其中之一是该函数会以bulk方式将封包发送给设备。</p>
<p>如果成功返回XDP_REDIRECT，否则返回XDP_ABORTED。</p>
<div class="blog_h3"><span class="graybg">bpf_sk_redirect_map</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_sk_redirect_map(struct bpf_map *map, u32 key, u64 flags)</pre></p>
<p>将封包重定向给map（类型BPF_MAP_TYPE_SOCKMAP）的key所指向的套接字。ingress/egress接口都可以用于重定向。标记BPF_F_INGRESS用于确定是不是ingress。</p>
<p>如果成功返回SK_PASS，否则返回SK_DROP。</p>
<div class="blog_h3"><span class="graybg">bpf_sock_map_update</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_sock_map_update(struct bpf_sock_ops *skops, struct bpf_map *map, void *key, u64 flags)</pre></p>
<p>添加/更新map的条目，skopts作为key的新值。flags是以下其中之一：</p>
<ol>
<li>BPF_NOEXIST，仅添加</li>
<li>BPF_EXIST，仅更新</li>
<li>BPF_ANY，添加或更新</li>
</ol>
<div class="blog_h3"><span class="graybg">bpf_skb_vlan_push</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_vlan_push(struct sk_buff *skb, __be16 vlan_proto, u16 vlan_tci)</pre> </p>
<p>将vlan_proto协议的vlan_tci（VLAN Tag控制信息）Push给skb关联的封包，并且更新Checksum。需要注意ETH_P_8021Q和ETH_P_8021AD的vlan_proto是不一样的，这里使用前者。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_vlan_pop</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_vlan_pop(struct sk_buff *skb)</pre></p>
<p>弹出skb关联的封包的VLAN头。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_get_tunnel_key</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_get_tunnel_key(struct sk_buff *skb, struct bpf_tunnel_key *key, u32 size, u64 flags)</pre></p>
<p>获取隧道（外层报文）的元数据，skb关联的封包的隧道元数据被填充到key，长度size。标记BPF_F_TUNINFO_IPV6提示隧道是基于IPv6而非IPv4。</p>
<p><pre class="crayon-plain-tag">bpf_tunnel_key</pre>是一个容器结构，它将各种隧道协议的主要参数都存入其中，这样eBPF程序可以方便的根据封装（外层）报文的头来作出各种决定。</p>
<p>对端的IP地址被存放在<pre class="crayon-plain-tag">key-&gt;remote_ipv4</pre> 或 <pre class="crayon-plain-tag">key-&gt;remote_ipv6</pre></p>
<p>通过<pre class="crayon-plain-tag">key-&gt;tunnel_id</pre>可以访问隧道的ID，通常映射到VNI（虚拟网络标识符），调用<pre class="crayon-plain-tag">bpf_skb_set_tunnel_key()</pre>函数需要用到</p>
<p>下面这个示例用在隧道一端的TC Ingress接口，可以过滤掉对端隧道IP不是10.0.0.1的封包：</p>
<pre class="crayon-plain-tag">int ret;
struct bpf_tunnel_key key = {};

ret = bpf_skb_get_tunnel_key(skb, &amp;key, sizeof(key), 0);
if (ret &lt; 0)
        return TC_ACT_SHOT;     // drop packet

if (key.remote_ipv4 != 0x0a000001)
        return TC_ACT_SHOT;     // drop packet

return TC_ACT_OK;               // accept packet</pre>
<p>支持VxLAN、Geneve、GRE、IPIP等类型的隧道。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_set_tunnel_key</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_set_tunnel_key(struct sk_buff *skb, struct bpf_tunnel_key *key, u32 size, u64 flags)</pre></p>
<p>为skb关联的封包生成隧道元数据。隧道元数据被设置为长度为size的bpf_tunnel_key结构。flags是如下位域的组合：</p>
<ol>
<li>BPF_F_TUNINFO_IPV6 指示隧道基于IPv6而非IPv4</li>
<li>BPF_F_ZERO_CSUM_TX 对于IPv4封包，添加一个标记到隧道元数据，提示应该跳过Checksum计算，将其置零</li>
<li>BPF_F_DONT_FRAGMENT，添加一个标记到隧道元数据，提示封包不得被分片（fragmented）</li>
<li>BPF_F_SEQ_NUMBER，添加一个标记到隧道元数据，提示发送封包之前，需要添加sequence number</li>
</ol>
<p>示例：</p>
<pre class="crayon-plain-tag">struct bpf_tunnel_key key;
// populate key ...
bpf_skb_set_tunnel_key(skb, &amp;key, sizeof(key), 0);
bpf_clone_redirect(skb, vxlan_dev_ifindex, 0);</pre>
<div class="blog_h3"><span class="graybg">bpf_skb_get_tunnel_opt</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_get_tunnel_opt(struct sk_buff *skb, u8 *opt, u32 size)</pre></p>
<p>从skb关联的封包中获取隧道选项元数据，并且将原始的隧道选项信息存储到大小为size的opt中。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_set_tunnel_opt</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_set_tunnel_opt(struct sk_buff *skb, u8 *opt, u32 size)</pre></p>
<p>将隧道选项元数据设置给skb关联的封包。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_change_proto</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_change_proto(struct sk_buff *skb, __be16 proto, u64 flags)</pre></p>
<p>将skb的协议改为proto。目前仅仅支持将IPv4改为IPv6。助手函数会做好底层工作，例如修改套接字缓冲的大小。eBPF程序需要调用<pre class="crayon-plain-tag">skb_store_bytes</pre>填充必要的新的报文头字段，并调用<pre class="crayon-plain-tag">bpf_l3_csum_replace</pre>、<pre class="crayon-plain-tag">bpf_l4_csum_replace</pre>重新计算Checksum。</p>
<p>该助手函数的主要意义是执行一个NAT64操作。</p>
<p>在内部实现上，封包的GSO（generic segmentation offload）类型标记为dodgy，因而报文头被检查，TCP分段被GSO/GRO引擎重新分段。</p>
<p>flags必须清零，这个参数暂时没有使用。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_csum_diff</span></div>
<p>签名：<pre class="crayon-plain-tag">s64 bpf_csum_diff(__be32 *from, u32 from_size, __be32 *to, u32 to_size, __wsum seed)</pre></p>
<p>计算两个缓冲区from到to的checksum difference。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_change_type</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_change_type(struct sk_buff *skb, u32 type)</pre></p>
<p>修改封包类型，即设置<pre class="crayon-plain-tag">skb-&gt;pkt_type</pre>为type。主要用途是将skb改为PACKET_HOST。type的取值：</p>
<ol style="list-style-type: undefined;">
<li>PACKET_HOST 单播给本机的封包</li>
<li>PACKET_BROADCAST 广播封包</li>
<li>PACKET_MULTICAST 组播封包</li>
<li>PACKET_OTHERHOST单播给其它机器的封包</li>
</ol>
<div class="blog_h3"><span class="graybg">bpf_skb_change_head</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_change_head(struct sk_buff *skb, u32 len, u64 flags)</pre></p>
<p>增长封包的headroom，增长len长度，调整MAC头的偏移量。如果需要，该函数会自动扩展和重新分配内存。</p>
<p>该函数可以用于在L3的skb上，推入一个MAC头，然后将其重定向到L2设备。</p>
<p>flags为保留字段，全部置空。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_under_cgroup</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_under_cgroup(struct sk_buff *skb, struct bpf_map *map, u32 index)</pre></p>
<p>检查skb是否是由BPF_MAP_TYPE_CGROUP_ARRAY类型的Map的index位置所指向的CGroup2的descendant。</p>
<p>返回值：</p>
<p style="padding-left: 30px;">0 ：不是目标Cgroup2的descendant<br />1：是目标Cgroup2的descendant<br />负数：出错</p>
<div class="blog_h3"><span class="graybg">bpf_set_hash_invalid</span></div>
<p>签名：<pre class="crayon-plain-tag">void bpf_set_hash_invalid(struct sk_buff *skb)</pre></p>
<p>无效化<pre class="crayon-plain-tag">skb-&gt;hash</pre>。在通过直接封包访问修改报文头之后调用此函数，以提示哈希值以及过期，内核下一次访问哈希或者调用bpf_get_hash_recalc时会触发哈希值的重新计算。</p>
<div class="blog_h3"><span class="graybg">bpf_get_hash_recalc</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_get_hash_recalc(struct sk_buff *skb)</pre></p>
<p>获取封包哈希值<pre class="crayon-plain-tag">skb-&gt;hash</pre>，如果该字段没有设置（特别是因为封包修改导致哈希被清空）则计算并设置哈希。后续可以直接访问skb-&gt;哈希获取哈希值。</p>
<p>调用bpf_set_hash_invalid()、bpf_skb_change_proto()、bpf_skb_store_bytes()+BPF_F_INVALIDATE_HASH标记，都会导致哈希值清空，并导致下一次bpf_get_hash_recalc()调用重新生成哈希值。</p>
<div class="blog_h3"><span class="graybg">bpf_set_hash</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_set_hash(struct sk_buff *skb, u32 hash)</pre></p>
<p>设置完整哈希值到skb-&gt;hash</p>
<div class="blog_h3"><span class="graybg">bpf_skb_change_tail</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_change_tail(struct sk_buff *skb, u32 len, u64 flags)</pre></p>
<p>Resize(trim/grow) skb关联的封包到len长。flags必须置零。</p>
<p>改变封包长度后，eBPF程序可能需要调用bpf_skb_store_bytes、bpf_l3_csum_replace、bpf_l3_csum_replace等函数填充数据、重新计算Checksum。</p>
<p>一般用于回复ICMP控制报文。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_skb_pull_data</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_pull_data(struct sk_buff *skb, u32 len)</pre></p>
<p>所谓non-linear的skb，是指被fragmented的skb，即有一部分数据没有存放在skb所在内存，而是存放在其它内存页（可能有多个），并通过skb_shared_info记录这些数据位置。</p>
<p>当skb是non-linear的、并且不是所有len长是linear section的一部分的前提下，拉取skb的non-linear数据。确保skb的len字节是可读写的。如果len设置为0，则拉取拉取skb的整个长度的数据。</p>
<p>进行封包直接访问时，通过<pre class="crayon-plain-tag">skb-&gt;data_end</pre>来测试某个偏移量是否在封包范围内，可能因为两个原因失败：</p>
<ol>
<li>偏移量是无效的</li>
<li>偏移量对应的数据是在skb的non-linear部分中</li>
</ol>
<p>该助手函数可以用来一次性拉取non-linear数据，然后再进行偏移量测试和数据访问。</p>
<p>此函数确保skb是uncloned，这是直接封包访问的前提。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_get_socket_cookie</span></div>
<p>签名：<pre class="crayon-plain-tag">u64 bpf_get_socket_cookie(struct sk_buff *skb)</pre></p>
<p>如果skb关联到一个已知的套接字，则得到套接字的cookie（由内核生成），如果尚未设置cookie，则生成之。一旦cookie生成，在套接字的生命周期范围内都不会改变。</p>
<p>该助手用于监控套接字网络流量统计信息，它在网络命名空间范围内为套接字提供唯一标识。</p>
<div class="blog_h3"><span class="graybg">bpf_get_socket_uid</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_get_socket_uid(struct sk_buff *skb)</pre></p>
<p>获得套接字的owner UID。如果套接字是NULL，或者不是full socket（time-wait状态，或者是一个request socket），则返回overflowuid，overflowuid不一定是socket的实际UID。</p>
<div class="blog_h3"><span class="graybg">bpf_csum_update</span></div>
<p>签名：<pre class="crayon-plain-tag">s64 bpf_csum_update(struct sk_buff *skb, __wsum csum)</pre></p>
<p>如果驱动已经为整个封包提供了Checksum，那么此函数将csum加到<pre class="crayon-plain-tag">skb-&gt;csum</pre>字段上，其它情况下返回错误。</p>
<p>该助手函数应当和<pre class="crayon-plain-tag">bpf_csum_diff()</pre>联合使用，典型场景是，通过封包直接访问修改了封包内容之后，进行Checksum更新。</p>
<div class="blog_h3"><span class="graybg">bpf_get_route_realm</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_get_route_realm(struct sk_buff *skb)</pre></p>
<p>得到路由的Realm，也就是skb的destination的tclassid字段。这个字段是用户提供的一个tag，类似于net_cls的classid。不同的是，这里的tag关联到路由条目（destination entry）。</p>
<p>可以在clsact TC egress钩子中调用此函数，或者在经典的classful egress qdiscs上使用。不能在TC ingress路径上使用。</p>
<p>要求内核配置选项CONFIG_IP_ROUTE_CLASSID。</p>
<p>返回skb关联的封包的路由的realm。</p>
<div class="blog_h3"><span class="graybg">bpf_setsockopt</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_setsockopt(struct bpf_sock_ops *bpf_socket, int level, int optname, char *optval, int optlen)</pre></p>
<p>针对bpf_socket关联的套接字发起一个setsockopt()操作，此套接字必须是full socket。optname为选项名，optval/optlen指定了选项值，level指定了选项的位置。</p>
<p>该函数实际上实现了setsockopt()的子集，支持以下level：</p>
<ol>
<li>SOL_SOCKET，支持选项SO_RCVBUF, SO_SNDBUF, SO_MAX_PACING_RATE, SO_PRIORITY, SO_RCVLOWAT, SO_MARK</li>
<li>IPPROTO_TCP，支持选项TCP_CONGESTION, TCP_BPF_IW, TCP_BPF_SNDCWND_CLAMP</li>
<li>IPPROTO_IP，支持选项IP_TOS</li>
<li>IPPROTO_IPV6，支持选项IPV6_TCLASS</li>
</ol>
<div class="blog_h3"><span class="graybg">bpf_skb_adjust_room</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_skb_adjust_room(struct sk_buff *skb, u32 len_diff, u32 mode, u64 flags)</pre></p>
<p>增加/缩小skb关联的封包的数据的room，增量为len_diff。mode可以是：</p>
<ol>
<li>BPF_ADJ_ROOM_NET，在网络层调整room，即在L3头上增加/移除room space</li>
</ol>
<p>flags必须置零。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_xdp_adjust_head</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_xdp_adjust_head(struct xdp_buff *xdp_md, int delta)</pre></p>
<p>移动<pre class="crayon-plain-tag">xdp_md-&gt;data</pre> delta字节，delta可以是负数。</p>
<p>该函数准备用于push/pop headers的封包。</p>
<p>调用此助手函数会导致封包缓冲区改变，因此在加载期间校验器对指针的校验将失效，必须重新校验。</p>
<div class="blog_h3"><span class="graybg">bpf_xdp_adjust_meta</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_xdp_adjust_meta(struct xdp_buff *xdp_md, int delta)</pre></p>
<p>调整 xdp_md-&gt;data_meta所指向的地址delta字节。该操作改变了存储在xdp_md-&gt;data中的地址信息。</p>
<div class="blog_h3"><span class="graybg">bpf_get_current_task</span></div>
<p>签名：<pre class="crayon-plain-tag">u64 bpf_get_current_task(void)</pre></p>
<p>获取当前Task结构的指针。</p>
<div class="blog_h3"><span class="graybg">bpf_get_stackid</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_get_stackid(struct pt_reg *ctx, struct bpf_map *map, u64 flags)</pre></p>
<p>获取一个用户/内核栈，得到其ID。需要传入ctx，即当前追踪程序在其中执行的上下文对象，以及一个BPF_MAP_TYPE_STACK_TRACE类型的Map。通过flags指示需要跳过多少栈帧（0-255），masked with BPF_F_SKIP_FIELD_MASK。flags的其它位如下：</p>
<ol>
<li>BPF_F_USER_STACK 收集用户空间的栈，而非内核栈</li>
<li>BPF_F_FAST_STACK_CMP 基于哈希来对比栈</li>
<li>BPF_F_REUSE_STACKID 如果两个不同的栈哈希到同一个stackid，丢弃旧的</li>
</ol>
<div class="blog_h3"><span class="graybg">bpf_get_current_pid_tgid</span></div>
<p>签名：<pre class="crayon-plain-tag">u64 bpf_get_current_pid_tgid(void)</pre></p>
<p>返回一个包含了当前tgid和pid的64bit整数。值为<pre class="crayon-plain-tag">current_task-&gt;tgid &lt;&lt; 32 | current_task-&gt;pid</pre></p>
<div class="blog_h3"><span class="graybg">bpf_get_current_uid_gid</span></div>
<p>签名：<pre class="crayon-plain-tag">u64 bpf_get_current_uid_gid(void)</pre></p>
<p>返回一个包含了当前GID和UID的整数。值为<pre class="crayon-plain-tag">current_gid &lt;&lt; 32 | current_uid</pre></p>
<div class="blog_h3"><span class="graybg">bpf_get_current_comm</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_get_current_comm(char *buf, u32 size_of_buf)</pre></p>
<p>将当前任务的comm属性拷贝到长度为size_of_buf的buf中。comm属性包含可执行文件的路径</p>
<p>调用成功时助手函数确保buf是NULL-terminated。如果失败，则填满0</p>
<div class="blog_h3"><span class="graybg">bpf_get_cgroup_classid</span></div>
<p>签名：<pre class="crayon-plain-tag">u32 bpf_get_cgroup_classid(struct sk_buff *skb)</pre></p>
<p>得到当前任务的classid，即skb所属的<a href="/cgroup-illustrated#net-cls">net_cls控制组</a>的classid。该助手函数可用于TC的egress路径，不能用于ingress路径。</p>
<p>Linux支持两个版本的Cgroups，v1和v2，用户可以混合使用。但是，net_cls是v1特有的Cgroup。这意味着此助手函数和run on cgroups（v2 only）的eBPF程序不兼容，套接字一次仅仅能携带一个版本Cgroup的数据。</p>
<p>内核必须配置CONFIG_CGROUP_NET_CLASSID=y/m才能使用此助手函数。</p>
<p>返回classid，或者0，即默认的没有被配置的classid。</p>
<div class="blog_h3"><span class="graybg">bpf_probe_write_user</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_probe_write_user(void *dst, const void *src, u32 len)</pre></p>
<p>尝试在以一个安全方式来写入src的len字节到dst中。仅仅对于运行在用户上下文的线程可用，dst必须是有效的用户空间地址。</p>
<p>由于TOC-TOU攻击的原因，此助手函数不得用于实现任何类型的安全机制。</p>
<p>此函数用于试验目的，存在的导致系统、进程崩溃的风险。当调用了此函数的eBPF程序被挂钩后，内核日志会打印一条警告信息，包含PID和进程名信息。</p>
<div class="blog_h3"><span class="graybg">bpf_probe_read_str</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_probe_read_str(void *dst, int size, const void *unsafe_ptr)</pre></p>
<p>从unsafe_ptr拷贝一个NULL结尾的字符串到dst，size包含结尾的NULL字符。如果字符串长度小于size，不会补NUL；如果字符串长度大于size，则截断（保证填充字符串结尾NULL）</p>
<div class="blog_h3"><span class="graybg">bpf_current_task_under_cgroup</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_current_task_under_cgroup(struct bpf_map *map, u32 index)</pre></p>
<p>检查当前正在运行的探针是否在map的index所指向的Cgroup2之下。</p>
<div class="blog_h3"><span class="graybg">bpf_get_numa_node_id</span></div>
<p>签名： <pre class="crayon-plain-tag">int bpf_get_numa_node_id(void)</pre></p>
<p>得到当前NUMA节点的ID。该函数的主要目的是用于选取本地NUMA节点的套接字。</p>
<div class="blog_h3"><span class="graybg">bpf_perf_event_output</span></div>
<p>签名：<pre class="crayon-plain-tag">int bpf_perf_event_output(struct pt_reg *ctx, struct bpf_map *map, u64 flags, void *data, u64 size)</pre></p>
<p>将长度为size的blob写入到Map所存放的特殊BPF perf event。map的类型是BPF_MAP_TYPE_PERF_EVENT_ARRAY</p>
<p>perf event必须具有属性：</p>
<p style="padding-left: 30px;">sample_type = PERF_SAMPLE_RAW<br />type = PERF_TYPE_SOFTWARE<br />config = PERF_COUNT_SW_BPF_OUTPUT</p>
<p>flags用于指定写入到数组的索引。masked by BPF_F_INDEX_MASK，如果指定BPF_F_CURRENT_CPU则取当前CPU的值。</p>
<p>当前程序的ctx也需要传递给助手函数。</p>
<p>在用户空间，希望读取值的程序需要针对perf event调用perf_event_open() ，然后将文件描述符存储到Map中。这个操作必须在eBPF程序第一次写入数据到Map之前完成。参考内核中的例子samples/bpf/trace_output_user.c </p>
<p>要和用户空间进行数据交互，该函数优于bpf_trace_printk()，性能更好。适合从eBPF程序stream数据给用户空间读取。</p>
<div class="blog_h3"><span class="graybg">bpf_perf_event_read</span></div>
<p>签名： <pre class="crayon-plain-tag">u64 bpf_perf_event_read(struct bpf_map *map, u64 flags)</pre></p>
<p>读取一个perf event counter的值，该助手函数操作BPF_MAP_TYPE_PERF_EVENT_ARRAY类型的Map。这个Map本质上是一个数组，它的size和CPU数量一致，其值和对应CPU相关。取哪个CPU的值，masked by BPF_F_INDEX_MASK，如果指定BPF_F_CURRENT_CPU则取当前CPU的值。</p>
<p>在4.13之前，仅仅支持hardware perf event。</p>
<p>成功时返回计数器值，否则返回负数。</p>
<p>考虑使用<pre class="crayon-plain-tag">bpf_perf_event_read_value</pre>代替此函数。</p>
<div class="blog_h2"><span class="graybg">BPF Skeleton</span></div>
<p>一个BPF Application由1-N个BPF program、BPF Maps、全局变量组成。所有BPF program（各自对应一个ELF section）、用户空间（的控制）程序可以共享Map/全局变量。</p>
<div class="blog_h3"><span class="graybg">管理生命周期</span></div>
<p>BPF Application通常会经历以下生命周期阶段：</p>
<ol>
<li>Open：控制程序打开BPF object文件，解析了programs/map/global vars，但是尚未在内核中创建这些对象。打开BPF object文件后，控制程序可能进行一些调整，例如设置程序类型、设置全局变量的值，等等</li>
<li>Load：在此阶段，BPF Maps被创建，各种relocations被解析，BPF programs被载入内核并校验。这个阶段结束时，BPF程序的所有组件都被校验并且存在于内核，但是BPF program还不会被内核执行，可以保证在没有竞态条件的前提下，对BPF Map进行初始化</li>
<li>Attach：BPF programs被挂钩到相应的BPF挂钩点（例如tracepoint、kprobes、cgroup hooks、网络封包处理流水线…），BPF program开始工作，读写BPF Maps和全局变量</li>
<li>Teardown：BPF程序被detach并unload，BPF Map被销毁</li>
</ol>
<p>通过bpftool生成的BPF Skeleton，包含了触发上述阶段的函数：</p>
<ol>
<li><pre class="crayon-plain-tag">&lt;name&gt;__open()</pre>：创建并打开BPF Application</li>
<li><pre class="crayon-plain-tag">&lt;name&gt;__load()</pre>：实例化、加载、校验BPF Application组件</li>
<li><pre class="crayon-plain-tag">&lt;name&gt;__attach()</pre>：将BPF programs挂钩到内核的hook point。你也可以选择直接使用libbpf API进行细粒度控制</li>
<li><pre class="crayon-plain-tag">&lt;name&gt;__destroy()</pre>：销毁BPF programs并释放所有资源</li>
</ol>
<div class="blog_h3"><span class="graybg">访问全局变量</span></div>
<p>在内核空间，访问全局变量使用普通的C语法，你甚至可以取地址并将其传递给助手函数。</p>
<p>在控制程序中，你需要通过BPF skeleton来访问这些变量：</p>
<ol>
<li><pre class="crayon-plain-tag">skel-&gt;rodata</pre>，访问只读变量（常量）</li>
<li><pre class="crayon-plain-tag">skel-&gt;bss</pre>，访问可变的、以0初始化的变量</li>
<li><pre class="crayon-plain-tag">skel-&gt;data</pre>，访问可变的、非0初始化的变量</li>
</ol>
<p>在用户空间对这些变量的修改，会立刻反映到内核空间。</p>
<div class="blog_h2"><span class="graybg">BPF Ring Buffer</span></div>
<p>BPF ring buffer是一个新的BPF数据结构，它解决了BPF perf buffer（现有的给用户空间发送数据的事实标准工具）的内存效率、事件re-ordering问题。</p>
<p>ringbuf提供了兼容perfbuf的API，便于迁移。同时也提供了新的reserve/commit API，具有更好的易用性。</p>
<div class="blog_h3"><span class="graybg">对比perfbuf</span></div>
<p>当BPF程序将收集的信息发送到用户空间，供后续处理时，通常会利用perfbuf。perfbuf是per-CPU的环形缓冲区，允许内核和用户空间进行高效的数据交换。但是由于它的per-CPU设计，会导致两个缺点：内存低效、事件乱序。</p>
<p>因为这些缺点，内核5.8+开始，BPF提供了新的数据结构，BPF ring buffer。它是一个多生产者、单消费者（MPSC）的队列，可以安全的被多CPU环境共享。</p>
<p>ringbuf支持perfbuf的特性：</p>
<ol>
<li>可变长度数据记录</li>
<li>基于内存映射区域，高效的从用户空间读取数据的能力，不需要内存拷贝或者执行系统调用</li>
<li>支持epoll通知，或者忙循环（最小化延迟）</li>
</ol>
<div class="blog_h3"><span class="graybg">内存低效问题</span></div>
<p>perfbuf为每个CPU都分配了独立的缓冲区，开发者可能需要进行权衡：</p>
<ol>
<li>如果分配足够大的缓冲，则会浪费内存，特别是核心数很多的情况下</li>
<li>如果分配较小的缓冲，那么出现事件spike时，会丢失数据</li>
</ol>
<p>而ringbuf使用单个缓冲区，因而能够容易应对spike，同时不需要太大的内存消耗。</p>
<div class="blog_h3"><span class="graybg">事件乱序问题</span></div>
<p>在追踪相关事件（例如进程启动/退出、网络连接生命周期事件）的时候，事件顺序非常重要。</p>
<p>使用perfbuf时，如果两个相关事件在很短事件内（若干ms）被不同CPU处理，则可能发出到用户空间的顺序是错乱的。</p>
<div class="blog_h3"><span class="graybg">关于性能</span></div>
<p>在所有应用场景下，BPF ringbuf都有着和perfbuf可比较的性能。</p>
<p>唯一需要考虑使用perfbuf的场景是在NMI (non-maskable interrupt)上下文下运行的BPF程序，例如处理cpu-cycles之类的perf事件。由于ringbuf内部使用一个轻量的自旋锁，在NMI上下文下可能发生锁争用并导致reserve失败。</p>
<div class="blog_h3"><span class="graybg">用法对比</span></div>
<p>为了简化代码的迁移，ringbuf提供了一套类似于perfbuf的API，本节做一个对比。</p>
<p>下面的数据结构代表BPF程序需要收集的一个事件：</p>
<pre class="crayon-plain-tag">#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 512

struct event {
	int pid;
	char comm[TASK_COMM_LEN];
	char filename[MAX_FILENAME_LEN];
};</pre>
<p>perfbuf和ringbuf对应不同的Map类型：</p>
<pre class="crayon-plain-tag">/*** perfbuf ***/
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(int));
    __uint(value_size, sizeof(int));
} pb SEC(".maps");

/*** ringbuf ***/
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    // 和perfbuf不同，ringbuf的尺寸可以在内核端定义
    // 但是，在用户空间也可以通过bpf_map__set_max_entries()指定或覆盖ringbuf的尺寸
    // 单位是字节，必须是内核页（一般都是4KB）大小的整数倍，并且是2的幂
    __uint(max_entries, 256 * 1024 /* 256 KB */);
} rb SEC(".maps");</pre>
<p>由于event超过512字节，因此不能直接在栈上分配，需要存储到BPF_MAP_TYPE_PERCPU_ARRAY：</p>
<pre class="crayon-plain-tag">struct {
	__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
	__uint(max_entries, 1);  // 只需要1个元素即可
	__type(key, int);
	__type(value, struct event);
} heap SEC(".maps");</pre>
<p>内核空间代码：</p>
<pre class="crayon-plain-tag">// 挂钩到sched:sched_process_exec，每当成功的exec()系统调用后触发
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
	unsigned fname_off = ctx-&gt;__data_loc_filename &amp; 0xFFFF;
	struct event *e;
	int zero = 0;
	// 获得事件的指针
	e = bpf_map_lookup_elem(&amp;heap, &amp;zero);
	if (!e) /* can't happen */
		return 0;

	// 填充字段
	e-&gt;pid = bpf_get_current_pid_tgid() &gt;&gt; 32;
	bpf_get_current_comm(&amp;e-&gt;comm, sizeof(e-&gt;comm));
	bpf_probe_read_str(&amp;e-&gt;filename, sizeof(e-&gt;filename), (void *)ctx + fname_off);

	/*** 填充数据的API不同 ***/
	/*** perfbuf ***/
	// 输出追踪样本，要求在perfbuf中保留e大小的缓冲区，然后通知用户空间有数据可用
	bpf_perf_event_output(ctx, &amp;pb, BPF_F_CURRENT_CPU, e, sizeof(*e));
        /*** ringbuf ***/
	// 不需要传递ctx对象
	bpf_ringbuf_output(&amp;rb, e, sizeof(*e), 0);

	return 0;
}</pre>
<p>用户空间代码：</p>
<pre class="crayon-plain-tag">struct perf_buffer *pb = NULL;
struct perf_buffer_opts pb_opts = {};
struct perfbuf_output_bpf *skel;

/*** 回调函数的签名不同 ***/
/*** perfbuf ***/
void handle_event(void *ctx, int cpu, void *data, unsigned int data_sz)
{
    const struct event *e = data;
    struct tm *tm;
    char ts[32];
    time_t t;
    time(&amp;t);
    tm = localtime(&amp;t);
    strftime(ts, sizeof(ts), "%H:%M:%S", tm);

    printf("%-8s %-5s %-7d %-16s %s\n", ts, "EXEC", e-&gt;pid, e-&gt;comm, e-&gt;filename);
}
/*** ringbuf ***/
int handle_event(void *ctx, void *data, size_t data_sz)
{
    // ...
}

/*** 创建用户空间缓冲区的API不同 ***/
/*** perfbuf ***/
pb_opts.sample_cb = handle_event; // 回调函数
//                    指向内核空间对应物，也就是那个Map
//                                                为每个CPU分配8个页，也就是32KB的缓冲区
pb = perf_buffer__new(bpf_map__fd(skel-&gt;maps.pb), 8 /* 32KB per CPU */, &amp;pb_opts);
if (libbpf_get_error(pb)) {
    err = -1;
    fprintf(stderr, "Failed to create perf buffer\n");
    goto cleanup;
}
/*** ringbuf ***/
rb = ring_buffer__new(bpf_map__fd(skel-&gt;maps.rb), handle_event, NULL, NULL);
if (!rb) {
    err = -1;
    fprintf(stderr, "Failed to create ring buffer\n");
    goto cleanup;
}


// 开始epoll轮询
while (!exiting) {
    /*** 轮询接口不同 ***/
    /*** perfbuf ***/
    err = perf_buffer__poll(pb, 100 /* timeout, ms */);
    /*** ringbuf ***/
    err = ring_buffer__poll(rb, 100 /* timeout, ms */);
    
    // ...
}</pre>
<div class="blog_h3"><span class="graybg"> reserve/commit</span></div>
<p>上面的perfbuf兼容API，具有与perfbuf类似的缺点：</p>
<ol>
<li>额外的内存拷贝：你需要额外的内存空间来构建追踪样本对象，然后才能将它拷贝到缓冲区中</li>
<li>very late data reservation：构建（可能需要采集多种内存数据）追踪样本对象的工作可能是无意义的，如果缓冲区中剩余空间（由于用户空间程序处理缓慢或者事件burst）不足，构建的对象无处存放。使用xxx_output()接口不能提前感知缓冲区剩余空间，从而避免不必要的样本对象构造</li>
</ol>
<p>ringbuf提供了一套新的API，应对上述缺点。</p>
<ol>
<li><pre class="crayon-plain-tag">bpf_ringbuf_reserve()</pre>：尝试在缓冲区中预定空间，这个操作可以在构建样本对象之前就进行，尽早获取空间不足的状况。如果预定成功，则返回指针，并且可以保证后续可以将数据commit到其中；如果预定失败，则返回NULL，我们可以跳过后续的、无意义的操作</li>
<li><pre class="crayon-plain-tag">bpf_ringbuf_commit()</pre>：将样本对象发送到缓冲区</li>
</ol>
<p>此外，reserve的空间，在commit之前，对于用户空间是不可见的。因此你可以直接用它来构造样本对象，不用担心半初始化的对象被看到，同时达到节约内存的目的。</p>
<p>唯一的限制是：reserve的空间大小，必须能够被BPF verifier感知（常量）。如果样本尺寸是动态的，则只能使用<pre class="crayon-plain-tag">bpf_ringbuf_output()</pre>并且承受内存拷贝的代价。</p>
<p>用法示例：</p>
<pre class="crayon-plain-tag">// 不再需要到per-CPU array中预先构造对象：
//   e = bpf_map_lookup_elem(&amp;heap, &amp;zero);
e = bpf_ringbuf_reserve(&amp;rb, sizeof(*e), 0);

// 不再需要将per-CPU array中的对象拷贝到缓冲区
//   bpf_ringbuf_output(&amp;rb, e, sizeof(*e), 0);
bpf_ringbuf_submit(e, 0);</pre>
<div class="blog_h3"><span class="graybg">通知用户空间</span></div>
<p>不管是perfbuf还是ringbuf，如果内核每写入一个样本，就通知（也就是唤醒在poll/epoll上等待的）用户空间程序，其开销是相当大的。</p>
<p>perfbuf的解决办法是，允许用户空间程序构造perfbuf时，指定每发生N个样本才唤醒一次，这可能会让你丢失最多N-1个样本。</p>
<p>ringbuf则允许为<pre class="crayon-plain-tag">bpf_ringbuf_output()</pre>或<pre class="crayon-plain-tag">bpf_ringbuf_commit()</pre>指定额外的标记：</p>
<ol>
<li>BPF_RB_FORCE_WAKEUP 本次写入样本，强制唤醒用户空间程序</li>
<li>BPF_RB_NO_WAKEUP本次写入样本，不会唤醒用户空间程序</li>
</ol>
<p>如果不指定上述标记，ringbuf会根据用户空间程序lagging的情况决定是否唤醒。不指定标记通常是安全的默认值。</p>
<div class="blog_h2"><span class="graybg">调试BPF程序</span></div>
<p>BPF没有常规的调试工具，支持设置断点、探查变量、单步跟踪的那种。</p>
<div class="blog_h3"><span class="graybg">bpf_printk</span></div>
<p>使用<pre class="crayon-plain-tag">bpf_printk(fmt, args...)</pre>可以打印一些信息到<pre class="crayon-plain-tag">/sys/kernel/debug/tracing/trace_pipe</pre>，来帮助你理解发生了什么，这个函数最多支持3个args。</p>
<p>这个函数的成本很高，不能在生产环境使用。</p>
<div class="blog_h3"><span class="graybg">bpf_trace_printk</span></div>
<p>助手函数<pre class="crayon-plain-tag">long bpf_trace_printk(const char *fmt, __u32 fmt_size, ...)</pre>是bpf_printk的wrapper。</p>
<div class="blog_h2"><span class="graybg">libbpf-bootstrap</span></div>
<p>这是libbpf提供的脚手架，可以让你快速开始编写自己的eBPF程序。该脚手架目前提供一些demo程序：</p>
<ol>
<li>minimal：最小化的、能编译、加载、运行的BPF hello world程序</li>
<li>bootstrap：现实可用的、可移植的、全功能的BPF程序，依赖BPF CO-RE。需要当前使用的内核开启<pre class="crayon-plain-tag">CONFIG_DEBUG_INFO_BTF=y</pre>，该demo还示例了5.5+支持的BPF全局变量、5.8+支持的BPF ring buffer</li>
</ol>
<p>libbpf-bootstrap自带了libbpf（作为git子模块）和bpftool（仅x84），避免依赖你的Linux发行版所携带的特定版本。libbpf依赖的库包括libelf、zlib，需要确保已经安装。</p>
<p>执行下面的命令获得脚手架：</p>
<pre class="crayon-plain-tag">git clone https://github.com/libbpf/libbpf-bootstrap.git
git submodule init</pre>
<p>目录结构如下：</p>
<pre class="crayon-plain-tag">.
├── examples
│   ├── c  # C代码示例
│   │   ├── bootstrap.bpf.c  # 在内核空间执行的BPF程序
│   │   ├── bootstrap.c      # 在用户空间执行的逻辑，负责加载BPF字节码，在程序生命周期内和BPF程序交互
│   │   ├── bootstrap.h      # 用户/内核空间程序共享的头
│   │   ├── CMakeLists.txt
│   │   ├── fentry.bpf.c
│   │   ├── fentry.c
│   │   ├── kprobe.bpf.c
│   │   ├── kprobe.c
│   │   ├── Makefile
│   │   ├── minimal.bpf.c
│   │   ├── minimal.c
│   │   ├── uprobe.bpf.c
│   │   ├── uprobe.c
│   │   └── xmake.lua
│   └── rust # Rust代码示例
│       ├── Cargo.lock
│       ├── Cargo.toml
│       └── xdp
│           ├── build.rs
│           ├── Cargo.lock
│           ├── Cargo.toml
│           └── src
│               ├── bpf
│               │   ├── vmlinux.h -&gt; ../../../../../vmlinux/vmlinux.h
│               │   └── xdppass.bpf.c
│               └── main.rs
├── libbpf # 自带的libbpf
├── LICENSE
├── README.md
├── tools  # 自带bpftool，用于为你的BPF代码构建BPF skeleton、生成vmlinux.h头
│   ├── bpftool
│   ├── cmake
│   │   ├── FindBpfObject.cmake
│   │   └── FindLibBpf.cmake
│   └── gen_vmlinux_h.sh  # 用于生成自定义的vmlinux.h
└── vmlinux
    ├── vmlinux_508.h
    └── vmlinux.h -&gt; vmlinux_508.h  # 预生成的vmlinux.h</pre>
<div class="blog_h3"><span class="graybg">minimal</span></div>
<p>最小化的样例，不依赖BPF CO-RE，可以在很老版本的内核上运行。该程序会安装一个每秒触发一次的tracepoint handler，它使用<pre class="crayon-plain-tag">bpf_printk()</pre>和外部通信，你可以通过/sys/kernel/debug/tracing/trace_pipe查看输出。</p>
<p>构建此样例：</p>
<pre class="crayon-plain-tag">cd examples/c
make minimal</pre>
<p>内核空间代码：</p>
<pre class="crayon-plain-tag">// 包含基本的BPF相关的类型和常量。为了使用内核空间BPF API，例如助手函数的flags，需要引入此头
#include &lt;linux/bpf.h&gt;
// 由libbpf提供，包含了大部分常用宏、常量、助手函数，几乎每个BPF程序都会使用
#include &lt;bpf/bpf_helpers.h&gt;
// 这个变量定义你的代码的License，内核强制要求此字段存在，某些功能对于非GPL兼容的License不可用
//             必须定义在license段
char LICENSE[] SEC("license") = "Dual BSD/GPL";
// 全局变量，要求内核版本5.5+，全局变量甚至可以从用户空间读写
// 可以用于配置BPF程序、存放轻量的统计数据、在内核和用户空间传递数据
int my_pid = 0;

// 这里定义了BPF内核程序
// 这个注解，说明了需要创建的BPF程序类型，以及如何挂钩到内核
//   tracepoint BPF程序
//      在进入write系统调用时触发
SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
	// 调用助手函数，获得PID（内核的术语叫TGID）。为了节约空间，很多助手函数使用高低字节存储不同的数据
	int pid = bpf_get_current_pid_tgid() &gt;&gt; 32;

	// 判断是否是关注进程发出的系统调用
	if (pid != my_pid)
		return 0;
	// 如果是，打印信息到/sys/kernel/debug/tracing/trace_pipe
	// 注意，由于性能问题，这个函数不能用于生产环境
	bpf_printk("BPF triggered from PID %d.\n", pid);

	return 0;
}

// 你还可以定义更多的BPF程序，只需要为它们声明适当的SEC即可。所有这些程序共享全局变量</pre>
<p>用户空间代码：</p>
<pre class="crayon-plain-tag">#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
#include &lt;sys/resource.h&gt;
#include &lt;bpf/libbpf.h&gt;
// 由bpftool自动生成的，映射了minimal.bpf.c的高层结构的BPF skeleton
// 编译后的BPF object被内嵌到此头文件（也就是用户空间代码）中，简化了开发和部署
// 文件路径 .output/&lt;app&gt;.skel.h
#include "minimal.skel.h"

// 此回调打印libbpf日志到控制台
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
	return vfprintf(stderr, format, args);
}

static void bump_memlock_rlimit(void)
{
	struct rlimit rlim_new = {
		.rlim_cur	= RLIM_INFINITY,
		.rlim_max	= RLIM_INFINITY,
	};

	if (setrlimit(RLIMIT_MEMLOCK, &amp;rlim_new)) {
		fprintf(stderr, "Failed to increase RLIMIT_MEMLOCK limit!\n");
		exit(1);
	}
}

int main(int argc, char **argv)
{
	struct minimal_bpf *skel;
	int err;

	// 为所有libbpf日志设置回调函数
	libbpf_set_print(libbpf_print_fn);

	// 增大内核内部的per-user内存限制，允许BPF子系统为程序、Map分配足够的资源
	bump_memlock_rlimit();

	// 打开BPF skeleton
	skel = minimal_bpf__open();
	if (!skel) {
		fprintf(stderr, "Failed to open BPF skeleton\n");
		return 1;
	}

	// 访问BSS段中的全局变量
	skel-&gt;bss-&gt;my_pid = getpid();

	// 加载和校验BPF程序
	err = minimal_bpf__load(skel);
	if (err) {
		fprintf(stderr, "Failed to load and verify BPF skeleton\n");
		goto cleanup;
	}

	// 挂钩BPF程序，在此即注册tracepoint handler
	// libbpf能够根据SEC注解，自动的为大部分BPF程序类型（tracepoints, kprobes等）选择适当的挂钩点
	// 如果不能满足需求，可以调用libbpf提供的函数手工挂钩的API
	err = minimal_bpf__attach(skel);
	if (err) {
		fprintf(stderr, "Failed to attach BPF skeleton\n");
		goto cleanup;
	}

	printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
	       "to see output of the BPF programs.\n");

	// 触发write系统调用，进而触发BPF程序
	for (;;) {
		/* trigger our BPF program */
		fprintf(stderr, ".");
		sleep(1);
	}

cleanup:
	// 清除所有内核/用户空间的资源
	// 在大部分情况下，即使程序崩溃，没有清理，内核也会作自动清理
	minimal_bpf__destroy(skel);
	return -err;
}</pre>
<p>构建此程序的Makefile：</p>
<pre class="crayon-plain-tag"># SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
OUTPUT := .output
CLANG ?= clang
LLVM_STRIP ?= llvm-strip
BPFTOOL ?= $(abspath ../../tools/bpftool)
LIBBPF_SRC := $(abspath ../../libbpf/src)
LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a)
# vmlinux.h由bpftool从当前运行的内核中抽取出，包含了所有内核使用的类型定义
VMLINUX := ../../vmlinux/vmlinux.h
# 使用自己的libbpf API头文件、Linux UAPI头文件，避免依赖当前系统的（可能缺失或过期的）头文件
INCLUDES := -I$(OUTPUT) -I../../libbpf/include/uapi -I$(dir $(VMLINUX))
# 使用-g保留调试信息
CFLAGS := -g -Wall
# 体系结构信息会传递给后续的BPF构建步骤，使用bpf_tracing.h中低级别tracing助手函数需要此信息
ARCH := $(shell uname -m | sed 's/x86_64/x86/')

# 这个示例项目包含多个demo，每个对应一个APP
APPS = minimal bootstrap uprobe kprobe fentry

# 获取Clang在当前系统上默认的includes目录，当以-target bpf编译时，这些目录被显式添加到
# include列表。如果不这样做，某些体系结构/发行版下，体系结构特定的目录可能missing，诸如
# asm/types.h, asm/byteorder.h, asm/socket.h, asm/sockios.h, sys/cdefs.h之类
# 的头文件可能missing
CLANG_BPF_SYS_INCLUDES = $(shell $(CLANG) -v -E - &lt;/dev/null 2&gt;&amp;1 \
	| sed -n '/&lt;...&gt; search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }')

ifeq ($(V),1)
	Q =
	msg =
else
	Q = @
	msg = @printf '  %-8s %s%s\n'					\
		      "$(1)"						\
		      "$(patsubst $(abspath $(OUTPUT))/%,%,$(2))"	\
		      "$(if $(3), $(3))";
	MAKEFLAGS += --no-print-directory
endif

.PHONY: all
all: $(APPS)

.PHONY: clean
clean:
	$(call msg,CLEAN)
	$(Q)rm -rf $(OUTPUT) $(APPS)

$(OUTPUT) $(OUTPUT)/libbpf:
	$(call msg,MKDIR,$@)
	$(Q)mkdir -p $@

# 1. 构建 libbpf 为静态库，存放在.output目录下
#    如果希望和系统的libbpf共享库链接，去掉这个目标
$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf
	$(call msg,LIB,$@)
	$(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1		      \
		    OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@)		      \
		    INCLUDEDIR= LIBDIR= UAPIDIR=			      \
		    install

# 2. 构建BPF object
$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT)
	$(call msg,BPF,$@)
	# 必须使用-g -O2     目标必须是bpf 为bpf_tracing.h定义必要的宏
	# 
	$(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) $(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) -c $(filter %.c,$^) -o $@
	$(Q)$(LLVM_STRIP) -g $@ # 去除DWARF信息，从来不会用到。由于BPF程序最终以文本形式嵌入到BPF skeleton，因此要尽量精简

# 3. 生成BPF skeletons, 依赖2
$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT)
	$(call msg,GEN-SKEL,$@)
	$(Q)$(BPFTOOL) gen skeleton $&lt; &gt; $@

# 4. 构建用户空间程序object，依赖3
$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h

$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT)
	$(call msg,CC,$@)
	$(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@

# 5. 构建用户空间程序
$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT)
	$(call msg,BINARY,$@)
	$(Q)$(CC) $(CFLAGS) $^ -lelf -lz -o $@

# delete failed targets
.DELETE_ON_ERROR:

# keep intermediate (.skel.h, .bpf.o, etc) targets
.SECONDARY:</pre>
<div class="blog_h3"><span class="graybg">bootstrap</span></div>
<p>minimal是最小化的BPF程序实例，在现代Linux环境下开发BPF程序，bootstrap可以作为一个不错的开始点。它实现的功能包括：</p>
<ol>
<li>命令行参数解析</li>
<li>信号处理</li>
<li>多个BPF程序之间的交互 —— 通过Map共享状态</li>
<li>使用BPF ring buffer来发送数据到用户空间</li>
<li>用于行为参数化的全局常量，以及如何通过修改段数据初始化常量值</li>
<li>使用BPF CO-RE和vmlinux.h来读取内核<pre class="crayon-plain-tag">struct task_struct</pre>暴露的进程的额外信息</li>
</ol>
<p>该程序依赖BPF CO-RE，需要内核配置<pre class="crayon-plain-tag">CONFIG_DEBUG_INFO_BTF=y</pre>。</p>
<p>公共头文件：</p>
<pre class="crayon-plain-tag">#ifndef __BOOTSTRAP_H
#define __BOOTSTRAP_H

#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 127

struct event {
	int pid;
	int ppid;
	unsigned exit_code;
	unsigned long long duration_ns;
	char comm[TASK_COMM_LEN];
	char filename[MAX_FILENAME_LEN];
	bool exit_event;
};

#endif /* __BOOTSTRAP_H */</pre>
<p>内核空间代码：</p>
<pre class="crayon-plain-tag">// 该头文件包含内核的所有数据类型，通过gen_vmlinux_h.sh自动生成

// 其中的类型应用了__attribute__((preserve_access_index))注解，可以让Clang
// 生成BPF CO-RE relocations，这让libbpf能够自动的将BPF代码是配到宿主机内核的内存布局，即使
// 该布局和构建BPF程序时候的主机不一致
// BPF CO-RE relocations是创建pre-compiled、可移植的BPF程序的关键，它不需要在目标及其上部署
// Clang/LLVM工具链。一个被选的技术是BCC的运行时编译，这种技术有多个缺点

// 需要注意，vmlinux.h不能和其它系统级的头文件联合适用，会导致类型重定义和冲突
// 因此开发BPF程序时，仅可使用vmlinux.h、libbpf提供的头、你自己定义的头
#include "vmlinux.h"
#include &lt;bpf/bpf_helpers.h&gt;
// 下面两个头文件，用于编写基于BPF CO-RE的追踪应用程序
#include &lt;bpf/bpf_tracing.h&gt;
#include &lt;bpf/bpf_core_read.h&gt;
// 用于/内核空间共享头
#include "bootstrap.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 定义一个哈希表类型的Map
struct {
	// 这几个__开头的是宏bpf_helpers.h中定义的宏，用来定义结构体字段
	// 定义一个BPF_MAP_TYPE_HASH类型的，最大条目8192的，key类型为pid_t，值类型为u64的Map
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 8192);
	__type(key, pid_t);
	__type(value, u64);
} exec_start SEC(".maps");
//           为了让libbpf知道它需要创建BPF Map，必须添加此注解

// 定义一个BPF ring buffer类型的Map
// 用于向用户空间发送数据，本样例使用bpf_ringbuf_reserve()/bpf_ringbuf_submit()来实现最高
// 的易用性和性能
struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

// 全局常量，对于用户/内核空间均不可变。在BPF程序校验期间，此常量值已知，BPF Verifier可能依据值来裁减
// 某些dead code
// volatile是必须的，可以防止Clang将变量优化掉（直接没了）
const volatile unsigned long long min_duration_ns = 0;

// 本样例由两个BPF程序组成，这个监控exec系统调用
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
	struct task_struct *task;
	unsigned fname_off;
	struct event *e;
	pid_t pid;
	u64 ts;

	// 得到当前PID
	pid = bpf_get_current_pid_tgid() &gt;&gt; 32;
	// 获取当前内核时间
	ts = bpf_ktime_get_ns();
	// 记录当前进程的创建时间
	bpf_map_update_elem(&amp;exec_start, &amp;pid, &amp;ts, BPF_ANY);

	/* don't emit exec events when minimum duration is specified */
	if (min_duration_ns)
		return 0;

	// 在ring buffer中保留event长度的空间，返回得到的空间指针
	e = bpf_ringbuf_reserve(&amp;rb, sizeof(*e), 0);
	if (!e)
		return 0;

	// 得到当前Task对象
	task = (struct task_struct *)bpf_get_current_task();
	// 并填充event
	e-&gt;exit_event = false;
	e-&gt;pid = pid;
	// BPF CO-RE读取
	// 相当于e-&gt;ppid = task-&gt;real_parent-&gt;tgid;
	// 但是对于BPF程序来说，BPF verifier需要额外的检查，因为存在读取任意内核内存的可能
	// BPF_CORE_READ()通过简洁的方式完成此过程，并且记录必要的BPF CO-RE relocations，从而
	// 允许libbpf根据宿主机的内存布局，来调整字段的偏移量
	e-&gt;ppid = BPF_CORE_READ(task, real_parent, tgid);
	// 读取当前任务的可执行文件名（不包含路径）
	bpf_get_current_comm(&amp;e-&gt;comm, sizeof(e-&gt;comm));

	// 获取新进程文件名在ctx的偏移量            移除u32高16位
	fname_off = ctx-&gt;__data_loc_filename &amp; 0xFFFF;
	// 从ctx读取文件名，存入e
	bpf_probe_read_str(&amp;e-&gt;filename, sizeof(e-&gt;filename), (void *)ctx + fname_off);

	// 提交给用户空间供处理
	bpf_ringbuf_submit(e, 0);
	return 0;
}

// 本样例由两个BPF程序组成，这个监控进程退出，即exit系统调用
SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
	struct task_struct *task;
	struct event *e;
	pid_t pid, tid;
	u64 id, ts, *start_ts, duration_ns = 0;
	
	// 获取正在退出的进程的PID
	id = bpf_get_current_pid_tgid();
	// 高32位是pid，低32位是线程ID
	pid = id &gt;&gt; 32;
	tid = (u32)id;

	// 如果pid和tid不同，则意味着是非主线程退出，忽略
	if (pid != tid)
		return 0;

	// 查找进程开始时间，并计算进程持续时间
	start_ts = bpf_map_lookup_elem(&amp;exec_start, &amp;pid);
	if (start_ts)
		duration_ns = bpf_ktime_get_ns() - *start_ts;
	else if (min_duration_ns)
		return 0;
	// 从Map删除条目
	bpf_map_delete_elem(&amp;exec_start, &amp;pid);

	// 忽略持续时间过短的进程
	if (min_duration_ns &amp;&amp; duration_ns &lt; min_duration_ns)
		return 0;

	// 从ring buffer分配空间
	e = bpf_ringbuf_reserve(&amp;rb, sizeof(*e), 0);
	if (!e)
		return 0;

	// 填充并提交给用户空间处理
	task = (struct task_struct *)bpf_get_current_task();

	e-&gt;exit_event = true;
	e-&gt;duration_ns = duration_ns;
	e-&gt;pid = pid;
	e-&gt;ppid = BPF_CORE_READ(task, real_parent, tgid);
	e-&gt;exit_code = (BPF_CORE_READ(task, exit_code) &gt;&gt; 8) &amp; 0xff;
	bpf_get_current_comm(&amp;e-&gt;comm, sizeof(e-&gt;comm));

	/* send data to user-space for post-processing */
	bpf_ringbuf_submit(e, 0);
	return 0;
}</pre>
<p>用户空间代码：</p>
<pre class="crayon-plain-tag">#include &lt;argp.h&gt;
#include &lt;signal.h&gt;
#include &lt;stdio.h&gt;
#include &lt;time.h&gt;
#include &lt;sys/resource.h&gt;
#include &lt;bpf/libbpf.h&gt;
#include "bootstrap.h"
#include "bootstrap.skel.h"

// 使用libc的argp做命令行参数解析
static struct env {
	bool verbose;
	long min_duration_ms;
} env;

const char *argp_program_version = "bootstrap 0.0";
const char *argp_program_bug_address = "&lt;bpf@vger.kernel.org&gt;";
const char argp_program_doc[] =
"BPF bootstrap demo application.\n"
"\n"
"It traces process start and exits and shows associated \n"
"information (filename, process duration, PID and PPID, etc).\n"
"\n"
"USAGE: ./bootstrap [-d &lt;min-duration-ms&gt;] [-v]\n";

static const struct argp_option opts[] = {
	{ "verbose", 'v', NULL, 0, "Verbose debug output" },
	{ "duration", 'd', "DURATION-MS", 0, "Minimum process duration (ms) to report" },
	{},
};

static error_t parse_arg(int key, char *arg, struct argp_state *state)
{
	switch (key) {
	case 'v':
		env.verbose = true;
		break;
	case 'd':
		errno = 0;
		env.min_duration_ms = strtol(arg, NULL, 10);
		if (errno || env.min_duration_ms &lt;= 0) {
			fprintf(stderr, "Invalid duration: %s\n", arg);
			argp_usage(state);
		}
		break;
	case ARGP_KEY_ARG:
		argp_usage(state);
		break;
	default:
		return ARGP_ERR_UNKNOWN;
	}
	return 0;
}

static const struct argp argp = {
	.options = opts,
	.parser = parse_arg,
	.doc = argp_program_doc,
};

static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
	if (level == LIBBPF_DEBUG &amp;&amp; !env.verbose)
		return 0;
	return vfprintf(stderr, format, args);
}

static void bump_memlock_rlimit(void)
{
	struct rlimit rlim_new = {
		.rlim_cur	= RLIM_INFINITY,
		.rlim_max	= RLIM_INFINITY,
	};

	if (setrlimit(RLIMIT_MEMLOCK, &amp;rlim_new)) {
		fprintf(stderr, "Failed to increase RLIMIT_MEMLOCK limit!\n");
		exit(1);
	}
}

static volatile bool exiting = false;

static void sig_handler(int sig)
{
	exiting = true;
}

static int handle_event(void *ctx, void *data, size_t data_sz)
{
	// 直接转换为event
	const struct event *e = data;
	struct tm *tm;
	char ts[32];
	time_t t;

	time(&amp;t);
	tm = localtime(&amp;t);
	strftime(ts, sizeof(ts), "%H:%M:%S", tm);

	if (e-&gt;exit_event) {
		printf("%-8s %-5s %-16s %-7d %-7d [%u]", ts, "EXIT", e-&gt;comm, e-&gt;pid, e-&gt;ppid, e-&gt;exit_code);
		if (e-&gt;duration_ns) printf(" (%llums)", e-&gt;duration_ns / 1000000);
		printf("\n");
	} else {
		printf("%-8s %-5s %-16s %-7d %-7d %s\n",  ts, "EXEC", e-&gt;comm, e-&gt;pid, e-&gt;ppid, e-&gt;filename);
	}
	return 0;
}

int main(int argc, char **argv)
{
	struct ring_buffer *rb = NULL;
	struct bootstrap_bpf *skel;
	int err;

	// 解析命令行参数
	err = argp_parse(&amp;argp, argc, argv, 0, NULL, NULL);
	if (err)
		return err;

	// 日追回调函数
	libbpf_set_print(libbpf_print_fn);

	// 设置RLIMIT_MEMLOCK
	bump_memlock_rlimit();

	// 去除默认Ctrl-C处理逻辑
	signal(SIGINT, sig_handler);
	signal(SIGTERM, sig_handler);

	// 由于有一个常量需要设置值，因此这里没有使用bootstrap_bpf__open_and_load()
	// 而是先open
	skel = bootstrap_bpf__open();
	if (!skel) {
		fprintf(stderr, "Failed to open and load BPF skeleton\n");
		return 1;
	}

	// 然后设置常量值
	skel-&gt;rodata-&gt;min_duration_ns = env.min_duration_ms * 1000000ULL;

	// 最后load，加载之后，用户空间只能读取，不能修改 skel-&gt;rodata-&gt;min_duration_ns
	err = bootstrap_bpf__load(skel);
	if (err) {
		fprintf(stderr, "Failed to load and verify BPF skeleton\n");
		goto cleanup;
	}

	// 挂钩BPF程序到内核
	err = bootstrap_bpf__attach(skel);
	if (err) {
		fprintf(stderr, "Failed to attach BPF skeleton\n");
		goto cleanup;
	}

	// 创建ring buffer轮询
	// 映射位ring_buffer类型   内核空间对应物              轮询到数据后的回调
	rb = ring_buffer__new(bpf_map__fd(skel-&gt;maps.rb), handle_event, NULL, NULL);
	if (!rb) {
		err = -1;
		fprintf(stderr, "Failed to create ring buffer\n");
		goto cleanup;
	}

	// 打印表头
	printf("%-8s %-5s %-16s %-7s %-7s %s\n",
	       "TIME", "EVENT", "COMM", "PID", "PPID", "FILENAME/EXIT CODE");
	// 接收到信号前，反复轮询
	while (!exiting) {
		err = ring_buffer__poll(rb, 100 /* timeout, ms */);
		/* Ctrl-C will cause -EINTR */
		if (err == -EINTR) {
			err = 0;
			break;
		}
		if (err &lt; 0) {
			printf("Error polling perf buffer: %d\n", err);
			break;
		}
	}

cleanup:
	// 需要清理ring buffer和BPF
	ring_buffer__free(rb);
	bootstrap_bpf__destroy(skel);

	return err &lt; 0 ? -err : 0;
}</pre>
<div class="blog_h3"><span class="graybg">uprobe</span></div>
<p>上面两个例子都是tracepoint BPF程序。tracepoint是内核中比较稳定的追踪机制，属于静态instrument，它由一系列预定义在内核源码中的挂钩点组成。</p>
<p>kprobe/uprobe则属于动态instrument，在运行时动态的进行instrument，可以对任何函数进行调试追踪。例如在函数的入口、出口地址、或者某一行值入代码，执行到这些代码的时候，你就可以获得上下文信息，例如当前函数的名字、参数、返回值、寄存器甚至全局数据结构信息。</p>
<p>kprobe/uprobe很强大，但是依赖于程序/内核的特定版本，因为函数的签名可能改变，函数也可能被删除。</p>
<p>libbpf-bootstrap提供了一个uprobe样例，此样例能够处理用户空间的entry/exit(return)探针。</p>
<p>内核空间代码：</p>
<pre class="crayon-plain-tag">#include &lt;linux/bpf.h&gt;
#include &lt;linux/ptrace.h&gt;
#include &lt;bpf/bpf_helpers.h&gt;
#include &lt;bpf/bpf_tracing.h&gt;

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 函数entry探针
SEC("uprobe/func")
// 这里是调用一个宏，在函数体中可以使用参数 struct pt_regs *ctx
//                     逐个列出函数的参数
int BPF_KPROBE(uprobe, int a, int b)
{
	bpf_printk("UPROBE ENTRY: a = %d, b = %d\n", a, b);
	return 0;
}

// 函数exit探针
SEC("uretprobe/func")
//                           列出函数的返回值
int BPF_KRETPROBE(uretprobe, int ret)
{
	bpf_printk("UPROBE EXIT: return = %d\n", ret);
	return 0;
}</pre>
<p>用户空间代码：</p>
<pre class="crayon-plain-tag">#include &lt;errno.h&gt;
#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
#include &lt;sys/resource.h&gt;
#include &lt;bpf/libbpf.h&gt;
#include "uprobe.skel.h"

//...

/* 通过/proc/self/maps查找进程的base load address, 寻找第一个可执行的（r-xp）内存映射：
 * 绝对起始地址                    此区域的偏移量
 * 5574fd254000-5574fd258000 r-xp 00002000 fd:01 668759                     /usr/bin/cat
 * ^^^^^^^^^^^^                   ^^^^^^^^
 * 绝对起始地址 - 此区域的偏移量 即得到进程的base load address
 */
static long get_base_addr() {
	size_t start, offset;
	char buf[256];
	FILE *f;

	f = fopen("/proc/self/maps", "r");
	if (!f)
		return -errno;

	while (fscanf(f, "%zx-%*x %s %zx %*[^\n]\n", &amp;start, buf, &amp;offset) == 3) {
		if (strcmp(buf, "r-xp") == 0) {
			fclose(f);
			return start - offset;
		}
	}

	fclose(f);
	return -1;
}

// 这个是被追踪的函数
int uprobed_function(int a, int b)
{
	return a + b;
}

int main(int argc, char **argv)
{
	struct uprobe_bpf *skel;
	long base_addr, uprobe_offset;
	int err, i;

	// 注册日志回调钩子、调整rlimit、打开并加载BPF程序

	base_addr = get_base_addr();
	if (base_addr &lt; 0) {
		fprintf(stderr, "Failed to determine process's load address\n");
		err = base_addr;
		goto cleanup;
	}

	// uprobe/uretprobe期望获知被追踪函数的相对偏移量，这个偏移量是相对于process的base load address的
	// 这里直接得到目标函数的绝对地址，然后减去上面我们得到的base load address，即可得到偏移量
	//
	// 通常情况下，被追踪的函数不会在当前程序中，这可能需要解析其所在程序的ELF，
	// 函数相对base load address的偏移量 = .text段的偏移量 + 函数在.text段内的偏移量
	//
	//              得到函数的绝对地址
	uprobe_offset = (long)&amp;uprobed_function - base_addr;

	//                   挂钩
	skel-&gt;links.uprobe = bpf_program__attach_uprobe(skel-&gt;progs.uprobe,
							false /* entry钩子 */,
							0 /* 到当前进程 */,
							"/proc/self/exe", // 当前进程的二进制文件路径
							uprobe_offset); // 追踪的目标函数的偏移量
	err = libbpf_get_error(skel-&gt;links.uprobe);
	if (err) {
		fprintf(stderr, "Failed to attach uprobe: %d\n", err);
		goto cleanup;
	}

	// 这里示例了如何挂钩uprobe/uretprobe到任何现存的/未来创建的、使用同一二进制文件的进程
	skel-&gt;links.uretprobe = bpf_program__attach_uprobe(skel-&gt;progs.uretprobe,
							   true /* exit钩子 */,
							   -1 /* 任何进程 */,
							   "/proc/self/exe",
							   uprobe_offset);
	err = libbpf_get_error(skel-&gt;links.uretprobe);
	if (err) {
		fprintf(stderr, "Failed to attach uprobe: %d\n", err);
		goto cleanup;
	}

	printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
	       "to see output of the BPF programs.\n");

	for (i = 0; ; i++) {
		// 调用目标函数，触发BPF程序
		fprintf(stderr, ".");
		uprobed_function(i, i + 1);
		sleep(1);
	}

cleanup:
	uprobe_bpf__destroy(skel);
	return -err;
}</pre>
<p>运行此程序，就可以看到函数uprobed_function每次调用的参数、返回值了。</p>
<div class="blog_h3"><span class="graybg">kprobe</span></div>
<p>kprobe和uprobe工作方式很类似，只是挂钩的是内核函数。</p>
<p>libbpf-bootstrap提供了一个kprobe样例，该样例挂钩到do_unlinkat()函数，并且打印PID、文件名、返回值等信息。</p>
<p>内核空间代码：</p>
<pre class="crayon-plain-tag">#include "vmlinux.h"
#include &lt;bpf/bpf_helpers.h&gt;
#include &lt;bpf/bpf_tracing.h&gt;
#include &lt;bpf/bpf_core_read.h&gt;

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 注解里必须包含目标内核函数的名字
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
	pid_t pid;
	const char *filename;

	pid = bpf_get_current_pid_tgid() &gt;&gt; 32;
	// 必须使用此助手函数访问指针参数，fentry则不必
	filename = BPF_CORE_READ(name, name);
	bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
	return 0;
}

SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
	pid_t pid;

	pid = bpf_get_current_pid_tgid() &gt;&gt; 32;
	bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
	return 0;
}</pre>
<p>用户空间代码：</p>
<pre class="crayon-plain-tag">#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
#include &lt;signal.h&gt;
#include &lt;string.h&gt;
#include &lt;errno.h&gt;
#include &lt;sys/resource.h&gt;
#include &lt;bpf/libbpf.h&gt;
#include "kprobe.skel.h"

// ...

static volatile sig_atomic_t stop;

static void sig_int(int signo)
{
	stop = 1;
}

int main(int argc, char **argv)
{
	struct kprobe_bpf *skel;
	int err;

	// 注册日志回调钩子、调整rlimit、打开并加载BPF程序

	// 挂钩处理比起uprobe要简单的多，不需要计算目标函数的地址
	err = kprobe_bpf__attach(skel);
	if (err) {
		fprintf(stderr, "Failed to attach BPF skeleton\n");
		goto cleanup;
	}

	if (signal(SIGINT, sig_int) == SIG_ERR) {
		fprintf(stderr, "can't set signal handler: %s\n", strerror(errno));
		goto cleanup;
	}

	printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
	       "to see output of the BPF programs.\n");

	while (!stop) {
		fprintf(stderr, ".");
		sleep(1);
	}

cleanup:
	kprobe_bpf__destroy(skel);
	return -err;
}</pre>
<div class="blog_h3"><span class="graybg">fentry</span></div>
<p>内核5.5引入了BPF trampoline，可以让内核和BPF程序更快速的相互调用。fentry/fexit是它的一个用例，fentry/fexit基本上等价于kprobe/kretprobe，但是调用BPF程序不会引起额外的overhead。</p>
<p>在XDP开发中BPF trampoline能很大程度上改善BPF相关的网络troubleshooting的体验。BPF trampoline支持将fentry/fexit BPF程序挂钩到任何网络BPF程序上，从而可以看到任何XDP/TC/lwt/cgroup程序的输入输出封包，同时不会产生干扰。</p>
<p>libbpf-bootstrap提供了一个fentry样例，类似于kprobe样例，它也是挂钩到do_unlinkat()函数，从而在文件被删除后触发BPF程序，记录各种信息。</p>
<p>fentry/fexit提升了性能的同时，也改善了开发体验。<span style="background-color: #c0c0c0;">访问指针参数时</span>，不再需要调用助手函数，<span style="background-color: #c0c0c0;">可以直接解引用</span>。<span style="background-color: #c0c0c0;">fexit</span>和kretprobe比起来，<span style="background-color: #c0c0c0;">可以同时返回输入参数和返回值</span>，后者只能访问返回值。</p>
<p>内核空间代码：</p>
<pre class="crayon-plain-tag">#include "vmlinux.h"
#include &lt;bpf/bpf_helpers.h&gt;
#include &lt;bpf/bpf_tracing.h&gt;

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 注解里必须包含目标内核函数的名字
SEC("fentry/do_unlinkat")
int BPF_PROG(do_unlinkat, int dfd, struct filename *name)
{
	pid_t pid;

	pid = bpf_get_current_pid_tgid() &gt;&gt; 32;
	bpf_printk("fentry: pid = %d, filename = %s\n", pid, name-&gt;name);
	return 0;
}

SEC("fexit/do_unlinkat")
//                             可以同时访问入参和返回值
int BPF_PROG(do_unlinkat_exit, int dfd, struct filename *name, long ret)
{
	pid_t pid;

	pid = bpf_get_current_pid_tgid() &gt;&gt; 32;
	bpf_printk("fexit: pid = %d, filename = %s, ret = %ld\n", pid, name-&gt;name, ret);
	return 0;
}</pre>
<p>用户空间代码：</p>
<pre class="crayon-plain-tag">#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
#include &lt;signal.h&gt;
#include &lt;string.h&gt;
#include &lt;errno.h&gt;
#include &lt;sys/resource.h&gt;
#include &lt;bpf/libbpf.h&gt;
#include "fentry.skel.h"

static volatile sig_atomic_t stop;

int main(int argc, char **argv)
{
	struct fentry_bpf *skel;
	int err;

	// 注册日志回调钩子、调整rlimit、打开并加载BPF程序

	// 挂钩
	err = fentry_bpf__attach(skel);
	if (err) {
		fprintf(stderr, "Failed to attach BPF skeleton\n");
		goto cleanup;
	}

	while (!stop) {
		fprintf(stderr, ".");
		sleep(1);
	}

cleanup:
	fentry_bpf__destroy(skel);
	return -err;
}</pre>
<div class="blog_h2"><span class="graybg">内核样例程序</span></div>
<p>内核在samples/bpf/下包含了若干eBPF程序示例。这些程序都包含两部分：</p>
<ol>
<li><pre class="crayon-plain-tag">*_kern.c</pre>会编译为BPF内核程序</li>
<li><pre class="crayon-plain-tag">*_user.c</pre>为用户空间程序，加载上述内核程序</li>
</ol>
<p>这些样例没有封装，直接使用libbpf提供的函数完成各种功能，包括助手函数调用、ELF加载、BPF挂钩、Map创建和读写。</p>
<p>执行下面的命令构建示例：</p>
<pre class="crayon-plain-tag"># 可能需要进行必要的清理
make -C tools clean
make -C samples/bpf clean
make clean

# 当前正在构建版本的内核头文件，复制到usr/include子目录。构建samples会优先使用这些头文件
make headers_install

# 构建BPF样例
make M=samples/bpf</pre>
<div class="blog_h3"><span class="graybg">tracex4</span></div>
<p>eBPF程序代码：</p>
<pre class="crayon-plain-tag">#include &lt;linux/ptrace.h&gt;
#include &lt;linux/version.h&gt;
#include &lt;uapi/linux/bpf.h&gt;
#include &lt;bpf/bpf_helpers.h&gt;
#include &lt;bpf/bpf_tracing.h&gt;

struct pair {
	u64 val;
	u64 ip;
};

// 定义一个Map
struct {
    // eBPF提供多种Map，BPF_MAP_TYPE_HASH是其中之一
	__uint(type, BPF_MAP_TYPE_HASH);
	// 键值的类型
	__type(key, long);
	__type(value, struct pair);
	// 容量
	__uint(max_entries, 1000000);
} my_map SEC(".maps");
        // SEC宏用于在二进制文件中创建一个新的段

// 这个段实际上声明了挂钩点
SEC("kprobe/kmem_cache_free")
// 从Map中删除一个键值
int bpf_prog1(struct pt_regs *ctx)
{
	long ptr = PT_REGS_PARM2(ctx);

	bpf_map_delete_elem(&amp;my_map, &amp;ptr);
	return 0;
}

// 这个段实际上声明了挂钩点
SEC("kretprobe/kmem_cache_alloc_node")
// 添加一个键值到Map
int bpf_prog2(struct pt_regs *ctx)
{
	long ptr = PT_REGS_RC(ctx);
	long ip = 0;

	/* get ip address of kmem_cache_alloc_node() caller */
	BPF_KRETPROBE_READ_RET_IP(ip, ctx);

	struct pair v = {
		.val = bpf_ktime_get_ns(),
		.ip = ip,
	};

	bpf_map_update_elem(&amp;my_map, &amp;ptr, &amp;v, BPF_ANY);
	return 0;
}
char _license[] SEC("license") = "GPL";
u32 _version SEC("version") = LINUX_VERSION_CODE;</pre>
<p>用于空间程序源码：</p>
<pre class="crayon-plain-tag">#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;signal.h&gt;
#include &lt;unistd.h&gt;
#include &lt;stdbool.h&gt;
#include &lt;string.h&gt;
#include &lt;time.h&gt;
#include &lt;sys/resource.h&gt;

#include &lt;bpf/bpf.h&gt;
#include &lt;bpf/libbpf.h&gt;

struct pair {
	long long val;
	__u64 ip;
};

static __u64 time_get_ns(void)
{
	struct timespec ts;

	clock_gettime(CLOCK_MONOTONIC, &amp;ts);
	return ts.tv_sec * 1000000000ull + ts.tv_nsec;
}

static void print_old_objects(int fd)
{
	long long val = time_get_ns();
	__u64 key, next_key;
	struct pair v;

	key = write(1, "\e[1;1H\e[2J", 12); /* clear screen */

	key = -1;
	while (bpf_map_get_next_key(fd, &amp;key, &amp;next_key) == 0) {
		bpf_map_lookup_elem(fd, &amp;next_key, &amp;v);
		key = next_key;
		if (val - v.val &lt; 1000000000ll)
			/* object was allocated more then 1 sec ago */
			continue;
		printf("obj 0x%llx is %2lldsec old was allocated at ip %llx\n",
		       next_key, (val - v.val) / 1000000000ll, v.ip);
	}
}

int main(int ac, char **argv)
{
	struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
	struct bpf_link *links[2];
	struct bpf_program *prog;
	struct bpf_object *obj;
	char filename[256];
	int map_fd, i, j = 0;

	if (setrlimit(RLIMIT_MEMLOCK, &amp;r)) {
		perror("setrlimit(RLIMIT_MEMLOCK, RLIM_INFINITY)");
		return 1;
	}

	snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
	// 打开编译好的eBPF程序，tracex4_kern.o
	obj = bpf_object__open_file(filename, NULL);
	if (libbpf_get_error(obj)) {
		fprintf(stderr, "ERROR: opening BPF object file failed\n");
		return 0;
	}

	// 载入eBPF程序
	// 调用之后，定义在eBPF中的探针被添加到/sys/kernel/debug/tracing/kprobe_events
	//   cat /sys/kernel/debug/tracing/kprobe_events
	//   p:kprobes/kmem_cache_free kmem_cache_free
	//   r:kprobes/kmem_cache_alloc_node kmem_cache_alloc_node
	if (bpf_object__load(obj)) {
		fprintf(stderr, "ERROR: loading BPF object file failed\n");
		goto cleanup;
	}

	// 找到共享的Map
	map_fd = bpf_object__find_map_fd_by_name(obj, "my_map");
	if (map_fd &lt; 0) {
		fprintf(stderr, "ERROR: finding a map in obj file failed\n");
		goto cleanup;
	}

	// 执行挂钩
	bpf_object__for_each_program(prog, obj) {
		links[j] = bpf_program__attach(prog);
		if (libbpf_get_error(links[j])) {
			fprintf(stderr, "ERROR: bpf_program__attach failed\n");
			links[j] = NULL;
			// 如果出错，解除所有已经注册的钩子
			goto cleanup;
		}
		j++;
	}

	// 读取Map并打印
	for (i = 0; ; i++) {
		print_old_objects(map_fd);
		sleep(1);
	}

cleanup:
	for (j--; j &gt;= 0; j--)
		bpf_link__destroy(links[j]);

	bpf_object__close(obj);
	return 0;
}</pre>
<div class="blog_h2"><span class="graybg">XDP样例程序</span></div>
<div class="blog_h3"><span class="graybg">过滤IPv6</span></div>
<p>这个简单的、可以和iproute2一起工作的XDP样例，判断封包是否是IPv6的，如果是，则丢弃：</p>
<pre class="crayon-plain-tag">#define KBUILD_MODNAME "xdp_ipv6_filter"
#include &lt;uapi/linux/bpf.h&gt;
#include &lt;uapi/linux/if_ether.h&gt;
#include &lt;uapi/linux/if_packet.h&gt;
#include &lt;uapi/linux/if_vlan.h&gt;
#include &lt;uapi/linux/ip.h&gt;
#include &lt;uapi/linux/in.h&gt;
#include &lt;uapi/linux/tcp.h&gt;
#include "bpf_helpers.h"

#define DEBUG 1

#ifdef  DEBUG
/* Only use this for debug output. Notice output from  bpf_trace_printk()
 * end-up in /sys/kernel/debug/tracing/trace_pipe
 */
#define bpf_debug(fmt, ...)                     \
        ({                          \
            char ____fmt[] = fmt;               \
            // 这个函数打印消息到 /sys/kernel/debug/tracing/trace_pipe
            bpf_trace_printk(____fmt, sizeof(____fmt),  \
                     ##__VA_ARGS__);            \
        })
#else
#define bpf_debug(fmt, ...) { } while (0)
#endif

// 解析包头得到ethertype
static __always_inline bool parse_eth(struct ethhdr *eth, void *data_end, u16 *eth_type)
{
    u64 offset;

    offset = sizeof(*eth);
    if ((void *)eth + offset &gt; data_end)
        return false;
    *eth_type = eth-&gt;h_proto;
    return true;
}

// 定义一个名为prog的段
// 由于我们使用iproute2来挂钩，因此必须用prog这个段名。否则iproute2无法识别
SEC("prog")
// xdp_md结构包含访问封包所需的所有数据
int xdp_ipv6_filter_program(struct xdp_md *ctx)
{
    // 得到封包头尾指针
    void *data_end = (void *)(long)ctx-&gt;data_end;
    void *data     = (void *)(long)ctx-&gt;data;
    struct ethhdr *eth = data;
    u16 eth_type = 0;
    // 调用parse_eth函数，可以获取封包的ethertype
    if (!(parse_eth(eth, data_end, eth_type))) {
        bpf_debug("Debug: Cannot parse L2\n");
        return XDP_PASS;
    }

    bpf_debug("Debug: eth_type:0x%x\n", ntohs(eth_type));
    // 判断ethertype是否IPv6
    if (eth_type == ntohs(0x86dd)) {
        return XDP_PASS;
    } else {
        return XDP_DROP;
    }
}

char _license[] SEC("license") = "GPL";</pre>
<p>编译此程序后得到xdp_ipv6_filter.o文件。要将此文件加载到网络接口，可以使用两种方法：</p>
<ol>
<li>编写一个用户空间程序，加载对象文件，挂钩到一个网络接口</li>
<li>调用iproute2进行等价操作</li>
</ol>
<p>某些网络接口在驱动层支持XDP，包括ixgbe, i40e, mlx5, veth, tap, tun, virtio_net等。这种情况下，XDP钩子实现在网络栈的最底部，即NIC从Rx Ring中接收到封包之后，具有最好的性能。对于其它类型的网络接口，XDP钩子则在更高的层次实现，性能相对较差。</p>
<p>我们这里用一对veth来测试：</p>
<pre class="crayon-plain-tag">sudo ip link add dev veth0 type veth peer name veth1
sudo ip link set up dev veth0
sudo ip link set up dev veth1

# 挂钩eBPF程序到接口上，程序必须写在prog段中
sudo ip link set dev veth1 xdp object xdp_ipv6_filter.o

# 查看接口信息
sudo ip link show veth1
8: veth1@veth0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 xdp qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 32:05:fc:9a:d8:75 brd ff:ff:ff:ff:ff:ff
    # 可以看到xdp标记
    prog/xdp id 32 tag bdb81fb6a5cf3154 jited</pre>
<p>为了验证eBPF程序能正常工作，我们通过tcpdump回放<a href="https://github.com/dpino/xdp_ipv6_filter/blob/master/ipv4-and-ipv6-data.pcap">一段录制的封包</a>：</p>
<pre class="crayon-plain-tag"># 在veth1上录制IPv6封包，如果程序正常工作，应该接收不到
sudo tcpdump "ip6" -i veth1 -w captured.pcap -c 10
tcpdump: listening on veth1, link-type EN10MB (Ethernet), capture size 262144 bytes
# 预期结果：
# 10 packets captured
# 10 packets received by filter
# 0 packets dropped by kernel


# 将IPv4/IPv6混合流量（此pcap中包含10个IPv4，10个IPv6）回放到veth0
# q由于eBPF程序的存在IPv6的封包应该不会发到veth1
sudo tcpreplay -i veth0 ipv4-and-ipv6-data.pcap</pre>
<p>在/sys/kernel/debug/tracing/trace_pipe 中可以看到日志：</p>
<pre class="crayon-plain-tag">sudo cat /sys/kernel/debug/tracing/trace_pipe
tcpreplay-4496  [003] ..s1 15472.046835: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046847: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046855: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046862: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046869: 0: Debug: eth_type:0x86dd
tcpreplay-4496  [003] ..s1 15472.046878: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046885: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046892: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046903: 0: Debug: eth_type:0x800
tcpreplay-4496  [003] ..s1 15472.046911: 0: Debug: eth_type:0x800
...</pre>
<div class="blog_h1"><span class="graybg">BCC</span></div>
<div class="blog_h2"><span class="graybg">转换为CO-RE</span></div>
<p>BCC提供了开发可移植的BPF程序的一套方法，但是比起新出现的BPF CO-RE有不少缺点，建议切换为BPF CO-RE。</p>
<div class="blog_h3"><span class="graybg">过渡</span></div>
<p>如果你需要同时支持BCC和CO-RE，可以利用<pre class="crayon-plain-tag">BCC_SEC</pre>宏，对于BCC来说，它会自动定义：</p>
<pre class="crayon-plain-tag">#ifdef BCC_SEC
#define __BCC__
#endif

#ifdef __BCC__
/* BCC-specific code */
#else
/* libbpf-specific code */
#endif</pre>
<div class="blog_h3"><span class="graybg">头文件</span></div>
<p>使用BPF CO-RE时，你不需要包含任何内核头文件（<pre class="crayon-plain-tag">#include &lt;linux/whatever.h&gt;</pre>），而仅仅需要包含一个<pre class="crayon-plain-tag">vmlinux.h</pre>即可。</p>
<pre class="crayon-plain-tag">#ifdef __BCC__
/* linux headers needed for BCC only */
#else /* __BCC__ */
#include "vmlinux.h"               /* all kernel types */
#include &lt;bpf/bpf_helpers.h&gt;       /* most used helpers: SEC, __always_inline, etc */
#include &lt;bpf/bpf_core_read.h&gt;     /* for BPF CO-RE helpers */
#include &lt;bpf/bpf_tracing.h&gt;       /* for getting kprobe arguments */
#endif /* __BCC__ */</pre>
<p>由于vmlinux.h中可能缺少一部分内核通过#define定义的常量，因此你可能需要重新声明他们，这些变量中最常用的，已经声明在<pre class="crayon-plain-tag">bpf_helpers.h</pre>中了。</p>
<div class="blog_h3"><span class="graybg"> 字段访问</span></div>
<p>BCC会自动将<pre class="crayon-plain-tag">tsk-&gt;parent-&gt;pid</pre>这样的point chasing转换为bpf_probe_read()调用，CO-RE则没有这么好用功能，你需要使用<pre class="crayon-plain-tag">bpf_core_read.h</pre>中定义的助手函数/宏，并将上述表达式改写为<pre class="crayon-plain-tag">BPF_CORE_READ(tsk, parent, pid)</pre>形式。</p>
<p>从5.5+开始，<pre class="crayon-plain-tag">tp_btf</pre>和<pre class="crayon-plain-tag">fentry</pre>/<pre class="crayon-plain-tag">fext</pre>类型的BPF程序类型，可以使用原生的C指针访问语法。考虑你的目标环境。</p>
<p><pre class="crayon-plain-tag">BPF_CORE_READ</pre>和BCC兼容，也就是说，你可以统一使用该宏，避免重复代码。</p>
<div class="blog_h3"><span class="graybg">声明BPF Maps</span></div>
<p>下面给出BCC和libbpf声明Map语法的对比：</p>
<pre class="crayon-plain-tag">/* Array */
#ifdef __BCC__
BPF_ARRAY(my_array_map, struct my_value, 128);
#else
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 128);
    __type(key, u32);
    __type(value, struct my_value);
} my_array_map SEC(".maps");
#endif

/* Hashmap */
#ifdef __BCC__
BPF_HASH(my_hash_map, u32, struct my_value);
#else
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, struct my_value);
} my_hash_map SEC(".maps")
#endif

/* per-CPU array */
#ifdef __BCC__
BPF_PERCPU_ARRAY(heap, struct my_value, 1);
#else
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, struct my_value);
} heap SEC(".maps");
#endif</pre>
<p>注意：BCC中的Map默认大小一般是10240，使用libbpf时则需要精确的设置。</p>
<p>PERF_EVENT_ARRAY、STACK_TRACE以及一些其它的特殊Map（DEVMAP、CPUMAP）尚不支持BTF类型的键值，需要直接指定key_size/value_siz：</p>
<pre class="crayon-plain-tag">/* Perf event array (for use with perf_buffer API) */
#ifdef __BCC__
BPF_PERF_OUTPUT(events);
#else
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");
#endif</pre>
<div class="blog_h3"><span class="graybg">访问BPF Maps</span></div>
<p>BCC使用伪C++风格的语法，这些语法会自动被改写为BPF助手函数调用。使用CO-RE时候，你需要将：</p>
<pre class="crayon-plain-tag">some_map.operation(some, args)</pre>
<p>改写为：</p>
<pre class="crayon-plain-tag">bpf_map_operation_elem(&amp;some_map, some, args);</pre>
<p>下面是一些例子：</p>
<pre class="crayon-plain-tag">#ifdef __BCC__
    struct event *data = heap.lookup(&amp;zero);
#else
    struct event *data = bpf_map_lookup_elem(&amp;heap, &amp;zero);
#endif

#ifdef __BCC__
    my_hash_map.update(&amp;id, my_val);
#else
    bpf_map_update_elem(&amp;my_hash_map, &amp;id, &amp;my_val, 0 /* flags */);
#endif

#ifdef __BCC__
    events.perf_submit(args, data, data_len);
#else
    bpf_perf_event_output(args, &amp;events, BPF_F_CURRENT_CPU, data, data_len);
#endif</pre>
<div class="blog_h3"><span class="graybg">注册BPF Program</span></div>
<p>使用CO-RE时必须用<pre class="crayon-plain-tag">SEC()</pre>宏标记满足<a href="https://github.com/libbpf/libbpf/blob/787abf721ec8fac1a4a0a7b075acc79a927afed9/src/libbpf.c#L7935-L8075">约定</a>的段名：</p>
<pre class="crayon-plain-tag">#if !defined(__BCC__)
SEC("tracepoint/sched/sched_process_exec")
#endif
int tracepoint__sched__sched_process_exec(
#ifdef __BCC__
    struct tracepoint__sched__sched_process_exec *args
#else
    struct trace_event_raw_sched_process_exec *args
#endif
) {
/* ... */
}</pre>
<p>常用的约定例如：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">tp/&lt;category&gt;/&lt;name&gt;</pre> 用于tracepoint</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">kprobe/&lt;func_name&gt;</pre> 用于kprobe；<pre class="crayon-plain-tag">kretprobe/&lt;func_name&gt;</pre> 用于kretprobe</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">raw_tp/&lt;name&gt;</pre> 用于raw tracepoint</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">cgroup_skb/ingress</pre>, <pre class="crayon-plain-tag">cgroup_skb/egress</pre>, <pre class="crayon-plain-tag">cgroup/&lt;subtype&gt;</pre></p>
<div class="blog_h3"><span class="graybg">关于tracepoint</span></div>
<p>BCC和libbpf关于tracepoint上下文类型的命名有所不同。BCC的格式是<pre class="crayon-plain-tag">tracepoint__&lt;category&gt;__&lt;name&gt;</pre>，BCC会在运行时编译的时候，自动生成对应的C类型。</p>
<p>libbpf则没有这种自动生成类型的能力，不过内核已经提供了类似的，包含了所有tracepoint数据的类型，其命名为<pre class="crayon-plain-tag">trace_event_raw_&lt;name&gt;</pre>。</p>
<p>某些内核中的tracepoint会复用公共类型，因此上述类型对应关系不一定可用。例如没有<pre class="crayon-plain-tag">trace_event_raw_sched_process_exit</pre>，你需要使用<pre class="crayon-plain-tag">trace_event_raw_sched_process_template</pre>，具体需要关注内核源码或者vmlinux.h。</p>
<p>BCC和libbpf访问tracepoint上下文大部分字段，用同样的字段名。除了一些可变长度字符串字段。BBC是<pre class="crayon-plain-tag">data_loc_&lt;some_field&gt;</pre> 而libbpf是 <pre class="crayon-plain-tag">__data_loc_&lt;some_field&gt;</pre>格式。</p>
<div class="blog_h3"><span class="graybg">关于kprobe</span></div>
<p>Kprobe BPF程序以一个<pre class="crayon-plain-tag">struct pt_regs</pre>作为上下文参数，BCC支持直接声明为函数参数，libbpf则需要借助<pre class="crayon-plain-tag">BPF_KPROBE</pre> / <pre class="crayon-plain-tag">BPF_KRETPROBE</pre>宏：</p>
<pre class="crayon-plain-tag">#ifdef __BCC__
int kprobe__acct_collect(struct pt_regs *ctx, long exit_code, int group_dead)
#else
SEC("kprobe/acct_collect")
int BPF_KPROBE(kprobe__acct_collect, long exit_code, int group_dead)
#endif
{
    // 对于libbpf，在这里也可以访问名为ctx的pt_regs*
}</pre>
<p>另外一个需要注意的点，在4.17+，系统调用函数发生了重命名，例如<pre class="crayon-plain-tag">sys_kill</pre>被重命名为<pre class="crayon-plain-tag">__x64_sys_kill</pre>（对于x86，其它平台下有不同的前缀）。使用kprobe时需要注意到这种变化，如果可能，尽量使用tracepoint。</p>
<p>从5.5+开始，可以考虑用<pre class="crayon-plain-tag">raw_tp</pre>/<pre class="crayon-plain-tag">fentry</pre>/<pre class="crayon-plain-tag">fexit</pre>代替<pre class="crayon-plain-tag">tracepoint</pre>/<pre class="crayon-plain-tag">kprobe</pre>/<pre class="crayon-plain-tag">kretprobe</pre>。这些新探针具有更好的性能、易用性。</p>
<div class="blog_h3"><span class="graybg">处理编译时#if</span></div>
<p>在BCC代码中依赖预处理器（#ifdef 或者 #if）是流行的做法，原因通常是为了适配不同版本的内核、根据程序配置启用/禁用代码片段。CO-RE提供类似的能力：Kconfig externs和struct flavors。</p>
<div class="blog_h1"><span class="graybg">附录</span></div>
<div class="blog_h2"><span class="graybg">相关项目</span></div>
<div class="blog_h3"><span class="graybg">BCC</span></div>
<p>参考：<a href="/bcc">基于BCC进行性能追踪和网络监控</a></p>
<p>BCC（BPF Compiler Collection，BPF编译器集合）是一个工具箱，它提供了很多追踪用途的BPF程序，加载这些程序时使用BCC提供的Python接口。</p>
<div class="blog_h3"><span class="graybg">bpftrace</span></div>
<p>一个DTrace风格的动态追踪工具，使用LLVM作为后端，将脚本编译为BPF字节码，并使用BCC和内核的BPF tracing基础设施进行交互。和原生BCC相比，它提供了更加高级的、用于实现追踪脚本的语言。</p>
<div class="blog_h3"><span class="graybg">perf</span></div>
<p>参考：<a href="/perf">利用perf剖析Linux应用程序</a></p>
<p>perf_events是内核2.6+的一部分，用户空间工具perf在包linux-tools-common中。它有三种工作模式，其中最新的是4.4引入的BPF模式，支持载入tracing BPF程序。</p>
<div class="blog_h2"><span class="graybg">参考</span></div>
<ol>
<li><a href="https://qmonnet.github.io/whirl-offload/2016/09/01/dive-into-bpf/">Dive into BPF: a list of reading material</a></li>
<li><a href="https://docs.cilium.io/en/v1.9/bpf">https://docs.cilium.io/en/v1.9/bpf</a></li>
<li><a href="https://github.com/iovisor/bpf-docs/blob/master/bpf_helpers.rst">list of eBPF helper functions</a></li>
</ol>
<p>&nbsp;</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/ebpf">eBPF学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/ebpf/feed</wfw:commentRss>
		<slash:comments>4</slash:comments>
		</item>
	</channel>
</rss>
