<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ai on Wilson Wu</title><link>https://wilsonwu.me/zh/tags/ai/</link><description>Recent content in Ai on Wilson Wu</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Wed, 14 Oct 2026 00:00:00 -0400</lastBuildDate><atom:link href="https://wilsonwu.me/zh/tags/ai/index.xml" rel="self" type="application/rss+xml"/><item><title>训练一个自己的 Jev：决策模型的原理、数据、算力、训练与评测全流程</title><link>https://wilsonwu.me/zh/blog/2026/train-your-own-jev/</link><pubDate>Wed, 14 Oct 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/train-your-own-jev/</guid><description>&lt;p&gt;&lt;img alt="训练一个自己的 Jev" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/train-your-own-jev/1-train-your-own-jev.png"&gt;&lt;/p&gt;
&lt;h2 id="引言"&gt;引言&lt;/h2&gt;
&lt;p&gt;过去一个月，我连续写了两篇决策模型的文章：&lt;a href="https://wilsonwu.me/zh/blog/2026/jev-vs-laya/"&gt;Jev vs Laya&lt;/a&gt; 对比了闭源的 Jev 和开源的 Laya，&lt;a href="https://wilsonwu.me/zh/blog/2026/microsoft-decision-1/"&gt;Microsoft-Decision-1 详解&lt;/a&gt; 又加入了微软。写完之后，被问得最多的问题是：&lt;strong&gt;能不能自己训练一个？&lt;/strong&gt;&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="训练一个自己的 Jev" loading="lazy" src="/zh/blog/2026/train-your-own-jev/1-train-your-own-jev.png"></p>
<h2 id="引言">引言</h2>
<p>过去一个月，我连续写了两篇决策模型的文章：<a href="/zh/blog/2026/jev-vs-laya/">Jev vs Laya</a> 对比了闭源的 Jev 和开源的 Laya，<a href="/zh/blog/2026/microsoft-decision-1/">Microsoft-Decision-1 详解</a> 又加入了微软。写完之后，被问得最多的问题是：<strong>能不能自己训练一个？</strong></p>
<p>答案是可以，而且门槛比想象中低。看看第三方榜单 JevBench 就知道：截至 10 月中旬，开源权重榜上已经有 160 多个参与排名的系统，绝大多数出自社区和中小团队之手。排在前面的也不神秘：综合分第一的 H2O-Lightning-4B 是在阿里开源的 Qwen3.5-4B 上微调出来的，能力分第一的 Quyet-1.0-Large 则是 Gemma-4-31B 加一个适配器；微软的 Microsoft-Decision-1，底座同样是开源的 Qwen3.5-9B。</p>
<p>这说明一件事：<strong>决策模型的核心竞争力不在模型结构，而在数据、训练目标和评测体系。</strong> 这三样东西，普通团队完全可以自己掌握。</p>
<p>本文按一个真实项目的顺序，把训练一个“自己的 Jev”的全过程走一遍：</p>
<ol>
<li>原理：决策模型到底在学什么，为什么训练目标必须是“严格适当评分规则”</li>
<li>数据：去哪里找，怎么统一格式、清洗、增强和切分</li>
<li>算力：需要多少显存、多少 GPU 小时、多少钱</li>
<li>底座：怎么选基础模型</li>
<li>训练：从零写一套可以直接运行的 LoRA 训练脚本</li>
<li>评测：用哪些指标打分，怎么和 JevBench 上的模型同场比较</li>
<li>调优：分数不理想时，按什么思路改进</li>
</ol>
<p>文中的脚本我都在 Qwen3.5-0.8B 上实际跑通过（CPU 冒烟测试，只用来验证代码正确性），文中引用的榜单和模型数据均来自公开资料，链接列在文末。</p>
<h2 id="一先想清楚决策模型到底在学什么">一、先想清楚：决策模型到底在学什么</h2>
<h3 id="1-本质是一个条件概率">1. 本质是一个“条件概率”</h3>
<p>先回顾一下决策模型的接口：调用方给出一段状态（state）和一个问题，问题的候选答案由调用方提前定义好，模型返回每个候选答案的概率。三种问题原语都可以统一成同一件事：</p>
<table>
	<thead>
			<tr>
					<th>原语</th>
					<th>候选答案</th>
					<th>本质</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>choice</code></td>
					<td>调用方定义的 K 个选项</td>
					<td>K 分类</td>
			</tr>
			<tr>
					<td><code>noul</code></td>
					<td>是 / 否</td>
					<td>2 分类</td>
			</tr>
			<tr>
					<td><code>score</code></td>
					<td>K 个有序等级</td>
					<td>有序的 K 分类</td>
			</tr>
	</tbody>
</table>
<p>所以，训练一个决策模型，就是让模型学会估计 <code>P(选项 | 状态, 问题)</code>。和普通的文本分类模型相比，它有两个关键区别：</p>
<ul>
<li><strong>类别不是固定的</strong>：每次请求的问题和选项都不一样，模型必须读懂选项的含义，而不是记住一组固定的标签。这就是它需要“零样本泛化”能力的原因。</li>
<li><strong>概率本身就是产品</strong>：下游程序会直接拿概率做 <code>if</code> 判断，所以概率必须“诚实”，也就是校准过的。这决定了训练目标的选择，后面会专门讲。</li>
</ul>
<h3 id="2-三条技术路线">2. 三条技术路线</h3>
<p>目前社区的做法大致分成三类：</p>
<table>
	<thead>
			<tr>
					<th>路线</th>
					<th>代表</th>
					<th>做法</th>
					<th>优点</th>
					<th>缺点</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>编码器 + 决策头</td>
					<td>Laya</td>
					<td>在每个选项前放一个 <code>[MASK]</code>，经过双向编码器后，取这些位置的隐状态打分</td>
					<td>极快，4 亿参数，T4 上单题 33～40 ms</td>
					<td>零样本能力弱，上下文短，必须针对任务微调</td>
			</tr>
			<tr>
					<td>解码器 + 选项字母</td>
					<td>Quyet-1.0-Large、H2O-Lightning-4B</td>
					<td>把选项编号成 A、B、C……，模型读完提示词后，直接取下一个 token 在这些字母上的概率</td>
					<td>复用大模型的知识和语言模型头，零样本能力强</td>
					<td>参数量大，选项数受单 token 标签数量限制</td>
			</tr>
			<tr>
					<td>解码器 + 决策头</td>
					<td>jiwo、ClassOne</td>
					<td>取解码器的隐状态，接一个单独训练的打分头</td>
					<td>选项数和输出形式更灵活</td>
					<td>打分头从零开始训练，更依赖数据</td>
			</tr>
	</tbody>
</table>
<p>JevBench 的数据给出了很清楚的信号：</p>
<ul>
<li>几个编码器路线的系统，零样本智能分都只有个位数（Laya 1.8、modernbert-jev v2 2.6、open-jev-deberta-v3-large 2.8），和随机猜测差不多；</li>
<li>完全不做决策微调、直接读取 Qwen3-4B-Instruct-2507 的 logits，智能分就有 34.0；</li>
<li>在 Qwen3.5-4B 上做过决策微调的 H2O-Lightning-4B 达到 60.0，接近 Jev 1.13.0 的 63.6；</li>
<li>更大的 Gemma-4-31B 底座（Quyet-1.0-Large）达到 73.4。</li>
</ul>
<p>结论很明确：<strong>如果目标是 Jev 那样的“零样本通才”，应该走解码器路线</strong>，大模型预训练时学到的知识是零样本能力的来源，决策微调则把这些知识“对齐”到选项概率上；如果只服务一两个固定任务，对延迟要求极高，而且有足够的标注数据，Laya 这样的编码器路线依然是性价比最高的选择。</p>
<p>本文的主线是第二条路线：<strong>解码器 + 选项字母</strong>。它的推理过程是这样的：</p>
<p><img alt="一次前向计算读出选项概率" loading="lazy" src="/zh/blog/2026/train-your-own-jev/2-letter-logit-readout.png"></p>
<ol>
<li>把状态、问题和带字母编号的选项拼成一段对话提示词，以助手回复的开头结束；</li>
<li>模型做一次前向计算（只有预填充，没有逐 token 生成）；</li>
<li>在最后一个位置（答案位）取出 A、B、C……这几个字母 token 的 logits，只在这 K 个 logits 上做 softmax，得到每个选项的概率。</li>
</ol>
<p>H2O-Lightning-4B 的模型卡把这概括为“一次前向，一个输出 token”（one forward pass and one output token）。因为不生成文本，成本几乎只有输入 token；又因为输出空间被限定在 K 个字母上，模型不可能答出选项以外的东西。这条路线还有一个先天优势：大模型在选择题格式下给出的字母概率，本身就有不错的校准性（Kadavath 等人，2022），决策微调是在一个好的起点上继续打磨。</p>
<h3 id="3-训练目标为什么必须是严格适当评分规则">3. 训练目标：为什么必须是“严格适当评分规则”</h3>
<p>决策模型输出的概率要直接进入程序的分支判断，所以我们要的不只是“答对”，还要<strong>校准</strong>：在所有给出 80% 把握的回答里，应该恰好有大约 80% 是对的。</p>
<p><strong>第一个坑：只奖励“答对”会毁掉校准。</strong> 假设一道题的真实分布是 p（比如 70% 选 A、30% 选 B），模型报告的分布是 q。如果把模型当成策略，按 q 抽一个答案，答对得 1 分，期望奖励就是 <code>Σ p_k · q_k</code>，它对 q 是线性的，最优解是把全部概率押在最可能的选项上，也就是让 A 的概率等于 1。模型被训练成“永远 100% 确定”，准确率没变，概率却失去了意义。</p>
<p><strong>解决办法是用严格适当评分规则（strictly proper scoring rule）做目标。</strong> 它的定义是：只有当模型报告的分布 q 等于真实分布 p 时，期望得分才能达到最大值，说真话是唯一的最优策略（经典论述见 Gneiting 和 Raftery 2007 年的论文）。常用的有四种：</p>
<pre tabindex="0"><code>记 q = (q_1, …, q_K) 为模型给出的分布，y 为标签（one-hot 或软标签）

对数评分（越大越好）     S_log(q, y) = Σ y_k · log q_k            就是负的交叉熵
Brier 分数（越小越好）   B(q, y)     = Σ (q_k − y_k)²
球面评分（越大越好）     S_sph(q, y) = Σ y_k · q_k / ‖q‖          取值 0～1，梯度比对数评分温和
排序概率分（越小越好）   RPS(q, y)   = Σ (Q_i − Y_i)² / (K − 1)   Q、Y 是累积分布，用于有序的 score
</code></pre><p>这里有一个经常被忽略的事实：<strong>交叉熵本身就是严格适当的</strong>（它就是对数评分）。那为什么用交叉熵训练出来的神经网络普遍过度自信？原因不在损失函数，而在于有限的训练数据：模型会把训练样本“背”下来，把它们的概率推向 1，到了没见过的数据上，自信程度就高于实际准确率（Guo 等人 2017 年的论文对此有系统的分析）。所以有两点要做到：</p>
<ol>
<li><strong>训练时</strong>：用严格适当评分规则做目标；标签本身有不确定性时（标注员意见不一、多个教师模型投票不一致），用软标签代替 one-hot，让模型学会“诚实地不确定”；不要训练过多 epoch。</li>
<li><strong>训练后</strong>：在留出的开发集上做<strong>温度缩放</strong>（temperature scaling），即 <code>p = softmax(z / T)</code>，按问题类型拟合一个温度 T。它不改变排序，所以不影响准确率，只修正自信程度。</li>
</ol>
<p><img alt="过度自信 vs 校准" loading="lazy" src="/zh/blog/2026/train-your-own-jev/3-calibration.png"></p>
<p>温度缩放的效果往往非常明显。Laya 的出厂检查点按“问题类型 × 选项数”重新拟合温度后，平均 ECE（期望校准误差）从 0.466 降到了 0.081。而 H2O-Lightning-4B 随模型发布的温度是 choice 0.75、noul 0.8、score 0.65，都小于 1，说明它的原始输出反而偏保守，需要“锐化”。方向因模型而异，只能实测，不能拍脑袋。</p>
<h3 id="4-rlcdlaya-公开的强化学习做法">4. RLCD：Laya 公开的强化学习做法</h3>
<p>Jev 把自己的训练方法称为 RLCD（Reinforcement Learning for Calibrated Decisions，面向校准决策的强化学习），但没有公开细节。Laya 沿用了这个名字，并公开了一个具体实现：</p>
<ul>
<li><strong>奖励</strong>：三种严格适当评分的组合，<code>Reward = S_log + 0.5 × S_sph − RPS</code>，其中 RPS 只对有序的 score 题生效；</li>
<li><strong>探索</strong>：每道题对 logits 加 8 组高斯噪声（噪声在选项维度上减去均值，因为给所有 logits 加同一个常数不会改变 softmax），噪声的标准差从 1.0 逐步衰减到 0.3；</li>
<li><strong>优势</strong>：8 个样本的奖励减去组内均值、除以组内标准差，和 GRPO 的组内基线是同一个思路（GRPO 的原理可以参考我之前写的 <a href="/zh/blog/2026/ppo-vs-grpo/">PPO vs GRPO</a>）；</li>
<li><strong>更新</strong>：用高斯分布的对数密度做 REINFORCE 式的策略梯度，完全不使用交叉熵损失。</li>
</ul>
<p>我的理解是：<strong>当模型直接输出完整的概率分布时，评分规则本身就是可微的，直接对它求梯度（也就是把评分规则当损失函数）和用强化学习去估计这个梯度，在期望上指向同一个方向，前者方差更小、更省事。</strong> 强化学习的形式真正不可替代的场景，是奖励无法对输出直接求导的时候，比如奖励来自多步 Agent 任务最终是否成功、用户的真实反馈，或者 Laya 处理的多轮销售对话：奖励是对话最终是否成交，需要用 TD(λ) 把这个结果分摊到每一轮。</p>
<p>为了验证这个判断，我用后文训练脚本里的 <code>reward()</code> 函数做了两个小实验：一是把目标分布设为 [0.7, 0.2, 0.1]，直接对组合评分做梯度上升，模型输出的分布最终收敛到 [0.7, 0.2, 0.1]，说明这个组合评分确实“只奖励说真话”；二是对同一组 logits，用 RLCD 的方式（2 万组扰动，σ = 0.05）估计梯度，它和直接求导得到的梯度方向几乎完全一致（余弦相似度 0.999）。</p>
<p>所以本文的训练脚本两种都支持：默认直接优化评分规则（<code>--objective proper</code>），也可以切换成 Laya 式的 RLCD（<code>--objective rlcd</code>），自己对比。</p>
<h3 id="5-从概率到动作阈值由代价决定">5. 从概率到动作：阈值由代价决定</h3>
<p>概率校准好之后，“什么时候自动执行、什么时候转人工”就不必拍脑袋了。Laya 给它的 Act / Escalate 头设计了一个代价矩阵：判对 +1，判错 −3，转人工 −0.5。自动执行的期望收益是 <code>p × 1 + (1 − p) × (−3)</code>，它大于 −0.5 的条件是 <code>p &gt; 0.625</code>。一般地：</p>
<pre tabindex="0"><code>自动执行的阈值 = (转人工的收益 − 判错的收益) / (判对的收益 − 判错的收益)
              = (−0.5 − (−3)) / (1 − (−3)) = 0.625
</code></pre><p>只要概率是校准的，这个阈值可以直接从业务代价算出来，单独训练一个“该不该执行”的头反而不是必需的（Laya 的 README 也坦承它的 Act 头目前几乎没有信号，建议直接用置信度做门控）。H2O-Lightning-4B 在 3474 道留出题上给出了阈值的实际效果：</p>
<table>
	<thead>
			<tr>
					<th>置信度阈值</th>
					<th>转交比例</th>
					<th>其余题目的准确率</th>
					<th>放行但答错（占全部题目）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>不设阈值</td>
					<td>0%</td>
					<td>约 83%</td>
					<td>约 17%</td>
			</tr>
			<tr>
					<td>0.8</td>
					<td>37.7%</td>
					<td>95.8%</td>
					<td>2.6%</td>
			</tr>
			<tr>
					<td>0.9</td>
					<td>53.1%</td>
					<td>98.5%</td>
					<td>0.7%</td>
			</tr>
	</tbody>
</table>
<p>这张表就是你训练完之后要为自己的模型画出来的东西。</p>
<h3 id="6-全流程一览">6. 全流程一览</h3>
<p>把上面的原理落到工程上，一个完整的训练项目大致是这样的循环：</p>
<pre tabindex="0"><code>① 定义题目和验收标准：指标、阈值、上线门槛，先写下来再看结果
        │
② 找数据 → 统一格式 → 去重、去污染 → 按任务切分
        │
③ 零样本基线：直接读底座模型的选项字母概率，先知道起点在哪里
        │
④ LoRA 微调：选项随机乱序 + 严格适当评分规则（或 RLCD）
        │
⑤ 在开发集上拟合温度，在留出任务、公开集和私有封存集上打分
        │
⑥ 按切片做误差分析 → 补数据 → 回到 ④
        │
⑦ 合并权重，接入 /v1/systemone 接口，灰度上线，持续监控
</code></pre><p>接下来按这个顺序逐一展开。</p>
<h2 id="二数据从哪里找怎么准备">二、数据：从哪里找，怎么准备</h2>
<p>数据决定了决策模型的上限。H2O-Lightning-4B 在模型卡里总结的几条经验，几乎都和数据有关：留一份从不参与训练的封存评测集、上线标准先写下来再看结果、训练数据和所有评测集做精确和近似去重、按主题和题型找短板再补数据、标签只来自人工标注或多个独立强模型的一致意见。</p>
<h3 id="1-数据从哪里来">1. 数据从哪里来</h3>
<table>
	<thead>
			<tr>
					<th>来源</th>
					<th>例子</th>
					<th>优点</th>
					<th>注意事项</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>公开的人工标注数据集</td>
					<td>意图：Banking77、CLINC150、MASSIVE（51 种语言）；主题：AG News、TNEWS；推理：BoolQ、MNLI、XNLI、OCNLI；情感与打分：SST-5、Yelp 评论星级、HelpSteer2；安全：毒性检测、提示词注入</td>
					<td>标签可靠，数量大，覆盖面广</td>
					<td>逐一核对许可证，不少学术数据集只允许研究用途；格式各异，需要改写成决策题</td>
			</tr>
			<tr>
					<td>自己的业务数据</td>
					<td>工单、邮件、审核记录、Agent 执行日志，加上人工复核的结论</td>
					<td>和线上分布一致，价值最高</td>
					<td>脱敏；早期标签可能有噪声，需要抽检</td>
			</tr>
			<tr>
					<td>合成数据</td>
					<td>用开源大模型批量生成状态和问题，再由多个独立的模型分别作答，只保留意见一致的样本，或把投票比例作为软标签</td>
					<td>能快速补齐长尾场景和短板</td>
					<td>只用一个模型打标，学到的就是那个模型的错误；部分商业 API 的条款限制用其输出训练竞争模型，使用前务必确认</td>
			</tr>
			<tr>
					<td>程序生成的可验证数据</td>
					<td>日期计算、计数、按规则判断（如“金额超过阈值且无审批则拒绝”）、游戏和模拟中的状态追踪</td>
					<td>标签由代码算出，100% 正确，数量不受限</td>
					<td>题目容易模板化，要在措辞和结构上多做变化</td>
			</tr>
	</tbody>
</table>
<p>几个经验值得参考：</p>
<ul>
<li><strong>Laya 坚持“零合成”</strong>：训练数据全部来自人工标注的公开数据集（客服意图、自然语言推理、内容安全、越狱与注入、评分细则打分、多轮对话结果等），理由是用模型生成的标签训练校准模型，只会把模型校准到“老师”的错误上。</li>
<li><strong>H2O-Lightning-4B 的数据配比</strong>：按场景分，文档、政策与规则 37%，数字、日期与概率 22%，游戏与模拟 12%，日常语言与知识 10%，客服与运营 10%，安全与护栏 7%，代码与工具调用 2%；按题型分，choice 54%、noul 29%、score 16%；超过 99% 是英文。训练分三个阶段：先广泛覆盖，再集中强化长文档和数字推理，最后把所有类型重新混合。</li>
<li><strong>任务的多样性比单个任务的样本量更重要</strong>：FLAN 系列研究发现，指令微调时覆盖的任务种类越多，模型在没见过的任务上表现越好，这一点对决策模型同样成立。在总量相同的情况下，覆盖更多不同的任务，通常比在少数几个任务上堆样本更有利于零样本泛化。</li>
</ul>
<p>如果只是做某个垂直领域的专用模型，几千到几万条高质量标注就能有明显提升：Laya 的 typed-decisions 检查点用约 3 万道题微调，准确率从 0.362 提升到 0.766；社区有人用单张 16 GB 显卡把 Laya 微调成浏览器 Agent 的操作决策器，约 45 个候选元素中选对目标的比例从 0.10 提升到 0.66。</p>
<h3 id="2-统一的数据格式">2. 统一的数据格式</h3>
<p>建议直接沿用 Jev 的请求格式，每行一道决策题：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;ticket_routing&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: <span style="color:#e6db74">&#34;Checkout returns HTTP 500 for every customer since 09:10.&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Which team should handle this ticket?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: {<span style="color:#f92672">&#34;billing&#34;</span>: <span style="color:#e6db74">&#34;Charges, invoices and refunds&#34;</span>,
</span></span><span style="display:flex;"><span>                           <span style="color:#f92672">&#34;engineering&#34;</span>: <span style="color:#e6db74">&#34;Bugs, errors and outages&#34;</span>,
</span></span><span style="display:flex;"><span>                           <span style="color:#f92672">&#34;support&#34;</span>: <span style="color:#e6db74">&#34;How-to and account questions&#34;</span>}},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;answer&#34;</span>: <span style="color:#e6db74">&#34;engineering&#34;</span>}
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;refund_check&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: {<span style="color:#f92672">&#34;subject&#34;</span>: <span style="color:#e6db74">&#34;Charged twice&#34;</span>, <span style="color:#f92672">&#34;body&#34;</span>: <span style="color:#e6db74">&#34;Please refund the duplicate.&#34;</span>},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Does the customer ask for a refund?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: {<span style="color:#f92672">&#34;true&#34;</span>: <span style="color:#e6db74">&#34;yes, a refund is requested&#34;</span>, <span style="color:#f92672">&#34;false&#34;</span>: <span style="color:#e6db74">&#34;no refund is requested&#34;</span>}},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;answer&#34;</span>: <span style="color:#66d9ef">true</span>}
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;urgency&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: <span style="color:#e6db74">&#34;Payroll export fails and payroll runs today.&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;How urgent is this request?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: [<span style="color:#e6db74">&#34;not urgent&#34;</span>, <span style="color:#e6db74">&#34;soon&#34;</span>, <span style="color:#e6db74">&#34;today&#34;</span>, <span style="color:#e6db74">&#34;blocking right now&#34;</span>]},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;answer&#34;</span>: <span style="color:#ae81ff">2</span>}
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;intent_soft&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: <span style="color:#e6db74">&#34;I was billed twice and the app keeps crashing.&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;What is the main topic?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: {<span style="color:#f92672">&#34;billing&#34;</span>: <span style="color:#e6db74">&#34;payments&#34;</span>, <span style="color:#f92672">&#34;technical&#34;</span>: <span style="color:#e6db74">&#34;bugs&#34;</span>}},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;target&#34;</span>: {<span style="color:#f92672">&#34;billing&#34;</span>: <span style="color:#ae81ff">0.6</span>, <span style="color:#f92672">&#34;technical&#34;</span>: <span style="color:#ae81ff">0.4</span>}}
</span></span></code></pre></div><p>几点约定：</p>
<ul>
<li>为了便于阅读，上面的示例做了换行，实际文件中每条记录占一行（JSONL）；</li>
<li><code>answer</code> 对 choice 是选项名，对 noul 是布尔值，对 score 是等级下标；有不确定性的样本用 <code>target</code> 给出软标签；</li>
<li><code>task</code> 字段记录样本来自哪个任务，后面切分和按切片分析都要用到；</li>
<li>这个格式和 JevBench 公开题集的格式几乎一致（它用 <code>expected</code> 字段存答案），后面的评测脚本可以直接读取 JevBench 的公开题。</li>
</ul>
<h3 id="3-清洗与增强">3. 清洗与增强</h3>
<p><strong>清洗</strong>要做三件事：</p>
<ol>
<li><strong>去重</strong>：先做规范化后的精确去重，再用 MinHash 之类的方法做近似去重，避免同一条数据换个标点就出现好几遍；</li>
<li><strong>去污染</strong>：把所有评测集（自己的封存集、JevBench 公开题等）都和训练集做比对，有重叠的从训练集里删掉。JevBench 要求在公开题上训练过的模型必须主动声明，而且公开题成绩明显高于封存题的模型还会被扣分，“刷”公开题没有意义；</li>
<li><strong>配平</strong>：给每个任务设上限，防止某个大数据集主导训练；同时控制题型、语言、选项数的分布。</li>
</ol>
<p><strong>增强</strong>的目标是让模型学会“看内容，而不是看形式”：</p>
<table>
	<thead>
			<tr>
					<th>增强方式</th>
					<th>要防止的问题</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>每次训练都随机打乱选项顺序</td>
					<td>位置偏差：Laya 实测，5 个文字完全相同的选项，仅仅因为位置不同，logit 就从 +1.68 一路降到 −1.91</td>
			</tr>
			<tr>
					<td>改写问题的措辞，选项名在有语义的词和中性标签之间切换</td>
					<td>只认关键词，或被 <code>yes</code>、<code>true</code> 之类的选项名带偏</td>
			</tr>
			<tr>
					<td>state 在纯文本和嵌套 JSON 之间切换，字段名同义替换</td>
					<td>只认识某一种输入格式</td>
			</tr>
			<tr>
					<td>在 state 里加入无关字段和干扰段落</td>
					<td>被无关内容稀释注意力</td>
			</tr>
			<tr>
					<td>加入“无法判断”选项，并构造信息不足的样本</td>
					<td>信息不够时也被迫硬选</td>
			</tr>
			<tr>
					<td>构造否定问题对：同一状态分别问 Q 和“非 Q”</td>
					<td>两个概率之和不等于 1</td>
			</tr>
			<tr>
					<td>在 state 中插入提示词注入文本，标签保持不变</td>
					<td>被 state 里的指令劫持</td>
			</tr>
			<tr>
					<td>从大量选项中随机抽取“正确答案 + 若干干扰项”</td>
					<td>选项数量单一，也解决了选项数超过字母数的问题</td>
			</tr>
	</tbody>
</table>
<p>要注意顺序：<strong>先切分，再增强</strong>，而且增强只作用于训练集，否则同一道题的不同变体会同时出现在训练集和评测集里，评测分数就虚高了。</p>
<p><img alt="数据流水线" loading="lazy" src="/zh/blog/2026/train-your-own-jev/4-data-pipeline.png"></p>
<h3 id="4-切分一定要按任务留出">4. 切分：一定要按“任务”留出</h3>
<p>决策模型最重要的能力是泛化到没见过的问题，所以评测集要分层次：</p>
<table>
	<thead>
			<tr>
					<th>评测集</th>
					<th>怎么来</th>
					<th>回答的问题</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>dev（开发集）</td>
					<td>训练任务中随机留出 5% 左右</td>
					<td>训练是否收敛，拟合温度</td>
			</tr>
			<tr>
					<td>held-out tasks（留出任务）</td>
					<td>整个任务都不参与训练</td>
					<td>零样本泛化能力，最接近 Jev 的使用方式</td>
			</tr>
			<tr>
					<td>公开基准</td>
					<td>JevBench 公开的 231 道题</td>
					<td>和其他模型同场比较</td>
			</tr>
			<tr>
					<td>lockbox（封存集）</td>
					<td>自己写的、从不参与任何调参的题</td>
					<td>最终上线前的一次性验收，防止对评测集过拟合</td>
			</tr>
	</tbody>
</table>
<p>真实项目里，每种题型下面都应该有多个任务，留出整个任务之后，训练集里仍然有同题型的其他任务。</p>
<p>下面是一个最小的数据准备脚本，把 Banking77（意图，choice）、BoolQ（是否题，noul）、SST-5（情感，score）和 OCNLI（中文推理，choice）四个公开数据集改写成决策题，完成去重、去污染和按任务切分：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># prepare_data.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Turn a few public datasets into decision records, remove duplicates and eval overlap, split by task.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> hashlib
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> random
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pathlib <span style="color:#f92672">import</span> Path
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> datasets <span style="color:#f92672">import</span> load_dataset
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>random<span style="color:#f92672">.</span>seed(<span style="color:#ae81ff">13</span>)
</span></span><span style="display:flex;"><span>SST5_LEVELS <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#34;very negative&#34;</span>, <span style="color:#e6db74">&#34;negative&#34;</span>, <span style="color:#e6db74">&#34;neutral&#34;</span>, <span style="color:#e6db74">&#34;positive&#34;</span>, <span style="color:#e6db74">&#34;very positive&#34;</span>]
</span></span><span style="display:flex;"><span>OCNLI <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;entailment&#34;</span>: <span style="color:#e6db74">&#34;前提为真时，假设一定为真&#34;</span>,
</span></span><span style="display:flex;"><span>         <span style="color:#e6db74">&#34;neutral&#34;</span>: <span style="color:#e6db74">&#34;前提为真时，假设可能为真也可能为假&#34;</span>,
</span></span><span style="display:flex;"><span>         <span style="color:#e6db74">&#34;contradiction&#34;</span>: <span style="color:#e6db74">&#34;前提为真时，假设一定为假&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">banking77</span>():
</span></span><span style="display:flex;"><span>    ds <span style="color:#f92672">=</span> load_dataset(<span style="color:#e6db74">&#34;mteb/banking77&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>)
</span></span><span style="display:flex;"><span>    intents <span style="color:#f92672">=</span> sorted(set(ds[<span style="color:#e6db74">&#34;label_text&#34;</span>]))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> ds:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 77 intents do not fit 26 letters: show the gold intent among 9 random distractors</span>
</span></span><span style="display:flex;"><span>        shown <span style="color:#f92672">=</span> random<span style="color:#f92672">.</span>sample([i <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> intents <span style="color:#66d9ef">if</span> i <span style="color:#f92672">!=</span> row[<span style="color:#e6db74">&#34;label_text&#34;</span>]], <span style="color:#ae81ff">9</span>) <span style="color:#f92672">+</span> [row[<span style="color:#e6db74">&#34;label_text&#34;</span>]]
</span></span><span style="display:flex;"><span>        random<span style="color:#f92672">.</span>shuffle(shown)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;banking77&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: row[<span style="color:#e6db74">&#34;text&#34;</span>],
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Which banking intent does the customer&#39;s message express?&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;criteria&#34;</span>: {i: i<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;_&#34;</span>, <span style="color:#e6db74">&#34; &#34;</span>) <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> shown}},
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;answer&#34;</span>: row[<span style="color:#e6db74">&#34;label_text&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">boolq</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> load_dataset(<span style="color:#e6db74">&#34;google/boolq&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;boolq&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: row[<span style="color:#e6db74">&#34;passage&#34;</span>],
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;instructions&#34;</span>: row[<span style="color:#e6db74">&#34;question&#34;</span>]<span style="color:#f92672">.</span>capitalize() <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;?&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;criteria&#34;</span>: {<span style="color:#e6db74">&#34;true&#34;</span>: <span style="color:#e6db74">&#34;yes, according to the passage&#34;</span>,
</span></span><span style="display:flex;"><span>                                         <span style="color:#e6db74">&#34;false&#34;</span>: <span style="color:#e6db74">&#34;no, according to the passage&#34;</span>}},
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;answer&#34;</span>: row[<span style="color:#e6db74">&#34;answer&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">sst5</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> load_dataset(<span style="color:#e6db74">&#34;SetFit/sst5&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;sst5&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: row[<span style="color:#e6db74">&#34;text&#34;</span>],
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>, <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;How positive is this movie review?&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;criteria&#34;</span>: SST5_LEVELS},
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;answer&#34;</span>: row[<span style="color:#e6db74">&#34;label&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">ocnli</span>():
</span></span><span style="display:flex;"><span>    ds <span style="color:#f92672">=</span> load_dataset(<span style="color:#e6db74">&#34;clue/clue&#34;</span>, <span style="color:#e6db74">&#34;ocnli&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>)
</span></span><span style="display:flex;"><span>    names <span style="color:#f92672">=</span> ds<span style="color:#f92672">.</span>features[<span style="color:#e6db74">&#34;label&#34;</span>]<span style="color:#f92672">.</span>names
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> ds:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> row[<span style="color:#e6db74">&#34;label&#34;</span>] <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0</span>:  <span style="color:#75715e"># -1 marks unlabeled rows</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;ocnli&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: {<span style="color:#e6db74">&#34;前提&#34;</span>: row[<span style="color:#e6db74">&#34;sentence1&#34;</span>], <span style="color:#e6db74">&#34;假设&#34;</span>: row[<span style="color:#e6db74">&#34;sentence2&#34;</span>]},
</span></span><span style="display:flex;"><span>                   <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;假设和前提是什么关系？&#34;</span>, <span style="color:#e6db74">&#34;criteria&#34;</span>: OCNLI},
</span></span><span style="display:flex;"><span>                   <span style="color:#e6db74">&#34;answer&#34;</span>: names[row[<span style="color:#e6db74">&#34;label&#34;</span>]]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">key</span>(record):
</span></span><span style="display:flex;"><span>    text <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>dumps([record[<span style="color:#e6db74">&#34;state&#34;</span>], record[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;instructions&#34;</span>]], ensure_ascii<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> hashlib<span style="color:#f92672">.</span>sha1(<span style="color:#e6db74">&#34; &#34;</span><span style="color:#f92672">.</span>join(text<span style="color:#f92672">.</span>lower()<span style="color:#f92672">.</span>split())<span style="color:#f92672">.</span>encode())<span style="color:#f92672">.</span>hexdigest()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">write</span>(path, rows):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(path, <span style="color:#e6db74">&#34;w&#34;</span>, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        f<span style="color:#f92672">.</span>writelines(json<span style="color:#f92672">.</span>dumps(r, ensure_ascii<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span> <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> rows)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>():
</span></span><span style="display:flex;"><span>    held_out_tasks <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;banking77&#34;</span>}  <span style="color:#75715e"># whole tasks never trained on (choice is still covered by ocnli)</span>
</span></span><span style="display:flex;"><span>    banned <span style="color:#f92672">=</span> {key(json<span style="color:#f92672">.</span>loads(line)) <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> Path(<span style="color:#e6db74">&#34;eval&#34;</span>)<span style="color:#f92672">.</span>glob(<span style="color:#e6db74">&#34;*.jsonl&#34;</span>)
</span></span><span style="display:flex;"><span>              <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(p, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>)}  <span style="color:#75715e"># JevBench public files, your lockbox, ...</span>
</span></span><span style="display:flex;"><span>    seen, train, dev, held_out <span style="color:#f92672">=</span> set(), [], [], []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> source <span style="color:#f92672">in</span> (banking77, boolq, sst5, ocnli):
</span></span><span style="display:flex;"><span>        rows <span style="color:#f92672">=</span> list(source())
</span></span><span style="display:flex;"><span>        random<span style="color:#f92672">.</span>shuffle(rows)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> rows[:<span style="color:#ae81ff">20000</span>]:  <span style="color:#75715e"># cap every task so no single dataset dominates the mix</span>
</span></span><span style="display:flex;"><span>            k <span style="color:#f92672">=</span> key(r)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> k <span style="color:#f92672">in</span> seen <span style="color:#f92672">or</span> k <span style="color:#f92672">in</span> banned:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>            seen<span style="color:#f92672">.</span>add(k)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;task&#34;</span>] <span style="color:#f92672">in</span> held_out_tasks:
</span></span><span style="display:flex;"><span>                held_out<span style="color:#f92672">.</span>append(r)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">elif</span> random<span style="color:#f92672">.</span>random() <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0.05</span>:
</span></span><span style="display:flex;"><span>                dev<span style="color:#f92672">.</span>append(r)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>                train<span style="color:#f92672">.</span>append(r)
</span></span><span style="display:flex;"><span>    random<span style="color:#f92672">.</span>shuffle(train)
</span></span><span style="display:flex;"><span>    Path(<span style="color:#e6db74">&#34;data&#34;</span>)<span style="color:#f92672">.</span>mkdir(exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    write(<span style="color:#e6db74">&#34;data/train.jsonl&#34;</span>, train)
</span></span><span style="display:flex;"><span>    write(<span style="color:#e6db74">&#34;data/dev.jsonl&#34;</span>, dev)
</span></span><span style="display:flex;"><span>    write(<span style="color:#e6db74">&#34;data/heldout_tasks.jsonl&#34;</span>, held_out)
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;train </span><span style="color:#e6db74">{</span>len(train)<span style="color:#e6db74">}</span><span style="color:#e6db74">  dev </span><span style="color:#e6db74">{</span>len(dev)<span style="color:#e6db74">}</span><span style="color:#e6db74">  held-out tasks </span><span style="color:#e6db74">{</span>len(held_out)<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main()
</span></span></code></pre></div><p>把 JevBench 的公开题文件和自己的封存集放进 <code>eval/</code> 目录后运行，就会得到 <code>data/</code> 下的三个文件。我实际运行的结果是：训练集 36,044 条，开发集 1,917 条，留出任务（Banking77）9,993 条。这只是一个演示，真正的项目需要几十到几百个任务，但流程完全一样：<strong>每加一个数据源，就是再写一个这样的转换函数。</strong></p>
<h2 id="三gpu-资源到底需要多少卡">三、GPU 资源：到底需要多少卡</h2>
<h3 id="1-显存怎么估">1. 显存怎么估</h3>
<p>训练时的显存主要由三部分组成：模型权重、梯度和优化器状态、激活值。前两部分可以按“每个参数占多少字节”来估：</p>
<table>
	<thead>
			<tr>
					<th>训练方式</th>
					<th>每个参数约占</th>
					<th>构成</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>全参微调</td>
					<td>16 字节</td>
					<td>bf16 权重 2 + bf16 梯度 2 + fp32 主权重 4 + AdamW 两个动量 8</td>
			</tr>
			<tr>
					<td>LoRA</td>
					<td>2 字节</td>
					<td>冻结的 bf16 权重；适配器及其优化器状态通常不到 1 GB</td>
			</tr>
			<tr>
					<td>QLoRA</td>
					<td>0.5～0.6 字节</td>
					<td>冻结的 4-bit 权重</td>
			</tr>
	</tbody>
</table>
<p>按这个规则，不含激活值的显存大约是：</p>
<table>
	<thead>
			<tr>
					<th>底座</th>
					<th>全参微调</th>
					<th>LoRA</th>
					<th>QLoRA</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Qwen3.5-0.8B</td>
					<td>约 13 GB</td>
					<td>约 1.6 GB</td>
					<td>约 0.5 GB</td>
			</tr>
			<tr>
					<td>Qwen3.5-2B</td>
					<td>约 32 GB</td>
					<td>约 4 GB</td>
					<td>约 1.1 GB</td>
			</tr>
			<tr>
					<td>Qwen3.5-4B</td>
					<td>约 64 GB</td>
					<td>约 8 GB</td>
					<td>约 2.2 GB</td>
			</tr>
			<tr>
					<td>Qwen3.5-9B</td>
					<td>约 144 GB</td>
					<td>约 18 GB</td>
					<td>约 5 GB</td>
			</tr>
			<tr>
					<td>Gemma-4-31B</td>
					<td>约 500 GB</td>
					<td>约 62 GB</td>
					<td>约 17 GB</td>
			</tr>
	</tbody>
</table>
<p>激活值和 batch 大小、序列长度成正比。开启梯度检查点后，主要剩下每一层边界的隐状态：以 Qwen3.5-4B（32 层，隐藏维度 2560）为例，一条 4K token 的样本约 <code>32 × 4096 × 2560 × 2 字节 ≈ 0.7 GB</code>，batch 为 4 时约 3 GB，再加上单层重算的峰值和 CUDA 自身的开销。所以 <strong>4B 模型做 LoRA，单张 24 GB 的卡通常就能跑</strong>（序列在 4K 以内、batch 不大的情况下），80 GB 的卡则可以放开 batch 和序列长度。</p>
<p>这里有一个决策模型特有的大坑：<strong>语言模型头的 logits</strong>。Qwen3.5 的词表有 248,320 个 token，如果像普通 SFT 那样对每个位置都算 logits，一条 4K token 的样本光 fp32 的 logits 就要 <code>4096 × 248320 × 4 字节 ≈ 4 GB</code>，反向传播时还要再来一份。而决策模型只需要最后一个位置的 logits，前向时传入 <code>logits_to_keep=1</code>，这部分就只剩 1 MB 左右。后面的训练脚本就是这么做的。</p>
<h3 id="2-时间怎么估">2. 时间怎么估</h3>
<p>训练的计算量可以用一个经验公式估算：每个训练 token 约 <code>6 × 参数量</code> 次浮点运算（LoRA 加梯度检查点：前向 2N、重算 2N、反向 2N；全参微调加梯度检查点约为 8N）。举个例子，用 Qwen3.5-4B 做 LoRA，20 万道题、平均每题 600 token、训练 2 个 epoch：</p>
<pre tabindex="0"><code>训练 token 数  ≈ 200,000 × 600 × 2 = 2.4e8
训练计算量    ≈ 6 × 4e9 × 2.4e8 ≈ 5.8e18 FLOPs
H100 实际吞吐 ≈ 989 TFLOPS（bf16 稠密峰值）× 35% 利用率 ≈ 350 TFLOPS
训练时间      ≈ 5.8e18 / 3.5e14 ≈ 16,500 秒 ≈ 4.6 小时
</code></pre><p>同样的任务在 A100 上大约要 3 倍时间。实际利用率和序列长度、padding 比例、算子实现都有关系，要以自己的 profiler 数据为准。Qwen3.5 是“Gated DeltaNet 线性注意力 + 门控全注意力”的混合架构，训练前一定要装上 <code>flash-linear-attention</code> 和 <code>causal-conv1d</code>，否则会退回到纯 PyTorch 实现，慢很多（加载模型时会打印提示）。</p>
<h3 id="3-推荐配置">3. 推荐配置</h3>
<p><img alt="从编码器到全参微调的硬件阶梯" loading="lazy" src="/zh/blog/2026/train-your-own-jev/5-gpu-ladder.png"></p>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>底座与训练方式</th>
					<th>推荐硬件</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>编码器路线</td>
					<td>ModernBERT-large / mmBERT + 决策头</td>
					<td>2 张 T4（Kaggle 免费额度）或任意 16 GB 显卡</td>
					<td>Laya 的官方 Notebook：约 3 万道题、4 个 epoch，耗时 4～5 小时</td>
			</tr>
			<tr>
					<td>快速验证</td>
					<td>Qwen3.5-0.8B / 2B + LoRA</td>
					<td>单张 24 GB（RTX 4090、L4、A10）</td>
					<td>跑通流程、做数据实验</td>
			</tr>
			<tr>
					<td>主力方案</td>
					<td>Qwen3.5-4B + LoRA</td>
					<td>1 张 H100 / A100 80 GB</td>
					<td>性价比最高的档位，短序列时 24 GB 也能跑</td>
			</tr>
			<tr>
					<td>进阶</td>
					<td>Qwen3.5-9B + LoRA</td>
					<td>1～2 张 H100</td>
					<td>长上下文训练时用 2 张</td>
			</tr>
			<tr>
					<td>全参微调</td>
					<td>4B～9B 全参</td>
					<td>8 张 H100（FSDP 或 ZeRO-3）</td>
					<td>数据量很大、追求极致效果时</td>
			</tr>
	</tbody>
</table>
<p>成本方面，按 H100 按需价格每小时 2～3 美元估算（JevBench 的 GPU 成本计算器默认按 H100 PCIe 每小时 2.89 美元），一次 4B 的 LoRA 训练大约 15 美元。真正花钱的是迭代：一个项目通常要跑几十轮“改数据、重训、评测”，GPU 总花费在几百美元量级。<strong>算力不是最贵的，数据和评测才是。</strong></p>
<p>部署的成本更低：H2O-Lightning-4B 的 bf16 权重约 9.1 GB，在一张 32 GB 的 RTX PRO 4500 上用 vLLM 服务，标准题单次判断的 p50 约 30 ms；按 JevBench 计算器的估算，在 RTX 5090 上自托管，每千次决策约 0.008 美元，大约是 Jev 在 JevBench 上实测成本（每千次 0.032 美元）的四分之一。</p>
<h2 id="四基础模型怎么选">四、基础模型怎么选</h2>
<h3 id="1-选择标准">1. 选择标准</h3>
<ol>
<li><strong>零样本底子</strong>：优先选指令版（经过对齐的对话模型），它们本来就会做选择题，起点高；Base 版需要更多数据才能追上。JevBench 专门放了几个“底座对照组”（不做任何决策微调，直接读 logits），能直接看出不同底座的起点。</li>
<li><strong>语言</strong>：中文场景优先考虑 Qwen；注意 H2O-Lightning-4B 的训练数据 99% 以上是英文，直接拿来处理中文要先自测。</li>
<li><strong>尺寸与延迟</strong>：决策模型的定位是几十毫秒级的快速判断，4B 左右是目前延迟、成本和能力的甜点。</li>
<li><strong>上下文长度</strong>：JevBench 里有 23 道 7.7 万～8.1 万 token 的超长题，Jev 和不少模型因为超出上下文而拒答，按答错计分。Qwen3.5 原生支持 262K，但序列越长，训练的显存和时间成本上涨得越快。</li>
<li><strong>许可证</strong>：Qwen3.5 全系列、Gemma 4、ModernBERT 都是 Apache 2.0，mmBERT 是 MIT，商用比较省心。</li>
<li><strong>生态</strong>：训练要有 Transformers 和 PEFT 支持，部署要有 vLLM、SGLang、llama.cpp 支持；另外，选项字母必须各自是一个独立的 token（训练脚本里有断言检查）。</li>
</ol>
<h3 id="2-候选清单">2. 候选清单</h3>
<table>
	<thead>
			<tr>
					<th>底座</th>
					<th>规模</th>
					<th>许可证</th>
					<th>适合</th>
					<th>社区代表作</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Qwen3.5-0.8B / 2B</td>
					<td>0.8B / 2B</td>
					<td>Apache 2.0</td>
					<td>端侧、超低延迟、流程验证</td>
					<td>jiwo 0.8b、ClassOne Qwen 3.5 2B</td>
			</tr>
			<tr>
					<td>Qwen3.5-4B</td>
					<td>4B</td>
					<td>Apache 2.0</td>
					<td>主力推荐，中文友好</td>
					<td>H2O-Lightning-4B、Kahn1 4B</td>
			</tr>
			<tr>
					<td>Qwen3.5-9B</td>
					<td>9B</td>
					<td>Apache 2.0</td>
					<td>更强的零样本能力</td>
					<td>Microsoft-Decision-1、ClassOne Qwen 3.5 9B</td>
			</tr>
			<tr>
					<td>Gemma-4-12B-it</td>
					<td>12B</td>
					<td>Apache 2.0</td>
					<td>多语言，能力与成本平衡</td>
					<td>decider-12b、torchcast-decision-12b</td>
			</tr>
			<tr>
					<td>Gemma-4-31B-it / Qwen3.5-27B</td>
					<td>27B～31B</td>
					<td>Apache 2.0</td>
					<td>冲击榜单头部</td>
					<td>Quyet-1.0-Large、deck-31B</td>
			</tr>
			<tr>
					<td>ModernBERT-large / mmBERT</td>
					<td>0.3B～0.4B</td>
					<td>Apache 2.0 / MIT</td>
					<td>编码器路线，单一任务极速推理</td>
					<td>Laya</td>
			</tr>
	</tbody>
</table>
<h3 id="3-我的建议">3. 我的建议</h3>
<ul>
<li><strong>起步用 Qwen3.5-4B（指令版）+ LoRA</strong>：中文能力好，社区已经证明它能做到接近 Jev 的水平，单卡就能训练和部署；</li>
<li><strong>先在 0.8B / 2B 上把流程跑通</strong>：数据格式、评测脚本、阈值策略都在小模型上验证完，再换大底座，可以省掉大量试错成本；</li>
<li><strong>想冲击更高的能力分，再上 9B 到 31B</strong>：收益是真实的（Quyet-1.0-Large 的智能分比 H2O-Lightning-4B 高 13 分以上），但训练和推理成本也会成倍增加；</li>
<li><strong>使用 Qwen3.5 时关闭思考模式</strong>：它默认会先输出思考过程，构造提示词时要传 <code>enable_thinking=False</code>，让助手回复的第一个 token 就是答案字母。</li>
</ul>
<h2 id="五训练脚本一步步写出来">五、训练脚本：一步步写出来</h2>
<p>这一节给出一套完整、可以直接运行的代码。它刻意保持精简，没有依赖任何专门的训练框架，方便读懂每一步在做什么。</p>
<h3 id="1-准备环境">1. 准备环境</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pip install torch transformers peft accelerate datasets fastapi uvicorn
</span></span><span style="display:flex;"><span><span style="color:#75715e"># GPU 机器上再装 Qwen3.5 线性注意力层的加速算子</span>
</span></span><span style="display:flex;"><span>pip install flash-linear-attention causal-conv1d
</span></span></code></pre></div><p>项目结构如下：</p>
<pre tabindex="0"><code>my-jev/
├── prepare_data.py      # 数据准备（上一节）
├── decision_format.py   # 提示词渲染与一次前向打分，训练、评测、服务共用
├── train.py             # LoRA 训练
├── evaluate.py          # 打分测试与温度拟合
├── serve.py             # /v1/systemone 服务
├── eval/                # JevBench 公开题、自己的封存集
└── data/                # prepare_data.py 的输出
</code></pre><h3 id="2-共用模块把一道题变成一次前向">2. 共用模块：把一道题变成一次前向</h3>
<p>训练、评测和线上服务必须用<strong>完全相同</strong>的提示词格式，否则训练出来的概率到了线上就不再准确。所以先把这部分抽成一个共用模块：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># decision_format.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Shared by training, evaluation and serving: render a decision as a prompt and read option probabilities.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> string
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> transformers <span style="color:#f92672">import</span> AutoModelForCausalLM, AutoTokenizer
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>LETTERS <span style="color:#f92672">=</span> string<span style="color:#f92672">.</span>ascii_uppercase  <span style="color:#75715e"># up to 26 options per question</span>
</span></span><span style="display:flex;"><span>SYSTEM <span style="color:#f92672">=</span> (<span style="color:#e6db74">&#34;You are a decision model. Read the state and the question, &#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#e6db74">&#34;then answer with the letter of exactly one option.&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">load</span>(base, dtype<span style="color:#f92672">=</span>torch<span style="color:#f92672">.</span>bfloat16):
</span></span><span style="display:flex;"><span>    tok <span style="color:#f92672">=</span> AutoTokenizer<span style="color:#f92672">.</span>from_pretrained(base)
</span></span><span style="display:flex;"><span>    tok<span style="color:#f92672">.</span>padding_side <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;left&#34;</span>  <span style="color:#75715e"># every row then ends at its answer slot</span>
</span></span><span style="display:flex;"><span>    model <span style="color:#f92672">=</span> AutoModelForCausalLM<span style="color:#f92672">.</span>from_pretrained(base, dtype<span style="color:#f92672">=</span>dtype)  <span style="color:#75715e"># Qwen3.5 loads text-only here</span>
</span></span><span style="display:flex;"><span>    ids <span style="color:#f92672">=</span> [tok<span style="color:#f92672">.</span>encode(c, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>) <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> LETTERS]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> all(len(i) <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> ids), <span style="color:#e6db74">&#34;every option letter must be a single token&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> tok, model, torch<span style="color:#f92672">.</span>tensor([i[<span style="color:#ae81ff">0</span>] <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> ids])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">options</span>(question):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;[(key, description)] in the caller&#39;s order; noul is shown as yes / no.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    kind, crit <span style="color:#f92672">=</span> question[<span style="color:#e6db74">&#34;type&#34;</span>], question<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;criteria&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> kind <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;noul&#34;</span>:
</span></span><span style="display:flex;"><span>        crit <span style="color:#f92672">=</span> crit <span style="color:#f92672">or</span> {}
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [(<span style="color:#e6db74">&#34;yes&#34;</span>, crit<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;true&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>)), (<span style="color:#e6db74">&#34;no&#34;</span>, crit<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;false&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>))]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> kind <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [(str(i), desc) <span style="color:#66d9ef">for</span> i, desc <span style="color:#f92672">in</span> enumerate(crit)]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> list(crit<span style="color:#f92672">.</span>items()) <span style="color:#66d9ef">if</span> isinstance(crit, dict) <span style="color:#66d9ef">else</span> [(c, <span style="color:#e6db74">&#34;&#34;</span>) <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> crit]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">target</span>(record, opts):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Target distribution over opts: a soft label if the record has one, else one-hot.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    keys <span style="color:#f92672">=</span> [k <span style="color:#66d9ef">for</span> k, _ <span style="color:#f92672">in</span> opts]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;target&#34;</span> <span style="color:#f92672">in</span> record:  <span style="color:#75715e"># e.g. {&#34;billing&#34;: 0.7, &#34;technical&#34;: 0.3}</span>
</span></span><span style="display:flex;"><span>        dist <span style="color:#f92672">=</span> [float(record[<span style="color:#e6db74">&#34;target&#34;</span>]<span style="color:#f92672">.</span>get(k, <span style="color:#ae81ff">0.0</span>)) <span style="color:#66d9ef">for</span> k <span style="color:#f92672">in</span> keys]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>        ans <span style="color:#f92672">=</span> record<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;answer&#34;</span>, record<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;expected&#34;</span>))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> record[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;noul&#34;</span>:
</span></span><span style="display:flex;"><span>            ans <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;yes&#34;</span> <span style="color:#66d9ef">if</span> ans <span style="color:#f92672">in</span> (<span style="color:#66d9ef">True</span>, <span style="color:#e6db74">&#34;yes&#34;</span>, <span style="color:#e6db74">&#34;true&#34;</span>) <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;no&#34;</span>
</span></span><span style="display:flex;"><span>        dist <span style="color:#f92672">=</span> [<span style="color:#ae81ff">1.0</span> <span style="color:#66d9ef">if</span> k <span style="color:#f92672">==</span> str(ans) <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0.0</span> <span style="color:#66d9ef">for</span> k <span style="color:#f92672">in</span> keys]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> sum(dist) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;answer is not one of the options: </span><span style="color:#e6db74">{</span>record<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;id&#39;</span>, record[<span style="color:#e6db74">&#39;question&#39;</span>])<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> [d <span style="color:#f92672">/</span> sum(dist) <span style="color:#66d9ef">for</span> d <span style="color:#f92672">in</span> dist]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">prompt</span>(tok, record, order, max_state_tokens<span style="color:#f92672">=</span><span style="color:#ae81ff">6000</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Chat prompt whose next token is the answer letter; slot s shows option order[s].&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    q, opts, state <span style="color:#f92672">=</span> record[<span style="color:#e6db74">&#34;question&#34;</span>], options(record[<span style="color:#e6db74">&#34;question&#34;</span>]), record[<span style="color:#e6db74">&#34;state&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> isinstance(state, str):
</span></span><span style="display:flex;"><span>        state <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>dumps(state, ensure_ascii<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, indent<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    ids <span style="color:#f92672">=</span> tok<span style="color:#f92672">.</span>encode(state, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> len(ids) <span style="color:#f92672">&gt;</span> max_state_tokens:  <span style="color:#75715e"># cut the state, never the options or the answer slot</span>
</span></span><span style="display:flex;"><span>        state <span style="color:#f92672">=</span> tok<span style="color:#f92672">.</span>decode(ids[:max_state_tokens]) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">[truncated]&#34;</span>
</span></span><span style="display:flex;"><span>    lines <span style="color:#f92672">=</span> [<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>LETTERS[s]<span style="color:#e6db74">}</span><span style="color:#e6db74">. </span><span style="color:#e6db74">{</span>opts[i][<span style="color:#ae81ff">0</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">+</span> (<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;: </span><span style="color:#e6db74">{</span>opts[i][<span style="color:#ae81ff">1</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#66d9ef">if</span> opts[i][<span style="color:#ae81ff">1</span>] <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>             <span style="color:#66d9ef">for</span> s, i <span style="color:#f92672">in</span> enumerate(order)]
</span></span><span style="display:flex;"><span>    user <span style="color:#f92672">=</span> (<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;&lt;state&gt;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{</span>state<span style="color:#e6db74">}</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&lt;/state&gt;</span><span style="color:#ae81ff">\n\n</span><span style="color:#e6db74">Question (</span><span style="color:#e6db74">{</span>q[<span style="color:#e6db74">&#39;type&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">): </span><span style="color:#e6db74">{</span>q[<span style="color:#e6db74">&#39;instructions&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;Options:</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span><span style="color:#f92672">.</span>join(lines) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n\n</span><span style="color:#e6db74">Answer with the letter of one option.&#34;</span>)
</span></span><span style="display:flex;"><span>    messages <span style="color:#f92672">=</span> [{<span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;system&#34;</span>, <span style="color:#e6db74">&#34;content&#34;</span>: SYSTEM}, {<span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;user&#34;</span>, <span style="color:#e6db74">&#34;content&#34;</span>: user}]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> tok<span style="color:#f92672">.</span>apply_chat_template(messages, tokenize<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, add_generation_prompt<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>                                   enable_thinking<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">option_logits</span>(model, tok, letter_ids, prompts, ks, device):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;One forward pass for a batch: logits of the shown option letters at the answer slot.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    enc <span style="color:#f92672">=</span> tok(prompts, return_tensors<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;pt&#34;</span>, padding<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>to(device)
</span></span><span style="display:flex;"><span>    out <span style="color:#f92672">=</span> model(input_ids<span style="color:#f92672">=</span>enc<span style="color:#f92672">.</span>input_ids, attention_mask<span style="color:#f92672">=</span>enc<span style="color:#f92672">.</span>attention_mask, logits_to_keep<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    z <span style="color:#f92672">=</span> out<span style="color:#f92672">.</span>logits[:, <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, letter_ids[: max(ks)]<span style="color:#f92672">.</span>to(device)]<span style="color:#f92672">.</span>float()
</span></span><span style="display:flex;"><span>    mask <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>arange(z<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>], device<span style="color:#f92672">=</span>device)[<span style="color:#66d9ef">None</span>, :] <span style="color:#f92672">&lt;</span> torch<span style="color:#f92672">.</span>tensor(ks, device<span style="color:#f92672">=</span>device)[:, <span style="color:#66d9ef">None</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> z<span style="color:#f92672">.</span>masked_fill(<span style="color:#f92672">~</span>mask, <span style="color:#f92672">-</span><span style="color:#ae81ff">1e4</span>), mask
</span></span></code></pre></div><p>几个关键点：</p>
<ul>
<li><strong>左侧 padding</strong>：一个 batch 里的提示词长短不一，左侧补齐之后，每一行的最后一个位置都是答案位，配合 <code>logits_to_keep=1</code>，语言模型头只需要算一个位置。Qwen3.5 有线性注意力层，我专门验证过：同一道题单独计算和放进左侧补齐的 batch 里计算，logits 的最大差异只有 6×10⁻⁵；</li>
<li><strong>只加载文本部分</strong>：Qwen3.5 是原生多模态模型，但用 <code>AutoModelForCausalLM</code> 加载时得到的是纯文本的 <code>Qwen3_5ForCausalLM</code>，不会带上视觉编码器，省显存，LoRA 也不会挂到视觉层上；</li>
<li><strong>只在 K 个字母上做 softmax</strong>：取出 A、B、C……对应的 logits，多余的槽位填一个很小的值，模型的输出永远落在合法选项里；</li>
<li><strong>state 过长时只截断 state</strong>：问题、选项和答案位必须完整保留；</li>
<li><strong>noul 统一渲染成“yes / no”两个选项</strong>，返回的 P(yes) 就是 noul 的答案；score 的各个等级按顺序排列，便于计算 RPS；</li>
<li><strong>断言每个字母都是单独的 token</strong>：换其他底座时，这一行能第一时间发现问题。在 Qwen3.5 的分词器上，A 到 Z 都是单个 token，而且在答案位后面追加一个字母，恰好多出一个 token。</li>
</ul>
<h3 id="3-训练主脚本">3. 训练主脚本</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># train.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;LoRA fine-tuning of a decision model with strictly proper scoring rules, or Laya-style RLCD.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> argparse
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> math
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> random
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> accelerate <span style="color:#f92672">import</span> Accelerator
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> peft <span style="color:#f92672">import</span> LoraConfig, get_peft_model
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> torch.utils.data <span style="color:#f92672">import</span> DataLoader
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> transformers <span style="color:#f92672">import</span> get_cosine_schedule_with_warmup
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> decision_format <span style="color:#f92672">import</span> load, option_logits, options, prompt, target
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">reward</span>(z, mask, y, ordinal):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Laya&#39;s composite of strictly proper scores: log + 0.5 * spherical - RPS (RPS for score only).&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    logq <span style="color:#f92672">=</span> z<span style="color:#f92672">.</span>log_softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> logq<span style="color:#f92672">.</span>exp()
</span></span><span style="display:flex;"><span>    s_log <span style="color:#f92672">=</span> (y <span style="color:#f92672">*</span> logq)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)                      <span style="color:#75715e"># log score = minus cross-entropy</span>
</span></span><span style="display:flex;"><span>    s_sph <span style="color:#f92672">=</span> (y <span style="color:#f92672">*</span> q)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> q<span style="color:#f92672">.</span>norm(dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)        <span style="color:#75715e"># spherical score, bounded in [0, 1]</span>
</span></span><span style="display:flex;"><span>    k <span style="color:#f92672">=</span> mask<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>clamp(min<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    rps <span style="color:#f92672">=</span> ((q<span style="color:#f92672">.</span>cumsum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> y<span style="color:#f92672">.</span>cumsum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> (k <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>)  <span style="color:#75715e"># ranked probability score</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> s_log <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> s_sph <span style="color:#f92672">-</span> ordinal <span style="color:#f92672">*</span> rps
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">rlcd_loss</span>(z, mask, y, ordinal, sigma, group<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Policy gradient over Gaussian-perturbed logits with a group-mean baseline, as Laya describes RLCD.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    m <span style="color:#f92672">=</span> mask<span style="color:#f92672">.</span>float()
</span></span><span style="display:flex;"><span>    eps <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>randn(group, <span style="color:#f92672">*</span>z<span style="color:#f92672">.</span>shape, device<span style="color:#f92672">=</span>z<span style="color:#f92672">.</span>device) <span style="color:#f92672">*</span> m
</span></span><span style="display:flex;"><span>    eps <span style="color:#f92672">=</span> (eps <span style="color:#f92672">-</span> eps<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, keepdim<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>) <span style="color:#f92672">/</span> m<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, keepdim<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)) <span style="color:#f92672">*</span> m  <span style="color:#75715e"># softmax ignores a common shift</span>
</span></span><span style="display:flex;"><span>    sample <span style="color:#f92672">=</span> z<span style="color:#f92672">.</span>detach() <span style="color:#f92672">+</span> eps
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> torch<span style="color:#f92672">.</span>no_grad():
</span></span><span style="display:flex;"><span>        r <span style="color:#f92672">=</span> reward(sample, mask, y, ordinal)            <span style="color:#75715e"># [group, batch]</span>
</span></span><span style="display:flex;"><span>        adv <span style="color:#f92672">=</span> (r <span style="color:#f92672">-</span> r<span style="color:#f92672">.</span>mean(<span style="color:#ae81ff">0</span>)) <span style="color:#f92672">/</span> (r<span style="color:#f92672">.</span>std(<span style="color:#ae81ff">0</span>) <span style="color:#f92672">+</span> <span style="color:#ae81ff">1e-6</span>)
</span></span><span style="display:flex;"><span>    logp <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>(((sample <span style="color:#f92672">-</span> z) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>) <span style="color:#f92672">*</span> m)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> (<span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> sigma<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span>)  <span style="color:#75715e"># Gaussian log-density up to a constant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#f92672">-</span>(adv <span style="color:#f92672">*</span> logp)<span style="color:#f92672">.</span>mean()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Collate</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__init__</span>(self, tok, max_state_tokens):
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>tok, self<span style="color:#f92672">.</span>max_state_tokens <span style="color:#f92672">=</span> tok, max_state_tokens
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__call__</span>(self, records):
</span></span><span style="display:flex;"><span>        prompts, ks, ys, ordinal <span style="color:#f92672">=</span> [], [], [], []
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> records:
</span></span><span style="display:flex;"><span>            opts <span style="color:#f92672">=</span> options(r[<span style="color:#e6db74">&#34;question&#34;</span>])
</span></span><span style="display:flex;"><span>            order <span style="color:#f92672">=</span> list(range(len(opts)))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;score&#34;</span>:  <span style="color:#75715e"># score levels keep their order, RPS relies on it</span>
</span></span><span style="display:flex;"><span>                random<span style="color:#f92672">.</span>shuffle(order)
</span></span><span style="display:flex;"><span>            y <span style="color:#f92672">=</span> target(r, opts)
</span></span><span style="display:flex;"><span>            prompts<span style="color:#f92672">.</span>append(prompt(self<span style="color:#f92672">.</span>tok, r, order, self<span style="color:#f92672">.</span>max_state_tokens))
</span></span><span style="display:flex;"><span>            ys<span style="color:#f92672">.</span>append([y[i] <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> order])
</span></span><span style="display:flex;"><span>            ks<span style="color:#f92672">.</span>append(len(opts))
</span></span><span style="display:flex;"><span>            ordinal<span style="color:#f92672">.</span>append(float(r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>))
</span></span><span style="display:flex;"><span>        y <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>zeros(len(records), max(ks))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> row, t <span style="color:#f92672">in</span> enumerate(ys):
</span></span><span style="display:flex;"><span>            y[row, : len(t)] <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>tensor(t)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;prompts&#34;</span>: prompts, <span style="color:#e6db74">&#34;ks&#34;</span>: ks, <span style="color:#e6db74">&#34;y&#34;</span>: y, <span style="color:#e6db74">&#34;ordinal&#34;</span>: torch<span style="color:#f92672">.</span>tensor(ordinal)}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>():
</span></span><span style="display:flex;"><span>    ap <span style="color:#f92672">=</span> argparse<span style="color:#f92672">.</span>ArgumentParser()
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--base&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Qwen/Qwen3.5-4B&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--train&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;data/train.jsonl&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--out&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;out/my-jev&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--objective&#34;</span>, choices<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;proper&#34;</span>, <span style="color:#e6db74">&#34;rlcd&#34;</span>], default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;proper&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--epochs&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--lr&#34;</span>, type<span style="color:#f92672">=</span>float, default<span style="color:#f92672">=</span><span style="color:#ae81ff">1e-4</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--batch-size&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">4</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--grad-accum&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--lora-r&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">32</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--max-state-tokens&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">3000</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--mixed-precision&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;bf16&#34;</span>)
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> ap<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    acc <span style="color:#f92672">=</span> Accelerator(mixed_precision<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>mixed_precision, gradient_accumulation_steps<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>grad_accum)
</span></span><span style="display:flex;"><span>    tok, model, letter_ids <span style="color:#f92672">=</span> load(args<span style="color:#f92672">.</span>base)
</span></span><span style="display:flex;"><span>    model<span style="color:#f92672">.</span>gradient_checkpointing_enable(gradient_checkpointing_kwargs<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;use_reentrant&#34;</span>: <span style="color:#66d9ef">False</span>})
</span></span><span style="display:flex;"><span>    model <span style="color:#f92672">=</span> get_peft_model(model, LoraConfig(
</span></span><span style="display:flex;"><span>        r<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>lora_r, lora_alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> args<span style="color:#f92672">.</span>lora_r, lora_dropout<span style="color:#f92672">=</span><span style="color:#ae81ff">0.05</span>, target_modules<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;all-linear&#34;</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> acc<span style="color:#f92672">.</span>is_main_process:
</span></span><span style="display:flex;"><span>        model<span style="color:#f92672">.</span>print_trainable_parameters()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    records <span style="color:#f92672">=</span> [json<span style="color:#f92672">.</span>loads(line) <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(args<span style="color:#f92672">.</span>train, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>)]
</span></span><span style="display:flex;"><span>    loader <span style="color:#f92672">=</span> DataLoader(records, batch_size<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>batch_size, shuffle<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>                        collate_fn<span style="color:#f92672">=</span>Collate(tok, args<span style="color:#f92672">.</span>max_state_tokens))
</span></span><span style="display:flex;"><span>    opt <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>optim<span style="color:#f92672">.</span>AdamW([p <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> model<span style="color:#f92672">.</span>parameters() <span style="color:#66d9ef">if</span> p<span style="color:#f92672">.</span>requires_grad], lr<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>lr, weight_decay<span style="color:#f92672">=</span><span style="color:#ae81ff">0.0</span>)
</span></span><span style="display:flex;"><span>    total <span style="color:#f92672">=</span> args<span style="color:#f92672">.</span>epochs <span style="color:#f92672">*</span> math<span style="color:#f92672">.</span>ceil(len(loader) <span style="color:#f92672">/</span> args<span style="color:#f92672">.</span>grad_accum)
</span></span><span style="display:flex;"><span>    sched <span style="color:#f92672">=</span> get_cosine_schedule_with_warmup(opt, max(<span style="color:#ae81ff">1</span>, int(<span style="color:#ae81ff">0.03</span> <span style="color:#f92672">*</span> total)), total)
</span></span><span style="display:flex;"><span>    model, opt, loader, sched <span style="color:#f92672">=</span> acc<span style="color:#f92672">.</span>prepare(model, opt, loader, sched)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    step, n_batches <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>, args<span style="color:#f92672">.</span>epochs <span style="color:#f92672">*</span> len(loader)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> epoch <span style="color:#f92672">in</span> range(args<span style="color:#f92672">.</span>epochs):
</span></span><span style="display:flex;"><span>        model<span style="color:#f92672">.</span>train()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> batch <span style="color:#f92672">in</span> loader:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">with</span> acc<span style="color:#f92672">.</span>accumulate(model):
</span></span><span style="display:flex;"><span>                z, mask <span style="color:#f92672">=</span> option_logits(model, tok, letter_ids, batch[<span style="color:#e6db74">&#34;prompts&#34;</span>], batch[<span style="color:#e6db74">&#34;ks&#34;</span>], acc<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>objective <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;rlcd&#34;</span>:
</span></span><span style="display:flex;"><span>                    sigma <span style="color:#f92672">=</span> <span style="color:#ae81ff">1.0</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">0.7</span> <span style="color:#f92672">*</span> step <span style="color:#f92672">/</span> n_batches  <span style="color:#75715e"># exploration decays from 1.0 to 0.3</span>
</span></span><span style="display:flex;"><span>                    loss <span style="color:#f92672">=</span> rlcd_loss(z, mask, batch[<span style="color:#e6db74">&#34;y&#34;</span>], batch[<span style="color:#e6db74">&#34;ordinal&#34;</span>], sigma)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>                    loss <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>reward(z, mask, batch[<span style="color:#e6db74">&#34;y&#34;</span>], batch[<span style="color:#e6db74">&#34;ordinal&#34;</span>])<span style="color:#f92672">.</span>mean()
</span></span><span style="display:flex;"><span>                acc<span style="color:#f92672">.</span>backward(loss)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> acc<span style="color:#f92672">.</span>sync_gradients:
</span></span><span style="display:flex;"><span>                    acc<span style="color:#f92672">.</span>clip_grad_norm_(model<span style="color:#f92672">.</span>parameters(), <span style="color:#ae81ff">1.0</span>)
</span></span><span style="display:flex;"><span>                opt<span style="color:#f92672">.</span>step()
</span></span><span style="display:flex;"><span>                sched<span style="color:#f92672">.</span>step()
</span></span><span style="display:flex;"><span>                opt<span style="color:#f92672">.</span>zero_grad()
</span></span><span style="display:flex;"><span>            step <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> step <span style="color:#f92672">%</span> <span style="color:#ae81ff">20</span> <span style="color:#f92672">==</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>                acc<span style="color:#f92672">.</span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;epoch </span><span style="color:#e6db74">{</span>epoch <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> step </span><span style="color:#e6db74">{</span>step<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">{</span>n_batches<span style="color:#e6db74">}</span><span style="color:#e6db74"> loss </span><span style="color:#e6db74">{</span>loss<span style="color:#f92672">.</span>item()<span style="color:#e6db74">:</span><span style="color:#e6db74">.4f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>        acc<span style="color:#f92672">.</span>wait_for_everyone()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> acc<span style="color:#f92672">.</span>is_main_process:
</span></span><span style="display:flex;"><span>            acc<span style="color:#f92672">.</span>unwrap_model(model)<span style="color:#f92672">.</span>save_pretrained(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>args<span style="color:#f92672">.</span>out<span style="color:#e6db74">}</span><span style="color:#e6db74">/epoch</span><span style="color:#e6db74">{</span>epoch <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    acc<span style="color:#f92672">.</span>end_training()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main()
</span></span></code></pre></div><p>这段代码做了这几件事：</p>
<ol>
<li><strong>训练目标</strong>：<code>reward()</code> 就是前面介绍的 Laya 组合评分（对数评分 + 0.5 × 球面评分 − RPS），默认的 <code>--objective proper</code> 直接把它的相反数当损失；<code>--objective rlcd</code> 则按 Laya 的方式，用 8 组高斯扰动和组内基线做策略梯度，探索噪声从 1.0 衰减到 0.3。因为加了球面评分，choice 和 noul 题的损失下限是 −0.5（完全确定且答对），日志里出现负数是正常的；RLCD 的损失则是策略梯度的代理目标，数值本身没有意义，两种目标都要以评测指标为准；</li>
<li><strong>选项乱序</strong>：<code>Collate</code> 每次取 batch 都重新打乱 choice 和 noul 的选项顺序，标签跟着一起换位，这是对抗位置偏差最简单有效的办法；score 题保持等级顺序；</li>
<li><strong>LoRA</strong>：<code>target_modules=&quot;all-linear&quot;</code> 会覆盖注意力、Gated DeltaNet 和 MLP 中的所有线性层。在 Qwen3.5-0.8B 上，r=8 时可训练参数约 541 万，只占 0.71%；</li>
<li><strong>省显存</strong>：开启梯度检查点，只计算答案位的 logits；</li>
<li><strong>单卡、多卡同一份代码</strong>：Accelerate 负责设备、混合精度和梯度累积，多卡时自动切换成数据并行。</li>
</ol>
<h3 id="4-启动训练">4. 启动训练</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># 单卡：有效 batch = 4 × 8 = 32 道题</span>
</span></span><span style="display:flex;"><span>python train.py --base Qwen/Qwen3.5-4B --train data/train.jsonl --out out/my-jev
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 8 卡数据并行：有效 batch = 4 × 2 × 8 = 64 道题</span>
</span></span><span style="display:flex;"><span>accelerate launch --num_processes <span style="color:#ae81ff">8</span> train.py --base Qwen/Qwen3.5-4B <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --train data/train.jsonl --out out/my-jev --grad-accum <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 对比实验：换成 Laya 式的 RLCD</span>
</span></span><span style="display:flex;"><span>python train.py --base Qwen/Qwen3.5-4B --train data/train.jsonl --out out/my-jev-rlcd --objective rlcd
</span></span></code></pre></div><p>每个 epoch 结束会保存一份 LoRA 适配器（<code>out/my-jev/epoch1</code>、<code>epoch2</code>……），挑哪一份由下一节的评测决定。超参数可以从这里起步：</p>
<table>
	<thead>
			<tr>
					<th>参数</th>
					<th>起点</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>学习率</td>
					<td>LoRA 1e-4，全参 1e-5</td>
					<td>余弦衰减，3% 步数预热</td>
			</tr>
			<tr>
					<td>LoRA rank / alpha</td>
					<td>32 / 64</td>
					<td>数据量大、任务多时可以加到 64</td>
			</tr>
			<tr>
					<td>有效 batch</td>
					<td>32～64 道题</td>
					<td><code>batch-size × grad-accum × GPU 数</code></td>
			</tr>
			<tr>
					<td>epoch</td>
					<td>1～3</td>
					<td>以留出任务的 log loss 为准，开始变差就停</td>
			</tr>
			<tr>
					<td>state 截断长度</td>
					<td>3000 token</td>
					<td>长文档任务单独加大，并在训练数据里放足长样本</td>
			</tr>
			<tr>
					<td>选项顺序</td>
					<td>每个 batch 重新打乱</td>
					<td>score 题保持等级顺序</td>
			</tr>
	</tbody>
</table>
<p>我在 Qwen3.5-0.8B 上用 CPU 跑过这份脚本的冒烟测试（几十道题、两种训练目标各跑一遍），确认能正常训练和保存。真正的训练请在 GPU 上进行。</p>
<h2 id="六打分测试怎么知道模型好不好">六、打分测试：怎么知道模型好不好</h2>
<h3 id="1-看哪些指标">1. 看哪些指标</h3>
<p>只看准确率是远远不够的。一个合格的决策模型评测，至少要包含下面这些指标：</p>
<table>
	<thead>
			<tr>
					<th>指标</th>
					<th>含义</th>
					<th>怎么看</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>准确率</td>
					<td>概率最高的选项是否正确</td>
					<td>一定要和“永远猜最常见答案”的基线一起看</td>
			</tr>
			<tr>
					<td>去随机准确率</td>
					<td><code>(准确率 − 随机猜中率) / (1 − 随机猜中率)</code></td>
					<td>JevBench 的智能分就是这个思路，0 相当于瞎猜，100 为全对</td>
			</tr>
			<tr>
					<td>对数损失</td>
					<td>正确答案概率取对数的负值的均值</td>
					<td>训练目标本身，越小越好，对“自信地答错”惩罚最重</td>
			</tr>
			<tr>
					<td>Brier 分数</td>
					<td>概率与标签的平方误差之和</td>
					<td>越小越好，比对数损失更稳健</td>
			</tr>
			<tr>
					<td>ECE</td>
					<td>按置信度分成 10 个区间，置信度与准确率之差的加权平均</td>
					<td>校准的核心指标，越接近 0 越好</td>
			</tr>
			<tr>
					<td>RPS</td>
					<td>score 题累积分布的误差</td>
					<td>等级题专用，差一级比差三级惩罚小</td>
			</tr>
			<tr>
					<td>选项倒序翻转率</td>
					<td>把选项顺序倒过来，答案发生变化的比例</td>
					<td>位置偏差的直接度量</td>
			</tr>
			<tr>
					<td>阈值曲线</td>
					<td>不同置信度阈值下的转交比例和放行准确率</td>
					<td>用来确定上线阈值，也就是前面 H2O 的那张表</td>
			</tr>
			<tr>
					<td>延迟</td>
					<td>单题 p50 / p95</td>
					<td>决策模型的生命线</td>
			</tr>
	</tbody>
</table>
<p>位置偏差绝不是小问题。学术界早有研究表明，大模型做选择题时会偏好特定的选项位置和选项字母（Zheng 等人，2023）。JevBench 也记录过一个例子：open-alternative-jev 在“判断回答是否合格”这类题上，按作者给的选项顺序（<code>A. yes, B. no</code>）准确率是 72%，把两个选项倒过来就只剩 21%。除了脚本里的倒序翻转率，上线前最好再补几项一致性检查：同一问题换种问法答案是否一致、问题和它的否定形式概率之和是否接近 1、state 里插入注入文本后答案是否被带偏。</p>
<h3 id="2-评测脚本">2. 评测脚本</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># evaluate.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Evaluate a decision model: accuracy, chance-corrected accuracy, calibration, order robustness, latency.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> argparse
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> statistics
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> peft <span style="color:#f92672">import</span> PeftModel
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> decision_format <span style="color:#f92672">import</span> load, option_logits, options, prompt, target
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.no_grad</span>()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">predict</span>(model, tok, letter_ids, records, batch_size<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>, reverse<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Logits over each record&#39;s options, in the caller&#39;s order.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    result <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">0</span>, len(records), batch_size):
</span></span><span style="display:flex;"><span>        chunk, orders <span style="color:#f92672">=</span> records[i : i <span style="color:#f92672">+</span> batch_size], []
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> chunk:
</span></span><span style="display:flex;"><span>            order <span style="color:#f92672">=</span> list(range(len(options(r[<span style="color:#e6db74">&#34;question&#34;</span>]))))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> reverse <span style="color:#f92672">and</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;score&#34;</span>:
</span></span><span style="display:flex;"><span>                order<span style="color:#f92672">.</span>reverse()
</span></span><span style="display:flex;"><span>            orders<span style="color:#f92672">.</span>append(order)
</span></span><span style="display:flex;"><span>        prompts <span style="color:#f92672">=</span> [prompt(tok, r, o) <span style="color:#66d9ef">for</span> r, o <span style="color:#f92672">in</span> zip(chunk, orders)]
</span></span><span style="display:flex;"><span>        z, _ <span style="color:#f92672">=</span> option_logits(model, tok, letter_ids, prompts, [len(o) <span style="color:#66d9ef">for</span> o <span style="color:#f92672">in</span> orders], model<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> row, order <span style="color:#f92672">in</span> enumerate(orders):
</span></span><span style="display:flex;"><span>            logits <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>empty(len(order))
</span></span><span style="display:flex;"><span>            logits[order] <span style="color:#f92672">=</span> z[row, : len(order)]<span style="color:#f92672">.</span>cpu()  <span style="color:#75715e"># slot s showed option order[s]</span>
</span></span><span style="display:flex;"><span>            result<span style="color:#f92672">.</span>append(logits)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">ece</span>(conf, correct, bins<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>):
</span></span><span style="display:flex;"><span>    groups <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> c, ok <span style="color:#f92672">in</span> zip(conf, correct):
</span></span><span style="display:flex;"><span>        groups<span style="color:#f92672">.</span>setdefault(min(int(c <span style="color:#f92672">*</span> bins), bins <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>), [])<span style="color:#f92672">.</span>append((c, ok))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> sum(len(g) <span style="color:#f92672">/</span> len(conf) <span style="color:#f92672">*</span> abs(statistics<span style="color:#f92672">.</span>mean(o <span style="color:#66d9ef">for</span> _, o <span style="color:#f92672">in</span> g) <span style="color:#f92672">-</span> statistics<span style="color:#f92672">.</span>mean(c <span style="color:#66d9ef">for</span> c, _ <span style="color:#f92672">in</span> g))
</span></span><span style="display:flex;"><span>               <span style="color:#66d9ef">for</span> g <span style="color:#f92672">in</span> groups<span style="color:#f92672">.</span>values())
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">summary</span>(rows):
</span></span><span style="display:flex;"><span>    conf <span style="color:#f92672">=</span> [p<span style="color:#f92672">.</span>max()<span style="color:#f92672">.</span>item() <span style="color:#66d9ef">for</span> _, p, _ <span style="color:#f92672">in</span> rows]
</span></span><span style="display:flex;"><span>    correct <span style="color:#f92672">=</span> [float(p<span style="color:#f92672">.</span>argmax() <span style="color:#f92672">==</span> y<span style="color:#f92672">.</span>argmax()) <span style="color:#66d9ef">for</span> _, p, y <span style="color:#f92672">in</span> rows]
</span></span><span style="display:flex;"><span>    acc <span style="color:#f92672">=</span> statistics<span style="color:#f92672">.</span>mean(correct)
</span></span><span style="display:flex;"><span>    chance <span style="color:#f92672">=</span> statistics<span style="color:#f92672">.</span>mean(<span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> len(p) <span style="color:#66d9ef">for</span> _, p, _ <span style="color:#f92672">in</span> rows)
</span></span><span style="display:flex;"><span>    out <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;n&#34;</span>: len(rows), <span style="color:#e6db74">&#34;accuracy&#34;</span>: acc, <span style="color:#e6db74">&#34;chance_corrected&#34;</span>: (acc <span style="color:#f92672">-</span> chance) <span style="color:#f92672">/</span> (<span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> chance),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;log_loss&#34;</span>: statistics<span style="color:#f92672">.</span>mean(<span style="color:#f92672">-</span>(y <span style="color:#f92672">*</span> p<span style="color:#f92672">.</span>clamp_min(<span style="color:#ae81ff">1e-12</span>)<span style="color:#f92672">.</span>log())<span style="color:#f92672">.</span>sum()<span style="color:#f92672">.</span>item() <span style="color:#66d9ef">for</span> _, p, y <span style="color:#f92672">in</span> rows),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;brier&#34;</span>: statistics<span style="color:#f92672">.</span>mean(((p <span style="color:#f92672">-</span> y) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>sum()<span style="color:#f92672">.</span>item() <span style="color:#66d9ef">for</span> _, p, y <span style="color:#f92672">in</span> rows),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;ece&#34;</span>: ece(conf, correct)}
</span></span><span style="display:flex;"><span>    ordinal <span style="color:#f92672">=</span> [(p, y) <span style="color:#66d9ef">for</span> kind, p, y <span style="color:#f92672">in</span> rows <span style="color:#66d9ef">if</span> kind <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ordinal:
</span></span><span style="display:flex;"><span>        out[<span style="color:#e6db74">&#34;rps&#34;</span>] <span style="color:#f92672">=</span> statistics<span style="color:#f92672">.</span>mean((((p<span style="color:#f92672">.</span>cumsum(<span style="color:#ae81ff">0</span>) <span style="color:#f92672">-</span> y<span style="color:#f92672">.</span>cumsum(<span style="color:#ae81ff">0</span>)) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>sum() <span style="color:#f92672">/</span> (len(p) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>))<span style="color:#f92672">.</span>item()
</span></span><span style="display:flex;"><span>                                     <span style="color:#66d9ef">for</span> p, y <span style="color:#f92672">in</span> ordinal)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> out
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">metrics</span>(records, logits, temps):
</span></span><span style="display:flex;"><span>    rows <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r, z <span style="color:#f92672">in</span> zip(records, logits):
</span></span><span style="display:flex;"><span>        kind <span style="color:#f92672">=</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>]
</span></span><span style="display:flex;"><span>        p <span style="color:#f92672">=</span> (z <span style="color:#f92672">/</span> temps<span style="color:#f92672">.</span>get(kind, <span style="color:#ae81ff">1.0</span>))<span style="color:#f92672">.</span>softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>        rows<span style="color:#f92672">.</span>append((kind, p, torch<span style="color:#f92672">.</span>tensor(target(r, options(r[<span style="color:#e6db74">&#34;question&#34;</span>])))))
</span></span><span style="display:flex;"><span>    report <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;all&#34;</span>: summary(rows)}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> kind <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> any(row[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">==</span> kind <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> rows):
</span></span><span style="display:flex;"><span>            report[kind] <span style="color:#f92672">=</span> summary([row <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> rows <span style="color:#66d9ef">if</span> row[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">==</span> kind])
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> report
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">fit_temperatures</span>(records, logits):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;One temperature per question type, chosen to minimise log loss (fit on dev data only).&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    grid, temps <span style="color:#f92672">=</span> [round(<span style="color:#ae81ff">0.3</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.05</span> <span style="color:#f92672">*</span> i, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">55</span>)], {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> kind <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>):
</span></span><span style="display:flex;"><span>        pairs <span style="color:#f92672">=</span> [(z, torch<span style="color:#f92672">.</span>tensor(target(r, options(r[<span style="color:#e6db74">&#34;question&#34;</span>]))))
</span></span><span style="display:flex;"><span>                 <span style="color:#66d9ef">for</span> r, z <span style="color:#f92672">in</span> zip(records, logits) <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> kind]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> pairs:
</span></span><span style="display:flex;"><span>            temps[kind] <span style="color:#f92672">=</span> min(grid, key<span style="color:#f92672">=</span><span style="color:#66d9ef">lambda</span> t: sum(<span style="color:#f92672">-</span>(y <span style="color:#f92672">*</span> (z <span style="color:#f92672">/</span> t)<span style="color:#f92672">.</span>log_softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>))<span style="color:#f92672">.</span>sum()<span style="color:#f92672">.</span>item()
</span></span><span style="display:flex;"><span>                                                      <span style="color:#66d9ef">for</span> z, y <span style="color:#f92672">in</span> pairs))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> temps
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.no_grad</span>()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">latency_ms</span>(model, tok, letter_ids, records, n<span style="color:#f92672">=</span><span style="color:#ae81ff">50</span>):
</span></span><span style="display:flex;"><span>    times <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> records[:n]:
</span></span><span style="display:flex;"><span>        k <span style="color:#f92672">=</span> len(options(r[<span style="color:#e6db74">&#34;question&#34;</span>]))
</span></span><span style="display:flex;"><span>        text <span style="color:#f92672">=</span> prompt(tok, r, list(range(k)))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available():
</span></span><span style="display:flex;"><span>            torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>synchronize()
</span></span><span style="display:flex;"><span>        start <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter()
</span></span><span style="display:flex;"><span>        option_logits(model, tok, letter_ids, [text], [k], model<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available():
</span></span><span style="display:flex;"><span>            torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>synchronize()
</span></span><span style="display:flex;"><span>        times<span style="color:#f92672">.</span>append((time<span style="color:#f92672">.</span>perf_counter() <span style="color:#f92672">-</span> start) <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>)
</span></span><span style="display:flex;"><span>    times<span style="color:#f92672">.</span>sort()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;p50&#34;</span>: times[len(times) <span style="color:#f92672">//</span> <span style="color:#ae81ff">2</span>], <span style="color:#e6db74">&#34;p95&#34;</span>: times[min(len(times) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>, int(<span style="color:#ae81ff">0.95</span> <span style="color:#f92672">*</span> len(times)))]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>():
</span></span><span style="display:flex;"><span>    ap <span style="color:#f92672">=</span> argparse<span style="color:#f92672">.</span>ArgumentParser()
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--base&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Qwen/Qwen3.5-4B&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--adapter&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;LoRA directory; omit it to score the raw base model&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--data&#34;</span>, nargs<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;+&#34;</span>, required<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--fit-temperature&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;fit temperatures on --data (dev only) and save them here&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--temperatures&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;temperatures fitted earlier&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--batch-size&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>)
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> ap<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    tok, model, letter_ids <span style="color:#f92672">=</span> load(args<span style="color:#f92672">.</span>base)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>adapter:
</span></span><span style="display:flex;"><span>        model <span style="color:#f92672">=</span> PeftModel<span style="color:#f92672">.</span>from_pretrained(model, args<span style="color:#f92672">.</span>adapter)<span style="color:#f92672">.</span>merge_and_unload()
</span></span><span style="display:flex;"><span>    model<span style="color:#f92672">.</span>to(<span style="color:#e6db74">&#34;cuda&#34;</span> <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available() <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;cpu&#34;</span>)<span style="color:#f92672">.</span>eval()
</span></span><span style="display:flex;"><span>    records <span style="color:#f92672">=</span> [json<span style="color:#f92672">.</span>loads(line) <span style="color:#66d9ef">for</span> path <span style="color:#f92672">in</span> args<span style="color:#f92672">.</span>data <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(path, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>)]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    logits <span style="color:#f92672">=</span> predict(model, tok, letter_ids, records, args<span style="color:#f92672">.</span>batch_size)
</span></span><span style="display:flex;"><span>    temps <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(args<span style="color:#f92672">.</span>temperatures)) <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>temperatures <span style="color:#66d9ef">else</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>fit_temperature:
</span></span><span style="display:flex;"><span>        temps <span style="color:#f92672">=</span> fit_temperatures(records, logits)
</span></span><span style="display:flex;"><span>        json<span style="color:#f92672">.</span>dump(temps, open(args<span style="color:#f92672">.</span>fit_temperature, <span style="color:#e6db74">&#34;w&#34;</span>), indent<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    flipped <span style="color:#f92672">=</span> predict(model, tok, letter_ids, records, args<span style="color:#f92672">.</span>batch_size, reverse<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    flips <span style="color:#f92672">=</span> [bool(a<span style="color:#f92672">.</span>argmax() <span style="color:#f92672">!=</span> b<span style="color:#f92672">.</span>argmax())
</span></span><span style="display:flex;"><span>             <span style="color:#66d9ef">for</span> r, a, b <span style="color:#f92672">in</span> zip(records, logits, flipped) <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;score&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    report <span style="color:#f92672">=</span> metrics(records, logits, temps)
</span></span><span style="display:flex;"><span>    report[<span style="color:#e6db74">&#34;flip_rate_reversed_options&#34;</span>] <span style="color:#f92672">=</span> sum(flips) <span style="color:#f92672">/</span> max(<span style="color:#ae81ff">1</span>, len(flips))
</span></span><span style="display:flex;"><span>    report[<span style="color:#e6db74">&#34;temperatures&#34;</span>] <span style="color:#f92672">=</span> temps
</span></span><span style="display:flex;"><span>    report[<span style="color:#e6db74">&#34;latency_ms&#34;</span>] <span style="color:#f92672">=</span> latency_ms(model, tok, letter_ids, records)
</span></span><span style="display:flex;"><span>    print(json<span style="color:#f92672">.</span>dumps(report, indent<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main()
</span></span></code></pre></div><p>典型的用法分三步：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># 1) 零样本基线：不加适配器，直接读底座模型的字母概率，先知道起点</span>
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --data data/dev.jsonl data/heldout_tasks.jsonl
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 2) 在开发集上评测训练好的模型，并按题型拟合温度</span>
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --adapter out/my-jev/epoch2 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --data data/dev.jsonl --fit-temperature out/my-jev/temperatures.json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 3) 带上温度，在留出任务和 JevBench 公开题上打分（温度只能在开发集上拟合）</span>
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --adapter out/my-jev/epoch2 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --temperatures out/my-jev/temperatures.json --data data/heldout_tasks.jsonl
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --adapter out/my-jev/epoch2 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --temperatures out/my-jev/temperatures.json --data eval/easy.jsonl eval/original.jsonl eval/hard.jsonl
</span></span></code></pre></div><p>作为示例，下面是 Qwen3.5-0.8B 原始模型（不加适配器）在 JevBench 公开的 easy 和 original 两组、共 120 道题上的结果（节选，保留三位小数）。这是在我的 Mac 上用 CPU 跑的，延迟没有参考意义，就不列出来了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;all&#34;</span>:    {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">120</span>, <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.642</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.488</span>, <span style="color:#f92672">&#34;log_loss&#34;</span>: <span style="color:#ae81ff">1.326</span>, <span style="color:#f92672">&#34;brier&#34;</span>: <span style="color:#ae81ff">0.523</span>, <span style="color:#f92672">&#34;ece&#34;</span>: <span style="color:#ae81ff">0.108</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;choice&#34;</span>: {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">72</span>,  <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.694</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.614</span>, <span style="color:#f92672">&#34;ece&#34;</span>: <span style="color:#ae81ff">0.117</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;noul&#34;</span>:   {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">36</span>,  <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.583</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.167</span>, <span style="color:#f92672">&#34;ece&#34;</span>: <span style="color:#ae81ff">0.220</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;score&#34;</span>:  {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">12</span>,  <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.500</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.333</span>, <span style="color:#f92672">&#34;rps&#34;</span>: <span style="color:#ae81ff">0.151</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;flip_rate_reversed_options&#34;</span>: <span style="color:#ae81ff">0.620</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>几个值得注意的地方：</p>
<ul>
<li>noul 题的准确率只有 58.3%，去掉随机猜中的部分只剩 16.7%，基本是在猜；</li>
<li><strong>选项倒序翻转率高达 62%</strong>：只是把选项顺序倒过来，六成以上题目的答案就变了。我还单独试了一道题：state 是“结账页面对所有客户返回 HTTP 500”，问题是“是否有生产系统宕机”，选项按 yes、no 排列时 P(yes) 是 0.18，换成 no、yes 排列就变成了 0.45。小模型的位置偏差比想象中严重得多，这正是训练时要不断打乱选项、评测时一定要报告这个指标的原因；</li>
<li>作为对照，H2O-Lightning-4B 在同样这 120 道题上答对了 119 道。两者规模不同，差距不能全部归功于微调，但足以说明决策微调要补的课有多少。</li>
</ul>
<h3 id="3-和-jevbench-上的模型同场比较">3. 和 JevBench 上的模型同场比较</h3>
<p>JevBench 的 GitHub 仓库公开了 231 道题（<code>datasets/public/</code> 下的 easy 48 道、original 72 道、hard 111 道），格式和本文的数据格式一致，<code>evaluate.py</code> 可以直接读取。作为参照，H2O-Lightning-4B 在这 231 道题上答对 205 道（88.7%）：easy 48/48，original 71/72，hard 86/111。</p>
<p>不过要注意三点：</p>
<ol>
<li><strong>公开题只是参考</strong>：榜单分数基于每期 1500 道题（1200 道不公开的封存题加 300 道公开题，智能分里两部分各占一半），还做了去随机、分难度加权和公开 / 封存差距惩罚，公开题上的准确率不能直接换算成榜单分数；</li>
<li><strong>绝不能拿公开题训练</strong>：JevBench 要求在公开题上训练过的模型必须主动声明，<code>prepare_data.py</code> 里的去污染就是为此准备的；</li>
<li><strong>想上榜就提交</strong>：在 JevBench 网站提交模型后，会由维护者在他们自己的 GPU 上跑完整的封存题。</li>
</ol>
<p>如果想完全按 JevBench 官方的方式测，可以先把模型包装成和 Jev 一样的 HTTP 接口，再用官方的评测工具去调用它。</p>
<h3 id="4-服务化兼容-jev-的-v1systemone">4. 服务化：兼容 Jev 的 /v1/systemone</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># serve.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;A minimal /v1/systemone server (Jev wire format) in front of the fine-tuned model.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> fastapi <span style="color:#f92672">import</span> FastAPI, HTTPException
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> peft <span style="color:#f92672">import</span> PeftModel
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> decision_format <span style="color:#f92672">import</span> LETTERS, load, option_logits, options, prompt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>BASE <span style="color:#f92672">=</span> os<span style="color:#f92672">.</span>environ<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;BASE_MODEL&#34;</span>, <span style="color:#e6db74">&#34;Qwen/Qwen3.5-4B&#34;</span>)
</span></span><span style="display:flex;"><span>ADAPTER <span style="color:#f92672">=</span> os<span style="color:#f92672">.</span>environ<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;ADAPTER&#34;</span>, <span style="color:#e6db74">&#34;out/my-jev/epoch2&#34;</span>)
</span></span><span style="display:flex;"><span>TEMPS <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#34;TEMPERATURES&#34;</span>])) <span style="color:#66d9ef">if</span> os<span style="color:#f92672">.</span>environ<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;TEMPERATURES&#34;</span>) <span style="color:#66d9ef">else</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>tok, model, letter_ids <span style="color:#f92672">=</span> load(BASE)
</span></span><span style="display:flex;"><span>model <span style="color:#f92672">=</span> PeftModel<span style="color:#f92672">.</span>from_pretrained(model, ADAPTER)<span style="color:#f92672">.</span>merge_and_unload()
</span></span><span style="display:flex;"><span>model<span style="color:#f92672">.</span>to(<span style="color:#e6db74">&#34;cuda&#34;</span> <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available() <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;cpu&#34;</span>)<span style="color:#f92672">.</span>eval()
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.post</span>(<span style="color:#e6db74">&#34;/v1/systemone&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.no_grad</span>()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">systemone</span>(body: dict):
</span></span><span style="display:flex;"><span>    questions <span style="color:#f92672">=</span> body<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;questions&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> isinstance(questions, dict) <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> <span style="color:#ae81ff">0</span> <span style="color:#f92672">&lt;</span> len(questions) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">32</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> HTTPException(<span style="color:#ae81ff">422</span>, <span style="color:#e6db74">&#34;questions must be an object with 1 to 32 entries&#34;</span>)
</span></span><span style="display:flex;"><span>    names, prompts, ks <span style="color:#f92672">=</span> list(questions), [], []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> name <span style="color:#f92672">in</span> names:
</span></span><span style="display:flex;"><span>        q <span style="color:#f92672">=</span> questions[name]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            k <span style="color:#f92672">=</span> len(options(q))
</span></span><span style="display:flex;"><span>            ok <span style="color:#f92672">=</span> q[<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>) <span style="color:#f92672">and</span> isinstance(q[<span style="color:#e6db74">&#34;instructions&#34;</span>], str)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> (<span style="color:#a6e22e">KeyError</span>, <span style="color:#a6e22e">TypeError</span>, <span style="color:#a6e22e">AttributeError</span>):
</span></span><span style="display:flex;"><span>            ok <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> ok <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">&lt;=</span> k <span style="color:#f92672">&lt;=</span> len(LETTERS):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> HTTPException(<span style="color:#ae81ff">422</span>, <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>name<span style="color:#e6db74">}</span><span style="color:#e6db74">: malformed question or unsupported option count&#34;</span>)
</span></span><span style="display:flex;"><span>        prompts<span style="color:#f92672">.</span>append(prompt(tok, {<span style="color:#e6db74">&#34;state&#34;</span>: body<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;state&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>), <span style="color:#e6db74">&#34;question&#34;</span>: q}, list(range(k))))
</span></span><span style="display:flex;"><span>        ks<span style="color:#f92672">.</span>append(k)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    z, _ <span style="color:#f92672">=</span> option_logits(model, tok, letter_ids, prompts, ks, model<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>    answers <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row, name <span style="color:#f92672">in</span> enumerate(names):
</span></span><span style="display:flex;"><span>        q, k <span style="color:#f92672">=</span> questions[name], ks[row]
</span></span><span style="display:flex;"><span>        p <span style="color:#f92672">=</span> (z[row, :k] <span style="color:#f92672">/</span> TEMPS<span style="color:#f92672">.</span>get(q[<span style="color:#e6db74">&#34;type&#34;</span>], <span style="color:#ae81ff">1.0</span>))<span style="color:#f92672">.</span>softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>tolist()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> q[<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;noul&#34;</span>:
</span></span><span style="display:flex;"><span>            answers[name] <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>: p[<span style="color:#ae81ff">0</span>]}  <span style="color:#75715e"># P(yes)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> q[<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>:
</span></span><span style="display:flex;"><span>            answers[name] <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>: sum(i <span style="color:#f92672">*</span> x <span style="color:#66d9ef">for</span> i, x <span style="color:#f92672">in</span> enumerate(p)),
</span></span><span style="display:flex;"><span>                             <span style="color:#e6db74">&#34;probabilities&#34;</span>: {str(i): x <span style="color:#66d9ef">for</span> i, x <span style="color:#f92672">in</span> enumerate(p)}}
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            probs <span style="color:#f92672">=</span> {key: x <span style="color:#66d9ef">for</span> (key, _), x <span style="color:#f92672">in</span> zip(options(q), p)}
</span></span><span style="display:flex;"><span>            best <span style="color:#f92672">=</span> max(probs, key<span style="color:#f92672">=</span>probs<span style="color:#f92672">.</span>get)
</span></span><span style="display:flex;"><span>            answers[name] <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;choice&#34;</span>: best, <span style="color:#e6db74">&#34;probabilities&#34;</span>: probs,
</span></span><span style="display:flex;"><span>                             <span style="color:#e6db74">&#34;confidence&#34;</span>: (k <span style="color:#f92672">*</span> probs[best] <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> (k <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>)}
</span></span><span style="display:flex;"><span>    used <span style="color:#f92672">=</span> sum(len(ids) <span style="color:#66d9ef">for</span> ids <span style="color:#f92672">in</span> tok(prompts, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>input_ids)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;answers&#34;</span>: answers, <span style="color:#e6db74">&#34;usage&#34;</span>: {<span style="color:#e6db74">&#34;input_tokens&#34;</span>: used, <span style="color:#e6db74">&#34;output_tokens&#34;</span>: <span style="color:#ae81ff">0</span>}}
</span></span></code></pre></div><p>启动服务，然后用 curl 验证一下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ADAPTER<span style="color:#f92672">=</span>out/my-jev/epoch2 TEMPERATURES<span style="color:#f92672">=</span>out/my-jev/temperatures.json <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  uvicorn serve:app --host 127.0.0.1 --port <span style="color:#ae81ff">8741</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>curl -s http://127.0.0.1:8741/v1/systemone -H <span style="color:#e6db74">&#39;Content-Type: application/json&#39;</span> -d <span style="color:#e6db74">&#39;{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  &#34;state&#34;: &#34;Checkout returns HTTP 500 for every customer since 09:10.&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  &#34;questions&#34;: {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;team&#34;: {&#34;type&#34;: &#34;choice&#34;, &#34;instructions&#34;: &#34;Which team should handle this?&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             &#34;criteria&#34;: {&#34;billing&#34;: &#34;charges and refunds&#34;, &#34;engineering&#34;: &#34;bugs and outages&#34;}},
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;outage&#34;: {&#34;type&#34;: &#34;noul&#34;, &#34;instructions&#34;: &#34;Is a production system down?&#34;}
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">}&#39;</span>
</span></span></code></pre></div><p>返回结构沿用 Jev 的格式：每个答案都带 <code>type</code>，choice 返回 <code>choice</code>、<code>probabilities</code> 和按 Jev 公式 <code>(K·p_max − 1)/(K − 1)</code> 计算的 <code>confidence</code>，score 返回期望等级和以等级下标为键的概率，noul 直接返回 P(yes)，<code>usage</code> 里只有输入 token。这正是 JevBench 官方评测工具（<code>typesafe</code> 适配器）解析的字段，所以可以直接拿它来测，已有的 Jev 客户端通常也只需要改一下 <code>baseUrl</code>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone https://github.com/fstandhartinger/jevbench <span style="color:#f92672">&amp;&amp;</span> cd jevbench
</span></span><span style="display:flex;"><span>python -m jevbench.cli run <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tasks datasets/public/easy.jsonl,datasets/public/original.jsonl,datasets/public/hard.jsonl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --adapter typesafe --endpoint http://127.0.0.1:8741 --key-env <span style="color:#e6db74">&#39;&#39;</span> --model my-jev <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --cost-basis self_hosted_gpu --reserve-usd <span style="color:#ae81ff">0</span> --delay-s <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --results RUN/results.jsonl --raw-dir RUN/raw --ledger RUN/ledger.jsonl --manifest RUN/manifest.json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 汇总成绩</span>
</span></span><span style="display:flex;"><span>python -m jevbench.cli summarize <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tasks datasets/public/easy.jsonl,datasets/public/original.jsonl,datasets/public/hard.jsonl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --results RUN/results.jsonl --public-export RUN/summary.json
</span></span></code></pre></div><p>这个服务只是为了评测和演示：它一次处理一个请求，没有认证。正式上线时，推荐像 H2O-Lightning-4B 那样把合并后的模型交给 vLLM（连续批处理、前缀缓存），前面放一个很薄的协议转换层，并加上认证、限流和请求大小限制，不要直接暴露在公网上。</p>
<h2 id="七调整思路分数不理想怎么办">七、调整思路：分数不理想怎么办</h2>
<h3 id="1-先有基线再按切片找问题">1. 先有基线，再按切片找问题</h3>
<p>动手调参之前，先确认两件事：一是零样本基线（不微调、直接读底座的字母概率）是多少，二是“永远猜最常见答案”能拿多少分。训练后的模型必须和这两个数字比，否则很容易被虚高的准确率骗到。</p>
<p>然后按切片拆开看：题型、任务、语言、选项数、state 长度、置信度区间。总分掩盖的问题，往往在切片里一目了然。H2O 把这叫作“找短板”（gap hunting）：按主题和题型和其他系统对比，跑对抗性题集，然后针对性地补充经过验证的新数据。</p>
<h3 id="2-症状与对策">2. 症状与对策</h3>
<table>
	<thead>
			<tr>
					<th>症状</th>
					<th>可能的原因</th>
					<th>对策</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>训练任务上很好，留出任务上很差</td>
					<td>过拟合了训练任务的措辞和模板</td>
					<td>增加任务种类和问法；减少 epoch、降低学习率；用留出任务的 log loss 做早停</td>
			</tr>
			<tr>
					<td>准确率还行，但 ECE 很高</td>
					<td>训练过头导致过度自信，或标签有噪声</td>
					<td>温度缩放；减少 epoch；有争议的样本改用软标签</td>
			</tr>
			<tr>
					<td>选项倒序后答案改变</td>
					<td>位置偏差</td>
					<td>训练时每步打乱选项；推理时用多种顺序取平均（Laya 实测可把顺序导致的答案变化从 16.3% 降到 6.1%）；或改用选项之间互不可见的并行布局</td>
			</tr>
			<tr>
					<td>noul 总是偏向“是”或“否”</td>
					<td>标签词偏置、正反样本不均衡</td>
					<td>平衡正反样本；构造“问题 + 否定问题”成对的数据；检查 <code>criteria</code> 的措辞是否带有倾向</td>
			</tr>
			<tr>
					<td>score 误差大</td>
					<td>有序数据少，等级描述模糊</td>
					<td>增加有序打分数据，提高 RPS 的权重；每个等级写清楚判定标准</td>
			</tr>
			<tr>
					<td>选项一多就不准</td>
					<td>选项超过字母数，或选项彼此相似</td>
					<td>先用 embedding 或分组淘汰缩小候选（Laya 的分组淘汰把 BANKING77 从 0.430 提到 0.610）；或拆成“粗分类 + 细分类”两级问题</td>
			</tr>
			<tr>
					<td>长文档准确率下降</td>
					<td>训练时没见过长样本，或被截断</td>
					<td>加一个专门的长文档训练阶段（H2O 的第二阶段就是这么做的）；检查截断比例，调大 <code>max_state_tokens</code></td>
			</tr>
			<tr>
					<td>计数、日期、算术题出错</td>
					<td>语言模型的固有短板</td>
					<td>补充程序生成的可验证数据；更根本的办法是把计算交给代码，只把语义判断留给模型</td>
			</tr>
			<tr>
					<td>中文等非英语表现差</td>
					<td>训练数据以英文为主</td>
					<td>补充对应语言的数据；选多语言能力更强的底座；分语言看指标</td>
			</tr>
			<tr>
					<td>微调后零样本能力反而下降</td>
					<td>灾难性遗忘</td>
					<td>降低学习率和 LoRA rank；混入少量通用指令数据；对比微调前后在留出任务上的分数</td>
			</tr>
			<tr>
					<td>延迟太高</td>
					<td>底座太大，提示词太长</td>
					<td>换更小的底座；精简 state；开启前缀缓存；做 FP8 量化（H2O 实测 FP8 版本只少答对 3 道题，差异在统计上不显著）</td>
			</tr>
	</tbody>
</table>
<h3 id="3-让迭代形成闭环">3. 让迭代形成闭环</h3>
<p><img alt="训练、标定、评测、分析、补数据的迭代飞轮" loading="lazy" src="/zh/blog/2026/train-your-own-jev/6-eval-and-iterate.png"></p>
<p>最后是几条流程上的建议，它们比任何一个超参数都重要：</p>
<ol>
<li><strong>上线标准先写下来</strong>：比如“封存集上的去随机准确率不低于上一版，温度缩放后 ECE 不高于 0.05，选项倒序翻转率不高于 5%，p95 延迟不超过 100 ms”。先定标准再看结果，才能避免“看着结果改标准”。</li>
<li><strong>封存集只在发版时用一次</strong>：日常调参只看开发集和留出任务，封存集一旦被反复使用，它就变成了另一个开发集。</li>
<li><strong>每次只改一个变量</strong>：数据、超参数、底座同时改，就无法判断哪一个起了作用。</li>
<li><strong>固定版本，持续监控</strong>：底座或数据变化之后，温度和阈值都要重新标定；上线后持续记录概率分布并抽样复核，一旦分布漂移就回到飞轮的起点。</li>
</ol>
<h2 id="总结">总结</h2>
<p>把全文浓缩成几句话：</p>
<ol>
<li><strong>路线</strong>：想要 Jev 那样的零样本通才，走“解码器 + 选项字母”，从 Qwen3.5-4B + LoRA 起步；只服务固定任务、追求极致速度，编码器路线依然划算。</li>
<li><strong>目标</strong>：用严格适当评分规则训练，再用温度缩放校准。RLCD 是其中一种实现方式，而不是魔法。</li>
<li><strong>数据</strong>：任务的多样性、标签的质量、严格的去污染和按任务切分，比模型结构重要得多。</li>
<li><strong>算力</strong>：4B 模型的一次 LoRA 训练，单张 H100 几个小时、十几美元，钱主要花在一轮又一轮的迭代上。</li>
<li><strong>评测</strong>：准确率之外还要看校准、位置偏差、阈值曲线和延迟；用 JevBench 公开题对标，用自己的封存集验收。</li>
</ol>
<p>Jev 定义了“决策模型”这个品类，而短短一个月里，开源社区已经复刻出了上百个替代品，其中最好的一批在榜单上已经和 Jev 不相上下。训练一个自己的决策模型，已经从“能不能做到”变成了“值不值得做”。如果你有自己的数据、有合规要求，或者对延迟和成本格外敏感，答案多半是值得的。</p>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://benchmarkheaven.com/jev-models">JevBench：开源权重决策模型榜单</a></li>
<li><a href="https://benchmarkheaven.com/jev-models/methodology">JevBench：评测方法说明</a></li>
<li><a href="https://github.com/fstandhartinger/jevbench">JevBench：评测工具与公开题集（GitHub）</a></li>
<li><a href="https://huggingface.co/h2oai/h2o-lightning-4b">H2O-Lightning-4B 模型卡</a></li>
<li><a href="https://github.com/NandhaKishorM/laya">Laya GitHub 仓库</a></li>
<li><a href="https://dev.to/nandakishor_m_6cc0adfde9f/i-built-non-autoregressive-decision-models-a-year-ago-then-a-frontier-lab-called-it-a-18me">Nandakishor M：I Built Non-Autoregressive Decision Models a Year Ago. Then a Frontier Lab Called It a &ldquo;Breakthrough&rdquo;.</a></li>
<li><a href="https://huggingface.co/Qwen/Qwen3.5-4B">Qwen3.5-4B 模型卡</a></li>
<li><a href="https://doi.org/10.1198/016214506000001437">Gneiting &amp; Raftery：Strictly Proper Scoring Rules, Prediction, and Estimation（2007）</a></li>
<li><a href="https://arxiv.org/abs/1706.04599">Guo et al.：On Calibration of Modern Neural Networks（2017）</a></li>
<li><a href="https://arxiv.org/abs/2207.05221">Kadavath et al.：Language Models (Mostly) Know What They Know（2022）</a></li>
<li><a href="https://arxiv.org/abs/2309.03882">Zheng et al.：Large Language Models Are Not Robust Multiple Choice Selectors（2023）</a></li>
<li><a href="https://arxiv.org/abs/2109.01652">Wei et al.：Finetuned Language Models Are Zero-Shot Learners（2021）</a></li>
<li><a href="https://arxiv.org/abs/2106.09685">Hu et al.：LoRA: Low-Rank Adaptation of Large Language Models（2021）</a></li>
<li><a href="/zh/blog/2026/jev-vs-laya/">Jev vs Laya：闭源与开源 System One 决策模型的对比与选型</a></li>
<li><a href="/zh/blog/2026/microsoft-decision-1/">Microsoft-Decision-1 详解：微软入局决策模型，与 Jev、Laya 的三方对比</a></li>
<li><a href="/zh/blog/2025/llm-fine-tuning/">玩转大语言模型：大语言模型（LLM）微调主流方式和使用场景全面解析对比</a></li>
<li><a href="/zh/blog/2026/ppo-vs-grpo/">PPO vs GRPO：两种主流 LLM 强化学习范式的对比与选择</a></li>
</ul>
]]></content:encoded></item><item><title>Microsoft-Decision-1 详解：微软入局决策模型，与 Jev、Laya 的三方对比</title><link>https://wilsonwu.me/zh/blog/2026/microsoft-decision-1/</link><pubDate>Sat, 10 Oct 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/microsoft-decision-1/</guid><description>&lt;p&gt;&lt;img alt="Microsoft-Decision-1、Jev 与 Laya" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/microsoft-decision-1/1-microsoft-decision-1.png"&gt;&lt;/p&gt;
&lt;h2 id="引言"&gt;引言&lt;/h2&gt;
&lt;p&gt;上个月我写了一篇 &lt;a href="https://wilsonwu.me/zh/blog/2026/jev-vs-laya/"&gt;Jev vs Laya&lt;/a&gt;，对比了 TypeSafe AI 的闭源决策模型 Jev 和社区开源的 Laya。那时“决策模型”还是一个刚刚诞生的新品类：9 月 15 日 Jev 发布，三天后 Laya 开源。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="Microsoft-Decision-1、Jev 与 Laya" loading="lazy" src="/zh/blog/2026/microsoft-decision-1/1-microsoft-decision-1.png"></p>
<h2 id="引言">引言</h2>
<p>上个月我写了一篇 <a href="/zh/blog/2026/jev-vs-laya/">Jev vs Laya</a>，对比了 TypeSafe AI 的闭源决策模型 Jev 和社区开源的 Laya。那时“决策模型”还是一个刚刚诞生的新品类：9 月 15 日 Jev 发布，三天后 Laya 开源。</p>
<p>仅仅三周之后，这个赛道就挤满了玩家：10 月 6 日，OpenAI 开放了基于 GPT-6 Luna 的 Decisions API；Liquid AI、Inception 等公司也陆续推出了自己的决策 API；第三方榜单 JevBench 上参与排名的开源权重系统已经超过 140 个。10 月 9 日，微软也正式入局，发布了 <strong>Microsoft-Decision-1</strong>。</p>
<p>和其他入局者相比，Microsoft-Decision-1 有几个特别值得关注的地方：</p>
<ul>
<li><strong>出自大厂，底座却是开源模型</strong>：它基于阿里开源的 Qwen3.5-9B 后训练而来，微软还表示后续会迁移到自家的 MAI 系列和 OpenAI 的模型上；</li>
<li><strong>价格与 Jev 完全一致</strong>：输入每百万 token 0.042 美元，输出免费；</li>
<li><strong>接口几乎与 Jev 兼容</strong>：同样是 state + questions，同样是 noul / choice / score 三种问题类型；</li>
<li><strong>官方数据很亮眼</strong>：在 36 项基准、近 15 万道题的对比中平均准确率最高，中位延迟比 GPT-6 Sol 快约 35 倍。</li>
</ul>
<p>但官方数据只是故事的一半。微软发布的第二天，第三方榜单 JevBench 就完成了独立实测，结论并不完全一致。本文将从以下几个方面展开：</p>
<ol>
<li>Microsoft-Decision-1 是什么，又是怎么做出来的</li>
<li>它与 Jev、Laya 的技术路线和接口有什么不同</li>
<li>官方基准与第三方实测应该怎么看</li>
<li>Microsoft-Decision-1 的优势与劣势</li>
<li>适用场景、选型建议与代码示例</li>
</ol>
<h2 id="一分钟回顾什么是决策模型">一分钟回顾：什么是决策模型</h2>
<p>决策模型（System One Model）不聊天，也不写文章，它只做一件事：<strong>接收一段状态（state）和一组预先定义好的问题（questions），直接返回带概率的类型化决策</strong>。三家都支持同样的三种问题原语：</p>
<table>
	<thead>
			<tr>
					<th>原语</th>
					<th>回答的问题</th>
					<th>返回内容</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>noul</code></td>
					<td>是不是？</td>
					<td>“是”的概率（0～1）</td>
			</tr>
			<tr>
					<td><code>choice</code></td>
					<td>选哪一个？</td>
					<td>选中的选项，以及每个选项的概率</td>
			</tr>
			<tr>
					<td><code>score</code></td>
					<td>有多少？</td>
					<td>有序等级上的分数，以及每个等级的概率</td>
			</tr>
	</tbody>
</table>
<p>由于答案空间被提前限定，模型不会返回格式错误或凭空编造的选项；校准过的概率则可以直接写进程序的 <code>if</code> 语句：高置信度自动执行，中置信度交给更强的模型复核，低置信度转人工。在一个 AI 系统里，比较合理的分工是：<strong>代码负责骨架，决策模型负责快速判断，大模型负责深度思考</strong>。</p>
<p>更详细的概念介绍和置信度分流的思路，可以参考上一篇，这里不再展开。</p>
<h2 id="microsoft-decision-1-是什么">Microsoft-Decision-1 是什么</h2>
<h3 id="1-基本信息">1. 基本信息</h3>
<p>2026 年 10 月 9 日，微软首席技术官办公室（Office of the CTO）软件工程副总裁 Achint Srivastava 在微软的技术博客 Command Line 上发布了 Microsoft-Decision-1。文章开头是这样定义决策模型的：</p>

<blockquote>
  <p>Unlike LLMs, which are designed to generate text or reason through complex problems, decision models are purpose-built to deliver structured outputs that software can immediately act on.</p>
<p>与生成文本、推理复杂问题的大模型不同，决策模型专门用来输出软件可以立即据此行动的结构化结果。</p>

</blockquote>
<p>微软给它的定位是一个“快速决策打分”（fast decision-scoring）模型，面向路由、分类、优先级排序、结果校验和工作流控制。主要信息如下：</p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>发布时间</td>
					<td>2026 年 10 月 9 日</td>
			</tr>
			<tr>
					<td>发布方</td>
					<td>微软首席技术官办公室（Office of the CTO）</td>
			</tr>
			<tr>
					<td>底座模型</td>
					<td>Qwen3.5-9B（后训练），计划迁移到 MAI 和 OpenAI 的模型</td>
			</tr>
			<tr>
					<td>模型类型</td>
					<td>决策、文本分类、零样本分类</td>
			</tr>
			<tr>
					<td>问题类型</td>
					<td><code>noul</code>（是 / 否）、<code>choice</code>（多选一）、<code>score</code>（有序打分），以及基于评分细则（rubric）评估 AI 回答和 Agent 行为</td>
			</tr>
			<tr>
					<td>输入</td>
					<td>仅支持文本（字符串或 JSON），单次请求最多 32K token，不支持图像、音频和视频</td>
			</tr>
			<tr>
					<td>输出</td>
					<td>选中项和每个候选项的校准概率，不生成解释或理由</td>
			</tr>
			<tr>
					<td>获取方式</td>
					<td>Microsoft Foundry 模型目录（模型名 <code>Microsoft-Decision-1</code>，版本 <code>1</code>），也可以通过 OpenRouter 调用</td>
			</tr>
			<tr>
					<td>部署类型</td>
					<td><code>GlobalStandard</code>，部分区域支持 <code>DataZoneStandard</code></td>
			</tr>
			<tr>
					<td>调用端点</td>
					<td><code>{Foundry 资源端点}/providers/microsoft/v1/systemone</code></td>
			</tr>
			<tr>
					<td>认证方式</td>
					<td>Microsoft Entra ID（推荐）或 API Key</td>
			</tr>
			<tr>
					<td>价格</td>
					<td>输入 0.042 美元 / 百万 token，输出免费</td>
			</tr>
	</tbody>
</table>
<p>按这个价格，如果每次判断平均消耗 1000 个输入 token，100 万次判断的费用约为 42 美元，和 Jev 完全相同。</p>
<h3 id="2-它是怎么做出来的">2. 它是怎么做出来的</h3>
<p>微软公开的技术细节不多，但有几个关键点：</p>
<ul>
<li><strong>在开源底座上后训练</strong>：微软在 Qwen3.5-9B 上做了后训练（post-training），让它学会“单次前向完成决策打分”（fast, single-pass decision scoring）。给定一组固定的候选答案，模型直接为每个选项输出一个校准过的概率，而不是逐 token 生成文字。</li>
<li><strong>底座可以替换</strong>：微软明确表示，很快会把它迁移（rebase）到其他底座上，包括微软自研的 MAI 系列和 OpenAI 的模型。</li>
<li><strong>没有公开的部分</strong>：训练数据、后训练方法和打分机制的具体实现都没有公开，权重也没有开放，只能以托管 API 的形式使用。</li>
</ul>
<p><img alt="Microsoft-Decision-1 的工作方式" loading="lazy" src="/zh/blog/2026/microsoft-decision-1/2-how-decision-1-works.png"></p>
<p>微软在博客里把打造一个可靠决策模型的难点归纳为五个方面，并给出了相应的自测结果：</p>
<table>
	<thead>
			<tr>
					<th>挑战</th>
					<th>为什么重要</th>
					<th>微软的自测结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>速度</td>
					<td>Agent 工作流中的决策往往是串行的，每个决策多 100 毫秒，20 个串行决策就会多出 2 秒</td>
					<td>P50 延迟约为 GPT-6 Sol 的 1/35</td>
			</tr>
			<tr>
					<td>泛化</td>
					<td>模型很容易在单个基准上过拟合，必须验证它在没见过的任务上是否同样可靠</td>
					<td>在数十个对训练保密的基准（路由、排序、长上下文、多语言、分布外任务、推理、安全）上评测；与 JevBench 榜单上的头部模型在另外 36 个公开和私有基准上对比，平均准确率最高</td>
			</tr>
			<tr>
					<td>鲁棒性</td>
					<td>生产环境里，state 和指令会被改写，选项会被重排，字段名会变化，这些等价变化不应该改变决策</td>
					<td>对同一请求做 8 种扰动，平均决策翻转率为 1.3%；改写选项描述、颠倒或打乱选项顺序时，翻转率为 0</td>
			</tr>
			<tr>
					<td>校准</td>
					<td>概率本身就是 API 的一部分，应用要依据它决定是执行、推迟还是送审，“90% 的把握”应该在代表性样本上 10 次对 9 次</td>
					<td>36 项基准上的校准分为 92.2（满分 100），在参评的 7 个模型中排第三，比 Jev 低 1.5 分</td>
			</tr>
			<tr>
					<td>安全</td>
					<td>既要识别有害请求，又不能误伤正常请求</td>
					<td>在 11 个基准、5250 条请求（有害内容、越狱攻击、提示词注入）上测试，能够拒绝有害行为，同时保持较高的可用性</td>
			</tr>
	</tbody>
</table>
<h3 id="3-微软内部的四个试点">3. 微软内部的四个试点</h3>
<p>微软还公开了几个内部试用的案例（均为微软自测）：</p>
<table>
	<thead>
			<tr>
					<th>团队 / 场景</th>
					<th>任务</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Xbox Research：数据标注</td>
					<td>把来自问卷、Steam 和 Twitter/X 的 1 万多条开放式反馈，归入研究员预先定义好的主题</td>
					<td>质量与 GPT-6 Sol 相当，速度快 14 倍以上，成本低约 200 倍</td>
			</tr>
			<tr>
					<td>Copilot：质量控制</td>
					<td>评估聊天回答和 Agent 回答的质量</td>
					<td>与 GPT-5.6 Luna 表现相当，速度快约 100 倍</td>
			</tr>
			<tr>
					<td>事故响应</td>
					<td>值班工程师处理线上事故时，从日志、工单、通话和消息中检索相关知识</td>
					<td>比基于大模型的方案更准、更快</td>
			</tr>
			<tr>
					<td>Microsoft Discovery：科学研究</td>
					<td>Agent 按评分细则评估上一轮实验的结果，调整方案并反复迭代，直到完成目标</td>
					<td>打分一致性是大模型打分的 46 倍，速度是 3 倍，自适应重规划整体提速近 4 倍</td>
			</tr>
	</tbody>
</table>
<p>其中 Xbox 的三个数据集，微软给出了更详细的对比数据：</p>
<table>
	<thead>
			<tr>
					<th>数据集</th>
					<th>样本量</th>
					<th>平均每条耗时（GPT-6 Sol → Decision-1）</th>
					<th>跑完一遍的成本（GPT-6 Sol → Decision-1）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>某赛车游戏的评论</td>
					<td>753 条</td>
					<td>2.8 秒 → 159 毫秒（约 18 倍）</td>
					<td>1.83 美元 → 0.009 美元（约 213 倍）</td>
			</tr>
			<tr>
					<td>某第一人称射击游戏的评论</td>
					<td>341 条</td>
					<td>2.6 秒 → 188 毫秒（约 14 倍）</td>
					<td>1.03 美元 → 0.005 美元（约 212 倍）</td>
			</tr>
			<tr>
					<td>某工作室直播的帖子</td>
					<td>1030 条</td>
					<td>2.6 秒 → 143 毫秒（约 18 倍）</td>
					<td>2.92 美元 → 0.013 美元（约 223 倍）</td>
			</tr>
	</tbody>
</table>
<p>按 100 万条文本折算，GPT-6 Sol 约需 2434 美元，Microsoft-Decision-1 约需 11 美元（微软注明成本为估算值）。两者读取的是同一份任务说明，输入 token 数相同，差距来自单价，以及 GPT-6 Sol 还需要为输出付费。</p>
<h3 id="4-适合哪些场景">4. 适合哪些场景</h3>
<p>微软在博客里列出了 16 个推荐场景，大致可以归为四类：</p>
<table>
	<thead>
			<tr>
					<th>类别</th>
					<th>场景</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Agent 控制</td>
					<td>评估 Agent 提出的下一步，决定继续、停止、重试，还是交给其他模型、工具或人；按照 Skill 中的规则选择下一步动作，而不必反复处理一长串指令；在电脑和界面操作中选择下一个 UI 动作；在机器人场景中从预定义的动作里做选择</td>
			</tr>
			<tr>
					<td>路由与分类</td>
					<td>模型路由、意图识别、事故分级与派单、内容分类与过滤、数据标注</td>
			</tr>
			<tr>
					<td>评估与校验</td>
					<td>AI 评审（按标准决定接受、修改还是拒绝一个回答）、数据校验、代码扫描、安全风险筛查（放行、拦截或升级）</td>
			</tr>
			<tr>
					<td>排序与推荐</td>
					<td>推荐、搜索相关性打分，以及在科学研究中筛选假设、化合物和实验</td>
			</tr>
	</tbody>
</table>
<p>其中最值得关注的是 <strong>Agent 控制</strong>。今天的 Agent 每走一步都要做大量“小判断”：上一步成功了吗？要不要重试？这个工具调用会不会动到生产数据？如果这些判断都交给大模型，延迟和成本会随着步数线性累积。用一个百毫秒级的决策模型给 Agent 加一道“闸门”，是这类模型最有想象空间的用法。微软还演示了一个电脑操作（computer use）的例子：在完成“买一个背包”的任务时，由 Microsoft-Decision-1 选择下一步界面操作，完成速度明显快于 GPT-6 Sol。</p>
<p><img alt="用决策模型给 Agent 加一道闸门" loading="lazy" src="/zh/blog/2026/microsoft-decision-1/3-agent-decision-gate.png"></p>
<p>另一个值得一提的场景是<strong>模型路由</strong>：根据请求的质量、成本和延迟要求，选择最合适的模型来处理。我之前写过一篇 <a href="/zh/blog/2026/llm-smart-router/">让模型自己选模型：Embedding 驱动的 LLM 智能路由机制</a>，决策模型提供了另一种思路：不再依赖向量相似度，而是直接把“该交给哪个模型”当成一道 <code>choice</code> 题来回答。</p>
<h2 id="三条技术路线">三条技术路线</h2>
<p><img alt="三种决策模型的技术路线" loading="lazy" src="/zh/blog/2026/microsoft-decision-1/4-three-architectures.png"></p>
<p>把三个模型放在一起，会发现它们代表了三条截然不同的技术路线：</p>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Jev</th>
					<th>Laya</th>
					<th>Microsoft-Decision-1</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>模型结构</td>
					<td>未公开（社区猜测接近 encoder 类模型）</td>
					<td>双向编码器（ModernBERT / mmBERT）+ 决策头</td>
					<td>解码器大模型 Qwen3.5-9B 后训练</td>
			</tr>
			<tr>
					<td>参数规模</td>
					<td>未公开</td>
					<td>322M～421M</td>
					<td>约 9B</td>
			</tr>
			<tr>
					<td>训练方法</td>
					<td>RLCD（面向校准决策的强化学习，细节未公开）</td>
					<td>RLCD（严格适当评分规则 + 组基线策略梯度，已公开）</td>
					<td>面向单次决策打分的后训练（细节未公开）</td>
			</tr>
			<tr>
					<td>推理方式</td>
					<td>自研的并行采样器</td>
					<td>每个选项前放一个 <code>[MASK]</code>，单次前向打分</td>
					<td>单次前向打分</td>
			</tr>
			<tr>
					<td>权重</td>
					<td>闭源</td>
					<td>Apache 2.0 开源</td>
					<td>不开放（底座开源）</td>
			</tr>
	</tbody>
</table>
<p>有几点值得展开说说：</p>
<ol>
<li><strong>规模相差一个数量级以上</strong>。Laya 用 4 亿参数的编码器换取极致的速度和本地部署能力；Microsoft-Decision-1 用 90 亿参数的解码器换取更强的零样本泛化能力，代价是对算力的要求高得多，而且目前只能以云端托管的方式使用。</li>
<li><strong>底座正在变成可替换的零件</strong>。从 JevBench 榜单看，排名靠前的开源决策模型大多也是在 Qwen 或 Gemma 上后训练出来的，比如基于 Qwen3.5-4B 的 H2O-Lightning-4B、基于 Gemma-4-31B 的 Quyet-1.0-Large。微软直接选用阿里的开源模型做底座，又公开表示后续会换成 MAI 或 OpenAI 的模型，说明这类模型的竞争力主要来自后训练数据、评测体系和校准方法，而不是底座本身。</li>
<li><strong>“单次前向”已经是共识</strong>。无论是 Laya 的编码器还是 Microsoft-Decision-1 的解码器，核心思路都一样：不生成文本，一次前向计算就给出所有候选项的概率。这也是决策模型能比通用大模型快一到两个数量级的根本原因。</li>
</ol>
<h2 id="同一套接口三种后端">同一套接口，三种后端</h2>
<p><img alt="同一组请求，三种后端" loading="lazy" src="/zh/blog/2026/microsoft-decision-1/5-same-api-three-backends.png"></p>
<p>对开发者来说，一个非常实用的变化是：<strong>三者的请求格式几乎一致</strong>。</p>
<ul>
<li>Jev 的官方 API 是 <code>POST https://api.typesafe.ai/v1/systemone</code>；</li>
<li>Microsoft-Decision-1 的端点是 Foundry 资源下的 <code>/providers/microsoft/v1/systemone</code>，微软文档也直接说明，它的请求模式与 System One API 类似；</li>
<li>Laya 也提供了 <code>laya-serve</code>，在本地以同样的 <code>POST /v1/systemone</code> 协议对外服务，返回结构与 Jev 一致，已有的 Jev 客户端只需要修改 <code>baseUrl</code>。</li>
</ul>
<p>三者在细节上的差异如下：</p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>Microsoft-Decision-1</th>
					<th>Jev</th>
					<th>Laya（laya-serve）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>端点</td>
					<td><code>{Foundry 资源端点}/providers/microsoft/v1/systemone</code></td>
					<td><code>https://api.typesafe.ai/v1/systemone</code></td>
					<td><code>http://{主机}:8000/v1/systemone</code></td>
			</tr>
			<tr>
					<td>认证</td>
					<td>Entra ID 的 Bearer Token，或 <code>api-key</code> 请求头</td>
					<td><code>Authorization: Bearer {API Key}</code></td>
					<td>默认无认证，设置 <code>LAYA_API_KEY</code> 后使用 Bearer Token</td>
			</tr>
			<tr>
					<td><code>model</code> 字段</td>
					<td>部署名称（deployment name）</td>
					<td>模型版本或别名，如 <code>jev-1.13.0</code></td>
					<td>检查点名称，省略时由 Router 自动选择</td>
			</tr>
			<tr>
					<td>单次请求上限</td>
					<td>32K token</td>
					<td>64K token，其中 state 加最长的单个问题不超过 32K</td>
					<td>默认 512 / 1024 token，多语言检查点最高 8192</td>
			</tr>
			<tr>
					<td>批量</td>
					<td>同一个 state 可以带多个问题</td>
					<td>同左</td>
					<td>同左，另有 <code>/v1/systemone/batch</code>，一次最多 64 个 state</td>
			</tr>
	</tbody>
</table>
<p>这意味着 <code>state + questions</code> 风格的接口正在成为决策模型的事实标准，就像当年 OpenAI 的 Chat Completions 接口之于大模型。微软把价格定得和 Jev 一模一样、接口也几乎一致，很难不让人认为这是在刻意降低 Jev 用户的迁移门槛。对用户来说，切换成本越来越低；对厂商来说，护城河只能来自效果、延迟、单次成本、合规能力和生态。</p>
<h2 id="核心差异一览">核心差异一览</h2>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Microsoft-Decision-1</th>
					<th>Jev</th>
					<th>Laya</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>发布方</td>
					<td>微软</td>
					<td>TypeSafe AI（创业公司）</td>
					<td>Convai Innovations（独立研究者）</td>
			</tr>
			<tr>
					<td>发布时间</td>
					<td>2026 年 10 月 9 日</td>
					<td>2026 年 9 月 15 日</td>
					<td>2026 年 9 月 18 日</td>
			</tr>
			<tr>
					<td>开源</td>
					<td>权重不开放</td>
					<td>闭源</td>
					<td>Apache 2.0</td>
			</tr>
			<tr>
					<td>部署</td>
					<td>Azure 托管（Foundry），也可经 OpenRouter 调用</td>
					<td>TypeSafe 云端（美国西海岸）</td>
					<td>本地、私有云或离线环境</td>
			</tr>
			<tr>
					<td>架构与规模</td>
					<td>Qwen3.5-9B 后训练</td>
					<td>未公开</td>
					<td>编码器 + 决策头，322M～421M</td>
			</tr>
			<tr>
					<td>问题原语</td>
					<td>noul / choice / score + 评分细则</td>
					<td>noul / choice / score</td>
					<td>noul / choice / score</td>
			</tr>
			<tr>
					<td>上下文</td>
					<td>32K token</td>
					<td>64K token</td>
					<td>默认 512 / 1024 token</td>
			</tr>
			<tr>
					<td>零样本能力</td>
					<td>强</td>
					<td>强</td>
					<td>弱，需要微调</td>
			</tr>
			<tr>
					<td>定制方式</td>
					<td>调整 state、instructions 和选项</td>
					<td>调整 state、instructions 和 criteria</td>
					<td>可在自有数据上微调，并拟合校准温度</td>
			</tr>
			<tr>
					<td>官方延迟</td>
					<td>同区域 p50 约 85 ms</td>
					<td>70～500 ms</td>
					<td>T4 上单个问题约 33 ms</td>
			</tr>
			<tr>
					<td>价格</td>
					<td>输入 $0.042 / 百万 token，输出免费</td>
					<td>同左</td>
					<td>模型免费，自备算力</td>
			</tr>
			<tr>
					<td>企业集成</td>
					<td>Azure 订阅计费、Entra ID、RBAC、数据区域部署</td>
					<td>API Key，零数据保留仅面向企业客户</td>
					<td>完全自主可控</td>
			</tr>
			<tr>
					<td>多语言</td>
					<td>未列出支持的语言，评测中包含多语言任务</td>
					<td>以英语为主</td>
					<td>多语言检查点覆盖 100 多种语言</td>
			</tr>
	</tbody>
</table>
<h2 id="基准数据怎么看">基准数据怎么看</h2>
<h3 id="1-微软公布的对比数据">1. 微软公布的对比数据</h3>
<p>微软把 Microsoft-Decision-1 和 JevBench 榜单上的几个头部模型、Jev 以及 GPT-6 Sol 放在一起，在 36 个公开和私有基准、共 147,137 道题上做了对比（准确率和校准分越高越好，延迟越低越好）：</p>
<table>
	<thead>
			<tr>
					<th>模型</th>
					<th>平均准确率</th>
					<th>中位延迟（p50）</th>
					<th>校准分（100 为完美）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Microsoft-Decision-1</strong></td>
					<td><strong>83.5%</strong></td>
					<td><strong>85 ms</strong></td>
					<td>92.2</td>
			</tr>
			<tr>
					<td>Jev 1.13.0</td>
					<td>82.3%</td>
					<td>240 ms</td>
					<td><strong>93.7</strong></td>
			</tr>
			<tr>
					<td>Quyet-1.0-Large</td>
					<td>81.9%</td>
					<td>380 ms</td>
					<td>93.1</td>
			</tr>
			<tr>
					<td>Surogate Rune 26B-A4B</td>
					<td>79.7%</td>
					<td>380 ms</td>
					<td>91.8</td>
			</tr>
			<tr>
					<td>GPT-6 Luna Decisions</td>
					<td>79.4%</td>
					<td>300 ms</td>
					<td>89.9</td>
			</tr>
			<tr>
					<td>deck-31B</td>
					<td>77.8%</td>
					<td>400 ms</td>
					<td>83.5</td>
			</tr>
			<tr>
					<td>H2O-Lightning-4B v1.1</td>
					<td>77.2%</td>
					<td>210 ms</td>
					<td>91.8</td>
			</tr>
			<tr>
					<td>GPT-6 Sol（参照）</td>
					<td>未参与排名</td>
					<td>3010 ms</td>
					<td>未评分</td>
			</tr>
	</tbody>
</table>
<p>注：Microsoft-Decision-1 的延迟是在 Foundry 同区域测得的，其余模型的延迟引用自 JevBench v1.6.1 的调整后中位延迟（10 月 7 日数据）。另外，AWS 的 Strands-Decider 2B 只能回答 36 个基准中的 23 个，平均准确率为 54.8%，未列入上表。微软的文章在发布后还专门更新了一次，补充了 Jev 的准确率和校准数据。</p>
<p>按这组数据，Microsoft-Decision-1 的平均准确率第一，速度最快：比第二快的 H2O-Lightning-4B v1.1 快 2.5 倍，比 Jev 快约 2.8 倍，比 GPT-6 Sol 快约 35 倍；校准分排第三。</p>
<p>需要注意的是，微软说的是 36 项基准的<strong>平均</strong>准确率最高，而不是每一项都排第一，一些报道中“36 项基准全部第一”的说法并不准确。</p>
<h3 id="2-第三方-jevbench-的实测">2. 第三方 JevBench 的实测</h3>
<p>JevBench 是一个独立于 TypeSafe 的社区榜单（个人维护的开源项目 Benchmark Heaven），用 1500 道决策题（1200 道封存题加 300 道公开题），从智能（Intelligence）、校准（Calibration）、速度和成本四个维度评测决策模型。Microsoft-Decision-1 发布的第二天（10 月 10 日），JevBench 就通过 Azure Foundry 的原生接口完成了完整测试。下面是 API 榜单上的部分结果：</p>
<table>
	<thead>
			<tr>
					<th>综合排名</th>
					<th>系统</th>
					<th>综合分</th>
					<th>能力分</th>
					<th>每千次决策成本</th>
					<th>中位延迟</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Sage 1.3.0（Levanto Labs）</td>
					<td>74.0</td>
					<td>78.6</td>
					<td>约 $0.025</td>
					<td>0.15 s</td>
			</tr>
			<tr>
					<td>2</td>
					<td>d1（Liquid AI）</td>
					<td>73.0</td>
					<td>74.4</td>
					<td>$0.017</td>
					<td>0.27 s</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Mercury Decide（Inception）</td>
					<td>72.4</td>
					<td>73.6</td>
					<td>约 $0.018</td>
					<td>0.33 s</td>
			</tr>
			<tr>
					<td>4</td>
					<td><strong>Jev 1.13.0</strong></td>
					<td><strong>71.5</strong></td>
					<td><strong>77.1</strong></td>
					<td>$0.032</td>
					<td>0.24 s</td>
			</tr>
			<tr>
					<td>5</td>
					<td>wity-1</td>
					<td>70.8</td>
					<td>79.1</td>
					<td>$0.024</td>
					<td>1.57 s</td>
			</tr>
			<tr>
					<td>6</td>
					<td><strong>Microsoft-Decision-1</strong></td>
					<td><strong>69.1</strong></td>
					<td><strong>70.8</strong></td>
					<td>$0.019</td>
					<td>0.46 s</td>
			</tr>
			<tr>
					<td>8</td>
					<td>OpenAI Decisions（gpt-6-luna）</td>
					<td>62.5</td>
					<td>73.5</td>
					<td>$0.052</td>
					<td>0.30 s</td>
			</tr>
	</tbody>
</table>
<p>把 Jev 和 Microsoft-Decision-1 单独拿出来比较：</p>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Jev 1.13.0</th>
					<th>Microsoft-Decision-1</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>智能分</td>
					<td><strong>63.6</strong></td>
					<td>57.2</td>
			</tr>
			<tr>
					<td>校准分</td>
					<td><strong>90.6</strong></td>
					<td>84.4</td>
			</tr>
			<tr>
					<td>每千次决策成本</td>
					<td>$0.032</td>
					<td><strong>$0.019</strong></td>
			</tr>
			<tr>
					<td>中位延迟</td>
					<td><strong>0.24 s</strong></td>
					<td>0.46 s</td>
			</tr>
	</tbody>
</table>
<p>JevBench 的智能分是扣除随机猜测之后的得分（0 表示和随机猜测相当，100 表示全部答对），能力分是智能分和校准分的平均值，综合分则把智能、校准、速度和成本四个维度放在一起计算。它们和微软表格里的“平均准确率”不是同一个指标，不能直接换算。</p>
<h3 id="3-解读数据时必须注意的五点">3. 解读数据时必须注意的五点</h3>
<p><strong>第一，自测与第三方的结论并不一致。</strong> 在微软的 36 项基准上，Microsoft-Decision-1 的平均准确率比 Jev 高 1.2 个百分点；但在 JevBench 上，Jev 的智能分（63.6 对 57.2）和能力分（77.1 对 70.8）都明显领先。两边的题目、评分方法和统计口径完全不同，谁也不能代表“绝对真相”。比较稳妥的理解是：Microsoft-Decision-1 已经进入第一梯队，但还谈不上全面超越 Jev。这也再次说明，<strong>最可靠的基准永远是你自己的数据</strong>。</p>
<p><strong>第二，校准仍是 Jev 的强项。</strong> 即使在微软自己的数据里，Microsoft-Decision-1 的校准分（92.2）也落后于 Jev（93.7）和 Quyet-1.0-Large（93.1）；在 JevBench 上差距更大（84.4 对 90.6）。对于依赖置信度阈值做分流的系统来说，校准质量往往比准确率高出一两个点更重要。</p>
<p><strong>第三，延迟主要取决于部署位置。</strong> 微软的 85 ms 是在 Foundry 同区域测得的，而表中其他模型的延迟来自 JevBench 自己的测试环境。JevBench 原生调用 Microsoft-Decision-1 测得的中位延迟是 459 ms，在同一个榜单上，Jev 是 240 ms。两组数字都没有错，只是测量条件不同。决策模型的计算本身只需要几十毫秒，真正决定体验的是网络距离：把 Foundry 资源建在离应用最近的区域，对数据驻留有要求时选择 <code>DataZoneStandard</code>，并且一定要用真实流量实测。</p>
<p><strong>第四，同样的单价，不同的单次成本。</strong> 两者的标价都是每百万输入 token 0.042 美元，但 JevBench 在同一批题目上实测的每千次决策成本，Microsoft-Decision-1 约为 0.019 美元，Jev 约为 0.032 美元，前者便宜约 40%。差异来自两者对同样的输入计出的 token 数不同。调用量很大时，这个差距值得算进总成本。</p>
<p><strong>第五，Laya 不在同一条赛道上。</strong> 微软的对比中没有 Laya。JevBench 对 Laya 的三个检查点做了零样本测试，智能分只有 0.3～5.5，几乎等同于随机猜测。这和 Laya 作者自己的定位是一致的：它是一个需要在自有数据上微调的快速底座，而不是零样本决策引擎。再加上默认只有 512 / 1024 token 的上下文，Laya 在 JevBench 这种包含长文本、覆盖开放领域的考卷上吃亏是必然的。它的价值在于本地部署，以及特化之后的速度和成本，这一点在上一篇中已经详细讨论过。</p>
<p>此外，JevBench 中有 23 道约 7.7 万～8.1 万 token 的超长题。Microsoft-Decision-1 有 21 道因为超出上下文限制而失败，Jev 也拒绝了这 23 道超出其输入范围的题目，它们都按答错计分。</p>
<h3 id="4-小结">4. 小结</h3>
<p>比较公允的结论是：<strong>Microsoft-Decision-1 进入了决策模型的第一梯队，优势在于同区域延迟、单次成本和微软生态；Jev 在校准和长上下文上仍然领先；Laya 则是另一条赛道上的选手，适合有数据、需要本地化的团队。</strong></p>
<h2 id="microsoft-decision-1-的优势与劣势">Microsoft-Decision-1 的优势与劣势</h2>
<h3 id="优势">优势</h3>
<ol>
<li><strong>企业级集成</strong>：作为 Azure 直接销售的 Foundry 模型，它通过 Azure 订阅计费，受 Azure SLA 保障并由微软提供支持；支持 Entra ID 认证和 RBAC 权限控制，可以和现有的 Azure 网络、监控、合规体系一起管理。对已经在用 Azure 的企业来说，几乎不需要引入新的供应商。</li>
<li><strong>同区域延迟低</strong>：微软在同区域测得的 p50 约为 85 ms、p95 约为 125 ms，加上决策只需要一次前向计算，适合放进 Agent 的多步循环里。</li>
<li><strong>零样本能力进入第一梯队</strong>：不需要任何训练数据就能使用；在微软的 36 项基准上平均准确率最高，在 JevBench 的 27 个 API 产品中综合排名第 6。</li>
<li><strong>鲁棒性好</strong>：对同一请求做 8 种等价扰动，平均翻转率只有 1.3%，选项改写或重排时不翻转。在提示词和选项不断演进的生产环境中，这一点很重要。</li>
<li><strong>单次成本更低</strong>：标价与 Jev 相同，按 JevBench 的实测，每千次决策的成本还要低约 40%。</li>
<li><strong>接口兼容，迁移成本低</strong>：请求格式与 Jev 的 System One 风格接口几乎一致，已有的问题设计和分流逻辑可以直接复用（阈值需要重新标定）。</li>
<li><strong>有具体的落地参考</strong>：Xbox、Copilot、事故响应、Microsoft Discovery 等内部试点给出了可以借鉴的用法，虽然这些同样是自测数据。</li>
</ol>
<h3 id="劣势">劣势</h3>
<ol>
<li><strong>以自测数据为主，第三方结果不如官方亮眼</strong>：在 JevBench 上，它的智能分和校准分都低于 Jev，在 API 榜单上综合排名第 6。</li>
<li><strong>校准不是最强</strong>：无论官方数据还是第三方数据，它的校准都落后于 Jev，上线前一定要用自己的数据标定阈值。</li>
<li><strong>上下文只有 32K</strong>：比 Jev 的 64K 短，长邮件串、长合同或 Agent 的长执行轨迹需要先截断、摘要或分块。</li>
<li><strong>权重不开放，也不能微调</strong>：虽然底座是开源的 Qwen3.5-9B，但模型本身只能以托管 API 的形式使用，无法本地或离线部署；目前文档只介绍了通过 state、instructions 和选项来调优，没有提供基于自有数据的微调。</li>
<li><strong>版本和底座都可能变化</strong>：目前只有版本 <code>1</code>，而微软已经表示会把它迁移到 MAI 或 OpenAI 的底座上。底座一换，概率分布很可能随之变化，调好的阈值需要重新标定。</li>
<li><strong>没有解释，且对措辞敏感</strong>：模型只返回数字，不给理由。官方文档也提醒：问题和选项的措辞、顺序会影响分数；校准在熟悉的任务类型上最可靠；模型的知识可能已经过时；作为安全过滤器时，它可能漏掉隐蔽的有害内容，也可能误拦正常内容。</li>
<li><strong>跨境与数据驻留</strong>：中国大陆的团队目前需要通过 Azure 全球版的 Foundry 调用它，要同时考虑跨境网络延迟和数据出境的合规要求；<code>GlobalStandard</code> 部署可能在任意区域处理推理请求，对数据驻留敏感的场景应选择 <code>DataZoneStandard</code>。</li>
</ol>
<h2 id="与-jevlaya-相比怎么选">与 Jev、Laya 相比，怎么选</h2>
<h3 id="1-场景对照表">1. 场景对照表</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>推荐</th>
					<th>理由</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>已经在用 Azure，需要统一计费、Entra ID 认证和企业合规</td>
					<td>Microsoft-Decision-1</td>
					<td>不引入新的供应商，和现有的 Azure 资源统一管理</td>
			</tr>
			<tr>
					<td>Agent 的多步控制（继续、重试、交接）、AI 评审、电脑操作中的下一步选择</td>
					<td>Microsoft-Decision-1</td>
					<td>同区域延迟低，官方明确支持评分细则类的问题</td>
			</tr>
			<tr>
					<td>云端的海量分类和打标，追求单次成本</td>
					<td>Microsoft-Decision-1</td>
					<td>单价相同，实测单次成本更低</td>
			</tr>
			<tr>
					<td>对置信度校准要求最高的阈值分流</td>
					<td>Jev</td>
					<td>官方和第三方数据中，校准都领先</td>
			</tr>
			<tr>
					<td>长文本判断（32K～64K token）</td>
					<td>Jev</td>
					<td>64K 上下文</td>
			</tr>
			<tr>
					<td>细粒度、选项很多的分类（几十到上百个选项）</td>
					<td>Jev</td>
					<td>最多支持 255 个选项，在 Banking77 这类细粒度意图分类上表现好</td>
			</tr>
			<tr>
					<td>数据不能离开自己的环境，或者需要离线运行</td>
					<td>Laya</td>
					<td>三者中唯一可以本地部署的选项</td>
			</tr>
			<tr>
					<td>每秒几十次以上的实时决策、端侧应用</td>
					<td>Laya</td>
					<td>本地推理，没有网络往返</td>
			</tr>
			<tr>
					<td>有大量标注数据的垂直任务</td>
					<td>Laya（微调后）</td>
					<td>特化之后更快、更准、更便宜</td>
			</tr>
			<tr>
					<td>中文等非英语场景</td>
					<td>三者都需要自测</td>
					<td>Jev 以英语为主；Laya 要使用多语言检查点；Microsoft-Decision-1 没有公布支持语言的细节</td>
			</tr>
	</tbody>
</table>
<h3 id="2-选型时要回答的几个问题">2. 选型时要回答的几个问题</h3>
<p>可以按下面的顺序依次回答：</p>
<pre tabindex="0"><code>1. 数据能否离开自己的环境（以及境内）？
   └─ 不能 → Laya（硬约束，其他因素都要让路）
2. 是否已经在用 Azure，需要统一账单、Entra ID、RBAC 和数据区域部署？
   └─ 是 → 优先评估 Microsoft-Decision-1
3. 是否高度依赖置信度阈值，或者单次输入经常超过 32K token？
   └─ 是 → 优先评估 Jev
4. 是否有标注数据，并且需要每秒几十次以上的决策？
   └─ 是 → 微调 Laya，本地部署
5. 以上都不是硬约束？
   └─ 用同一组题目同时测试 Microsoft-Decision-1 和 Jev，
      按准确率、校准、延迟和单次成本综合选择
</code></pre><h3 id="3-组合使用不必三选一">3. 组合使用：不必三选一</h3>
<p>由于三者的接口几乎一致，组合使用的成本很低：</p>
<ol>
<li><strong>多供应商容灾</strong>：把 Microsoft-Decision-1 和 Jev 配置成互为备份的两个云端后端，一方限流或故障时自动切换。两者的概率分布不同，阈值需要分别标定。</li>
<li><strong>影子测试</strong>：生产流量走一个后端，同时把请求异步发给另一个后端，积累两者结论不一致的样本，再交给人工或大模型裁决，为后续的选型和阈值调整提供依据。</li>
<li><strong>分层级联</strong>：本地的 Laya 先处理高频、短文本、高置信度的请求；低置信度或长文本请求升级给 Microsoft-Decision-1 或 Jev；仍然拿不准的，再交给推理型大模型或人工处理。</li>
<li><strong>按数据敏感度路由</strong>：涉及敏感数据的请求只走本地的 Laya，其余请求走云端。</li>
</ol>
<h2 id="代码示例同一组问题三种后端">代码示例：同一组问题，三种后端</h2>
<h3 id="1-部署-microsoft-decision-1">1. 部署 Microsoft-Decision-1</h3>
<p>可以在 Foundry 门户的模型目录中搜索 Microsoft-Decision-1 并一键部署，也可以使用 Azure CLI：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>az cognitiveservices account deployment create <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --name &lt;ACCOUNT_NAME&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --resource-group &lt;RESOURCE_GROUP&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --deployment-name decision-1 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --model-name <span style="color:#e6db74">&#34;Microsoft-Decision-1&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --model-format Microsoft <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --model-version <span style="color:#e6db74">&#34;1&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --sku-name GlobalStandard <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --sku-capacity <span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>如果需要把推理限制在某个数据区域内，可以把 <code>--sku-name</code> 换成 <code>DataZoneStandard</code>（需要所在区域支持）。</p>
<h3 id="2-启动本地的-laya-服务">2. 启动本地的 Laya 服务</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pip install <span style="color:#e6db74">&#34;laya[serve]&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 只监听本机；如需对外提供服务，请设置 LAYA_API_KEY 并放在网关后面</span>
</span></span><span style="display:flex;"><span>LAYA_HOST<span style="color:#f92672">=</span>127.0.0.1 LAYA_DEVICE<span style="color:#f92672">=</span>cuda LAYA_PRELOAD<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> laya-serve
</span></span></code></pre></div><h3 id="3-用同一组问题调用三个后端">3. 用同一组问题调用三个后端</h3>
<p>下面的示例使用 <code>requests</code> 和 <code>azure-identity</code>（<code>pip install requests azure-identity</code>）。注意 <code>team</code> 问题里加了一个 <code>cannot_tell</code> 选项，这是微软文档推荐的做法：当模型不应该被迫做出选择时，给它一个“无法判断”的出口。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> requests
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> azure.identity <span style="color:#f92672">import</span> DefaultAzureCredential
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>payload <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;state&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;Checkout is down again. This is my third request.&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;customer_contact_count&#34;</span>: <span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;questions&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;team&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Which team should handle this request?&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;criteria&#34;</span>: {
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;billing&#34;</span>: <span style="color:#e6db74">&#34;Charges, invoices, and refunds&#34;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;engineering&#34;</span>: <span style="color:#e6db74">&#34;Bugs, errors, and outages&#34;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;support&#34;</span>: <span style="color:#e6db74">&#34;How-to and account questions&#34;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;cannot_tell&#34;</span>: <span style="color:#e6db74">&#34;The message does not say enough to decide&#34;</span>,
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;severity&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;How severe is this issue?&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;criteria&#34;</span>: [<span style="color:#e6db74">&#34;Cosmetic&#34;</span>, <span style="color:#e6db74">&#34;Minor&#34;</span>, <span style="color:#e6db74">&#34;Major&#34;</span>, <span style="color:#e6db74">&#34;Critical&#34;</span>],
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;repeat_contact&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Has the customer contacted support before?&#34;</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">decide</span>(url: str, headers: dict, model: str <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>) <span style="color:#f92672">-&gt;</span> dict:
</span></span><span style="display:flex;"><span>    body <span style="color:#f92672">=</span> {<span style="color:#f92672">**</span>payload, <span style="color:#e6db74">&#34;model&#34;</span>: model} <span style="color:#66d9ef">if</span> model <span style="color:#66d9ef">else</span> payload
</span></span><span style="display:flex;"><span>    resp <span style="color:#f92672">=</span> requests<span style="color:#f92672">.</span>post(url, headers<span style="color:#f92672">=</span>headers, json<span style="color:#f92672">=</span>body, timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>)
</span></span><span style="display:flex;"><span>    resp<span style="color:#f92672">.</span>raise_for_status()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> resp<span style="color:#f92672">.</span>json()[<span style="color:#e6db74">&#34;answers&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 1) Microsoft-Decision-1（Foundry）：model 填部署名称，推荐使用 Entra ID 认证</span>
</span></span><span style="display:flex;"><span>token <span style="color:#f92672">=</span> DefaultAzureCredential()<span style="color:#f92672">.</span>get_token(<span style="color:#e6db74">&#34;https://cognitiveservices.azure.com/.default&#34;</span>)<span style="color:#f92672">.</span>token
</span></span><span style="display:flex;"><span>answers <span style="color:#f92672">=</span> decide(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#39;AZURE_ENDPOINT&#39;</span>]<span style="color:#f92672">.</span>rstrip(<span style="color:#e6db74">&#39;/&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/providers/microsoft/v1/systemone&#34;</span>,
</span></span><span style="display:flex;"><span>    {<span style="color:#e6db74">&#34;Authorization&#34;</span>: <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Bearer </span><span style="color:#e6db74">{</span>token<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>},
</span></span><span style="display:flex;"><span>    os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#34;DEPLOYMENT_NAME&#34;</span>],
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;decision-1:&#34;</span>, answers[<span style="color:#e6db74">&#34;team&#34;</span>][<span style="color:#e6db74">&#34;choice&#34;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 2) Jev（TypeSafe）：固定版本号，避免别名升级后调好的阈值失效</span>
</span></span><span style="display:flex;"><span>answers <span style="color:#f92672">=</span> decide(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://api.typesafe.ai/v1/systemone&#34;</span>,
</span></span><span style="display:flex;"><span>    {<span style="color:#e6db74">&#34;Authorization&#34;</span>: <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Bearer </span><span style="color:#e6db74">{</span>os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#39;TYPESAFE_API_KEY&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>},
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;jev-1.13.0&#34;</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;jev:&#34;</span>, answers[<span style="color:#e6db74">&#34;team&#34;</span>][<span style="color:#e6db74">&#34;choice&#34;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 3) Laya（本地 laya-serve）：省略 model，由 Router 按语言自动选择检查点</span>
</span></span><span style="display:flex;"><span>answers <span style="color:#f92672">=</span> decide(<span style="color:#e6db74">&#34;http://127.0.0.1:8000/v1/systemone&#34;</span>, {})
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;laya:&#34;</span>, answers[<span style="color:#e6db74">&#34;team&#34;</span>][<span style="color:#e6db74">&#34;choice&#34;</span>])
</span></span></code></pre></div><p>如果使用 API Key 调用 Microsoft-Decision-1，把请求头换成 <code>{&quot;api-key&quot;: os.environ[&quot;AZURE_API_KEY&quot;]}</code> 即可。</p>
<p>三个后端返回的 <code>answers</code> 都以问题名为键，但有几个细节需要留意：</p>
<ul>
<li>Jev 和 Laya 的 <code>choice</code> 结果都包含 <code>choice</code>、<code>probabilities</code> 和 <code>confidence</code>，不过 Laya 的 <code>confidence</code> 计算方式和 Jev 不同，所以它额外提供了一个按 Jev 公式计算的 <code>x_jev_confidence</code>，方便沿用 Jev 上调好的阈值；</li>
<li>Microsoft-Decision-1 的文档说明会返回选中项和每个选项的概率，接入时建议先打印一次完整的响应，确认字段名之后再编写阈值逻辑；</li>
<li>Microsoft-Decision-1 的 <code>score</code> 返回的是各等级下标的概率加权平均值（四级量表的取值范围是 0～3，可能落在两个等级之间），文档建议把它用于相对排序和阈值判断，而不是当作绝对的校准评分。</li>
</ul>
<h2 id="落地注意事项">落地注意事项</h2>
<p>Microsoft-Decision-1 的官方文档给出了不少实用的建议，它们同样适用于 Jev 和 Laya：</p>
<ol>
<li><strong>先用代表性数据验证</strong>：用自己的标注样本测试准确率和延迟，再决定是否上线。</li>
<li><strong>按误判代价设置阈值</strong>：根据误报和漏报的代价分别设置置信度阈值；不应该强行选择时，加一个“无法判断”的选项。</li>
<li><strong>使用中性措辞，随机化选项顺序</strong>：测试改变选项顺序是否会影响结果。</li>
<li><strong>用 Coding Agent 迭代问题设计</strong>：让 Coding Agent 生成多组候选的 state、instructions 和选项，在标注集上评估、对比，人工审核之后再上线。</li>
<li><strong>涉及个人的重大决策必须有人参与</strong>：在信贷、招聘、住房、医疗、法律等场景中，只能把模型作为决策辅助，并告知受影响的用户有 AI 参与了决策；不要使用不必要的敏感属性，并持续监控不同群体之间的结果差异。</li>
<li><strong>阈值不能跨后端照搬</strong>：三个模型的概率分布不同，置信度的计算方式也可能不同，切换或混用后端时要分别标定。</li>
<li><strong>固定版本，持续监控</strong>：固定部署的模型版本，升级（包括底座迁移）之后重新标定阈值，并持续监控概率分布和校准漂移。</li>
</ol>
<h2 id="总结">总结</h2>
<p>用一句话概括三者：</p>

<blockquote>
  <p><strong>Microsoft-Decision-1 是“Azure 里的企业级决策组件”，Jev 是“开箱即用的云端通才”，Laya 是“可以特化的本地专才”。</strong></p>

</blockquote>
<p><img alt="三者的不同定位" loading="lazy" src="/zh/blog/2026/microsoft-decision-1/6-three-positions.png"></p>
<ul>
<li>如果你已经在用 Azure，需要统一的计费、身份认证和合规体系，或者想给 Agent 加一道低延迟的“决策闸门”，Microsoft-Decision-1 是最省事的选择；</li>
<li>如果你最看重置信度校准和长上下文，Jev 目前仍然更稳；</li>
<li>如果数据不能离开本地，或者需要毫秒级、海量的决策，并且有能力准备标注数据，Laya 依然不可替代。</li>
</ul>
<p>从更大的视角看，Microsoft-Decision-1 的意义不只是“又多了一个决策模型”。微软带着开源底座、与 Jev 相同的价格和几乎一致的接口入局，说明了两件事：一是 <code>state + questions</code> 风格的决策接口正在成为事实标准，用户的迁移成本越来越低；二是底座正在变成可替换的零件，竞争的焦点转向了后训练数据、评测体系、校准质量和分发渠道。对开发者来说这是好消息：<strong>把决策从大模型里拆出来，已经从创业公司和开源社区的尝试，变成了大厂也认可的工程实践。</strong></p>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://commandline.microsoft.com/microsoft-decision-1-model-foundry/">Microsoft Command Line：Introducing Microsoft-Decision-1, our model for fast decision-making</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/foundry/foundry-models/how-to/use-foundry-models-microsoft-decision">Microsoft Learn：Deploy and use Microsoft-Decision-1 in Microsoft Foundry</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure">Microsoft Learn：Foundry Models sold by Azure</a></li>
<li><a href="https://ai.azure.com/catalog/models/Microsoft-Decision-1">Microsoft Foundry 模型目录：Microsoft-Decision-1</a></li>
<li><a href="https://benchmarkheaven.com/jev-models/api">JevBench：Hosted decision API benchmark</a></li>
<li><a href="https://benchmarkheaven.com/jev-models">JevBench：Open-weights decision model leaderboard</a></li>
<li><a href="https://docs.typesafe.ai/models">TypeSafe 文档：Models</a></li>
<li><a href="https://github.com/NandhaKishorM/laya">Laya GitHub 仓库</a></li>
<li><a href="/zh/blog/2026/jev-vs-laya/">Jev vs Laya：闭源与开源 System One 决策模型的对比与选型</a></li>
</ul>
]]></content:encoded></item><item><title>用 Microsoft Web IQ 增强 OpenWebUI：零代码实现联网搜索与链接自动读取</title><link>https://wilsonwu.me/zh/blog/2026/microsoft-web-iq-in-openwebui/</link><pubDate>Mon, 28 Sep 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/microsoft-web-iq-in-openwebui/</guid><description>&lt;p&gt;&lt;img alt="OpenWebUI 接入 Microsoft Web IQ" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/microsoft-web-iq-in-openwebui/1-openwebui-web-iq.png"&gt;&lt;/p&gt;
&lt;p&gt;在&lt;a href="https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/"&gt;上一篇文章&lt;/a&gt;中，我把 OpenWebUI 的数据库迁移到了 Azure 托管 PostgreSQL。在日常使用 OpenWebUI 的过程中，我经常遇到两个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;问不到最新的信息&lt;/strong&gt;：模型有知识截止时间，问到最近的版本发布、新闻或价格，要么答不上来，要么答错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;贴进去的链接不会被打开&lt;/strong&gt;：把文章、文档或 GitHub 页面的 URL 贴进对话框，想让模型总结、翻译或对比，模型却不会去读这个链接，有时甚至会根据 URL 猜出一段“看起来很像”的内容。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这两个问题都可以靠 OpenWebUI 的联网能力解决。本文使用微软新推出的 &lt;strong&gt;Microsoft Web IQ&lt;/strong&gt; 作为联网搜索引擎，并让模型自动识别消息中的 URL、读取网页后再回答。整个过程只需要在 Azure 门户中创建一个 Web IQ 资源，再到 OpenWebUI 的管理界面中完成配置，&lt;strong&gt;不需要写代码，也不需要部署额外的服务&lt;/strong&gt;。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="OpenWebUI 接入 Microsoft Web IQ" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/1-openwebui-web-iq.png"></p>
<p>在<a href="/zh/blog/2026/migrate-openwebui-to-azure-pg/">上一篇文章</a>中，我把 OpenWebUI 的数据库迁移到了 Azure 托管 PostgreSQL。在日常使用 OpenWebUI 的过程中，我经常遇到两个问题：</p>
<ol>
<li><strong>问不到最新的信息</strong>：模型有知识截止时间，问到最近的版本发布、新闻或价格，要么答不上来，要么答错。</li>
<li><strong>贴进去的链接不会被打开</strong>：把文章、文档或 GitHub 页面的 URL 贴进对话框，想让模型总结、翻译或对比，模型却不会去读这个链接，有时甚至会根据 URL 猜出一段“看起来很像”的内容。</li>
</ol>
<p>这两个问题都可以靠 OpenWebUI 的联网能力解决。本文使用微软新推出的 <strong>Microsoft Web IQ</strong> 作为联网搜索引擎，并让模型自动识别消息中的 URL、读取网页后再回答。整个过程只需要在 Azure 门户中创建一个 Web IQ 资源，再到 OpenWebUI 的管理界面中完成配置，<strong>不需要写代码，也不需要部署额外的服务</strong>。</p>

<blockquote>
  <p>本文基于 Open WebUI v0.11.4（2026 年 9 月发布），界面名称以英文界面为准。文中的 API Key 均为占位符。</p>

</blockquote>
<h2 id="最终效果">最终效果</h2>
<p>配置完成后：</p>
<ul>
<li>提问涉及最新信息时，模型会自己调用 <code>search_web</code>，由 Web IQ 返回搜索结果，回答附带参考链接；</li>
<li>消息中出现 <code>http://</code> 或 <code>https://</code> 链接时，模型会自己调用 <code>fetch_url</code>，通过 Web IQ 读取页面正文后再回答，读取过的页面会出现在引用来源中；</li>
<li>两者可以在同一次回答中组合使用，例如先读取你给出的文档，再搜索最新的变更说明做对比。</li>
</ul>
<h2 id="为什么选择-microsoft-web-iq">为什么选择 Microsoft Web IQ</h2>
<p><img alt="Bing Search v7、Grounding with Bing Search 与 Web IQ 的接入方式对比" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/2-bing-vs-web-iq.png"></p>
<p>打开 OpenWebUI 的 <strong>Settings → Admin → Web Search</strong>，在 <strong>Web Search Engine</strong> 中可以找到 <code>bing</code>。选中后需要填写 <strong>Bing Search V7 Endpoint</strong> 和 <strong>Bing Search V7 Subscription Key</strong>，看起来只要填上 Azure 上 Bing 资源的密钥就能用，但这条路已经走不通了：</p>
<ul>
<li>Bing Search v7 API（包括 Bing Custom Search）已于 <strong>2025 年 8 月 11 日退役</strong>，已有实例全部下线，也无法再创建新资源；</li>
<li>现在 Azure 上能创建的 Bing 资源是 <strong>Grounding with Bing Search</strong>，它只能作为 Microsoft Foundry 智能体的工具使用，不能拿密钥直接调用搜索接口。想接入 OpenWebUI，就得自己开发一个桥接服务。</li>
</ul>
<p><strong>Microsoft Web IQ</strong> 是微软在 2026 年 6 月发布的一组面向 AI 应用的 API，用来为模型提供实时的网络信息（grounding），可以理解为“给 AI 智能体用的搜索引擎”：</p>
<ul>
<li>基于 Bing 的全球索引构建，并针对智能体的多轮检索重新设计；</li>
<li>返回的不只是链接列表，而是带来源的相关段落，可以直接放进模型上下文，用更少的 token 得到更好的回答；</li>
<li>覆盖网页、新闻、图片、视频等内容，支持 100 多种语言和市场。</li>
</ul>
<p>更重要的是，OpenWebUI 已经内置了 <code>microsoft_web_iq</code> 引擎。它既可以作为<strong>搜索引擎</strong>，也可以作为读取网页的<strong>网页加载引擎</strong>，只需要一个 API Key。</p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>在 OpenWebUI 中接入</th>
					<th>现状</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Bing Search v7</td>
					<td>内置 <code>bing</code> 引擎</td>
					<td>已退役，不可用</td>
			</tr>
			<tr>
					<td>Grounding with Bing Search</td>
					<td>自己开发桥接服务，通过 Foundry 调用</td>
					<td>主要面向 Foundry 智能体</td>
			</tr>
			<tr>
					<td>Microsoft Web IQ</td>
					<td>内置 <code>microsoft_web_iq</code> 引擎，填写 API Key 即可</td>
					<td>2026 年 6 月发布，目前为有限访问</td>
			</tr>
	</tbody>
</table>
<h2 id="工作原理">工作原理</h2>
<p><img alt="OpenWebUI 通过 Web IQ 搜索和读取网页" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/3-how-it-works.png"></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户消息
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>OpenWebUI（模型以原生工具调用方式工作）
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ├── search_web：联网搜索
</span></span><span style="display:flex;"><span>  │     └── Web IQ /search/web：返回标题、链接和相关段落
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  └── fetch_url：读取消息中的链接
</span></span><span style="display:flex;"><span>        └── Web IQ /browse：读取网页，返回 Markdown 格式的正文
</span></span></code></pre></div><p>几点说明：</p>
<ul>
<li><code>search_web</code> 和 <code>fetch_url</code> 是 OpenWebUI 的两个内置工具，同属 Web Search 类别，由模型自己决定何时调用；</li>
<li>这两个工具依赖原生工具调用（Native Function Calling），这是 v0.10.0 起的默认模式；</li>
<li>网页加载引擎也设为 Web IQ 之后，网页由 Web IQ 读取，需要 JavaScript 渲染的页面也能读到内容。PDF 等文件仍然由 OpenWebUI 下载后自行解析。</li>
</ul>
<h2 id="第一步创建-web-iq-资源并获取-api-key">第一步：创建 Web IQ 资源并获取 API Key</h2>
<p>Web IQ 以 Azure 资源的形式提供。在 Azure 门户中搜索 <strong>Microsoft Web IQ</strong>，进入 <strong>Microsoft Web IQ resources</strong>，点击 <strong>Create</strong>，在 <strong>Basics</strong> 页填写：</p>
<ul>
<li><strong>Subscription</strong> 和 <strong>Resource group</strong>：选择用于管理和计费的订阅与资源组；</li>
<li><strong>Name</strong>：资源名称；</li>
<li><strong>Region</strong>：固定为 <strong>Global</strong>，这是一个可以跨 Azure 区域使用的全局资源。</li>
</ul>
<p><img alt="在 Azure 门户中创建 Microsoft Web IQ 资源" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/4-create-web-iq-resource.png"></p>
<p>点击 <strong>Review + create</strong> 完成创建，然后在资源页面中复制 API Key，下一步会用到。</p>
<p>Web IQ 目前仍处于<strong>有限访问</strong>（limited access）阶段。如果在 Azure 门户中找不到或者无法创建这个资源，可以先到 <a href="https://www.microsoft.com/en-us/WebIQ">Microsoft Web IQ 官网</a> 点击 <strong>Join the waitlist</strong> 申请。</p>
<h2 id="第二步配置-web-iq-搜索和网页读取">第二步：配置 Web IQ 搜索和网页读取</h2>
<p>以管理员身份登录 OpenWebUI，点击左下角的头像，进入 <strong>Settings → Admin → Web Search</strong>。</p>
<h3 id="1-search-部分">1. Search 部分</h3>
<table>
	<thead>
			<tr>
					<th>设置项</th>
					<th>值</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Web Search</td>
					<td>开启</td>
					<td>全局开关</td>
			</tr>
			<tr>
					<td>Web Search Engine</td>
					<td><code>microsoft_web_iq</code></td>
					<td>选中后会出现下面三项</td>
			</tr>
			<tr>
					<td>Microsoft Web IQ API Base URL</td>
					<td><code>https://api.microsoft.ai/v3</code></td>
					<td>保持默认值</td>
			</tr>
			<tr>
					<td>Microsoft Web IQ API Key</td>
					<td><code>&lt;WEB_IQ_API_KEY&gt;</code></td>
					<td>第一步获取的 API Key</td>
			</tr>
			<tr>
					<td>Language</td>
					<td><code>en</code></td>
					<td>搜索结果的语言代码，默认为 <code>en</code></td>
			</tr>
			<tr>
					<td>Search Result Count</td>
					<td><code>5</code></td>
					<td>每次搜索返回的结果数量</td>
			</tr>
			<tr>
					<td>Fetch URL Content Length Limit</td>
					<td><code>50000</code></td>
					<td><code>fetch_url</code> 最多返回的字符数，留空表示不限制</td>
			</tr>
	</tbody>
</table>
<p>几点说明：</p>
<ul>
<li><strong>Microsoft Web IQ API Base URL</strong>：OpenWebUI 已经预先填好，一般不需要修改；如果 Web IQ 资源页面显示了其他终结点，以资源页面为准；</li>
<li><strong>Language</strong>：技术类内容用 <code>en</code> 通常效果更好；如果主要查询中文资讯，可以改为 <code>zh</code>（以 Web IQ 支持的语言代码为准）；</li>
<li><strong>Fetch URL Content Length Limit</strong>：默认不截断，遇到很长的页面会占用大量上下文，建议设置上限；</li>
<li><strong>Web Search Confirmation</strong>：可选。开启后，用户在对话中使用联网搜索前会先看到确认提示，例如默认的 “Your query will be sent to the configured web search provider.”，提醒用户查询内容会发送到外部服务。</li>
</ul>
<h3 id="2-loader-部分">2. Loader 部分</h3>
<table>
	<thead>
			<tr>
					<th>设置项</th>
					<th>值</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Web Loader Engine</td>
					<td><code>microsoft_web_iq</code></td>
					<td>读取网页也交给 Web IQ，与搜索共用上面的 API Key</td>
			</tr>
	</tbody>
</table>
<p>点击 <strong>Save</strong> 保存，配置立即生效，不需要重启容器。</p>
<p>Web Loader Engine 也可以保持 <strong>Default</strong>，由 OpenWebUI 服务器自己抓取网页。但默认加载器读不到需要 JavaScript 渲染的内容，它的 User-Agent 也容易被部分网站拦截，交给 Web IQ 读取会省心很多。</p>
<h2 id="第三步让模型自动识别并读取消息中的-url">第三步：让模型自动识别并读取消息中的 URL</h2>
<p><img alt="没有 fetch_url 时模型只能猜，有 fetch_url 时先读后答" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/5-read-before-answer.png"></p>
<h3 id="1-模型设置">1. 模型设置</h3>
<p>进入 <strong>Settings → Admin → Models</strong>，编辑对话使用的模型：</p>
<table>
	<thead>
			<tr>
					<th>位置</th>
					<th>设置</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Capabilities</td>
					<td>勾选 <strong>Web Search</strong>、<strong>Builtin Tools</strong>、<strong>Citations</strong>、<strong>Status Updates</strong></td>
			</tr>
			<tr>
					<td>Builtin Tools</td>
					<td>确认 <strong>Web Search</strong>（Search the web and fetch URLs）已勾选</td>
			</tr>
			<tr>
					<td>Default Features</td>
					<td>勾选 <strong>Web Search</strong>，新对话默认打开联网开关</td>
			</tr>
			<tr>
					<td>Advanced Params</td>
					<td><strong>Function Calling</strong> 设为 <strong>Native</strong>（默认值）</td>
			</tr>
	</tbody>
</table>
<p>其中 <strong>Default Features</strong> 很关键：原生模式下，只有模型启用了 Web Search 能力，并且当前对话打开了联网开关，<code>search_web</code> 和 <code>fetch_url</code> 才会提供给模型。不勾选的话，用户每次都要在输入框的 <strong>+</strong> 菜单中手动打开联网搜索，否则贴进来的链接不会被读取。</p>
<p>原生模式要求模型具备可靠的工具调用能力，建议使用较新的模型，太小或太旧的模型可能不会正确调用工具。</p>
<h3 id="2-系统提示词">2. 系统提示词</h3>
<p>是否调用 <code>fetch_url</code> 由模型自己决定。为了让行为更稳定，在同一个页面的 <strong>System Prompt</strong> 中加入以下规则：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>你可以使用 search_web 和 fetch_url 两个工具：
</span></span><span style="display:flex;"><span>1. 用户消息中出现以 http:// 或 https:// 开头的链接时，先调用 fetch_url 读取链接内容，再根据读取结果回答；有多个链接时逐个读取。
</span></span><span style="display:flex;"><span>2. 问题涉及最新动态、版本、价格等时效性信息，或者你不确定答案时，调用 search_web 搜索；搜索结果中的摘要不够时，再用 fetch_url 打开最相关的结果。
</span></span><span style="display:flex;"><span>3. 链接无法访问或内容为空时，如实告诉用户，不要根据链接地址推测页面内容。
</span></span><span style="display:flex;"><span>4. 在回答末尾列出参考链接。
</span></span></code></pre></div><p>第 3 条用来防止模型在读取失败后“编”一段内容；第 4 条则是因为原生模式下，<code>search_web</code> 的结果不会出现在引用来源中，只有 <code>fetch_url</code> 读取过的页面才会，所以需要模型在回答中写出来。</p>
<h3 id="3-手动附加网页">3. 手动附加网页</h3>
<p>如果希望某个链接一定被读取，也可以手动附加：</p>
<ul>
<li>在输入框中输入 <code>#</code> 后粘贴链接，然后选择该网页；</li>
<li>或者通过输入框的 <strong>+ → Attach Webpage</strong> 添加链接。</li>
</ul>
<p>这两种方式由 OpenWebUI 直接读取页面（同样使用上面配置的网页加载引擎），不依赖模型的判断。</p>
<h2 id="效果验证">效果验证</h2>
<h3 id="1-读取链接">1. 读取链接</h3>
<p>新建一个对话，输入：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>帮我总结这篇文档的要点：https://docs.openwebui.com/features/chat-conversations/web-search/providers/microsoft-web-iq
</span></span></code></pre></div><p>预期：</p>
<ul>
<li>回答过程中显示模型调用了 <code>fetch_url</code>；</li>
<li>回答下方的引用来源中出现这篇文档；</li>
<li>回答内容与文档一致，而不是泛泛而谈。</li>
</ul>
<h3 id="2-联网搜索">2. 联网搜索</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Open WebUI 最新发布的版本是多少？有哪些主要更新？
</span></span></code></pre></div><p>预期：</p>
<ul>
<li>模型调用 <code>search_web</code>，结果来自 Web IQ；</li>
<li>如果摘要不够，模型会继续调用 <code>fetch_url</code> 打开发布说明页面；</li>
<li>回答末尾列出参考链接。</li>
</ul>
<h3 id="3-组合使用">3. 组合使用</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>先读取 https://github.com/open-webui/open-webui/releases 的内容，再搜索一下社区对最新版本的反馈，最后给我一个是否升级的建议。
</span></span></code></pre></div><p>预期模型会先读取给出的链接，再根据需要多次搜索，最后综合两部分信息给出建议。</p>
<h3 id="4-排查问题">4. 排查问题</h3>
<p>如果没有达到预期效果，可以先查看 OpenWebUI 的日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker logs --since 10m open-webui 2&gt;&amp;<span style="color:#ae81ff">1</span> |
</span></span><span style="display:flex;"><span>grep -Ei <span style="color:#e6db74">&#39;web iq|microsoft browse|fetch_url|blocked&#39;</span>
</span></span></code></pre></div><p>常见的日志：</p>
<ul>
<li><code>Error searching with Microsoft Web IQ API</code>：搜索请求失败，通常是 API Key 不正确或者尚未开通；</li>
<li><code>Error browsing ... with Microsoft Web IQ</code>：Web IQ 读取网页失败；</li>
<li><code>Blocked ...</code>：链接指向内网地址或者在过滤列表中，被 OpenWebUI 拦截。</li>
</ul>
<p>也可以直接用 curl 验证 API Key，下面的请求格式与 OpenWebUI 实际发送的一致：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -sS https://api.microsoft.ai/v3/search/web <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -H <span style="color:#e6db74">&#34;x-apikey: </span>$WEB_IQ_API_KEY<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -H <span style="color:#e6db74">&#34;Content-Type: application/json&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#e6db74">&#39;{&#34;query&#34;: &#34;Open WebUI&#34;, &#34;maxResults&#34;: 3, &#34;language&#34;: &#34;en&#34;, &#34;contentFormat&#34;: &#34;passage&#34;}&#39;</span>
</span></span></code></pre></div><p>正常情况下会返回包含 <code>webResults</code> 的 JSON，每条结果都有 <code>url</code>、<code>title</code> 和 <code>content</code>。</p>
<h2 id="注意事项">注意事项</h2>
<ul>
<li><strong>数据会发送到 Web IQ</strong>：搜索词和需要读取的链接会发送到 Web IQ API（<code>api.microsoft.ai</code>），数据如何处理以 Web IQ 的服务条款为准。敏感对话可以关闭联网开关，或者开启 Web Search Confirmation 提醒用户。</li>
<li><strong>请求中会附带用户信息</strong>：从 v0.11.4 的源码看，调用 Web IQ 搜索时，OpenWebUI 会在请求头中附带当前用户的名称、ID、邮箱和角色（<code>X-OpenWebUI-User-*</code>），目前无法通过配置关闭。如果组织对此有要求，上线前需要评估。</li>
<li><strong>内网链接无法读取</strong>：读取链接之前，OpenWebUI 会先校验 URL，默认禁止访问内网地址和云元数据地址。这些防护默认开启，无需额外配置，Web IQ 本身也访问不到内网页面。</li>
<li><strong>调用次数与成本</strong>：每次 <code>search_web</code> 和 <code>fetch_url</code> 都是一次 Web IQ 调用，原生模式下模型还可能为一个问题连续调用多次。建议 Search Result Count 保持在 3～5，只在需要联网的模型上把 Web Search 设为默认功能。Web IQ 的费用计入创建资源时选择的 Azure 订阅，可以为资源组设置预算告警。</li>
</ul>
<h2 id="常见问题">常见问题</h2>
<table>
	<thead>
			<tr>
					<th>现象</th>
					<th>可能原因</th>
					<th>处理方法</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>贴了链接，模型说无法访问网页</td>
					<td>对话没有打开联网开关，或者模型没有启用 Web Search 能力和内置工具</td>
					<td>检查模型的 Capabilities、Default Features 和对话中的联网开关</td>
			</tr>
			<tr>
					<td>模型根据 URL 编造内容</td>
					<td>没有调用 <code>fetch_url</code></td>
					<td>确认 Function Calling 为 Native，并加入系统提示词</td>
			</tr>
			<tr>
					<td>普通用户看不到联网开关</td>
					<td>用户组没有 Web Search 功能权限</td>
					<td>在用户组的功能权限中允许 Web Search</td>
			</tr>
			<tr>
					<td>搜索始终没有结果</td>
					<td>API Key 错误或者尚未开通</td>
					<td>查看日志，并用 curl 验证 API Key</td>
			</tr>
			<tr>
					<td>搜索结果的语言不符合预期</td>
					<td>Language 与查询的语言不一致</td>
					<td>修改 Language</td>
			</tr>
			<tr>
					<td>部分网页读取失败</td>
					<td>页面需要登录、禁止抓取，或者链接指向内网</td>
					<td>换一个可以公开访问的链接，或者直接把正文粘贴到对话中</td>
			</tr>
	</tbody>
</table>
<h2 id="总结">总结</h2>
<p>Bing Search API 退役之后，OpenWebUI 内置的 <code>bing</code> 引擎已经无法使用。借助 OpenWebUI 内置的 <code>microsoft_web_iq</code> 引擎，只需要在 Azure 上创建一个 Web IQ 资源并拿到 API Key，就可以在管理界面中完成联网搜索的接入：</p>
<ul>
<li>搜索和网页读取都交给 Web IQ，返回的是适合直接放进模型上下文的段落和正文；</li>
<li>在原生工具调用模式下，<code>search_web</code> 负责搜索，<code>fetch_url</code> 负责读取消息中的链接；</li>
<li>通过模型的 Default Features 和系统提示词，让模型自动识别 URL、先读后答，并在回答中给出参考链接。</li>
</ul>
<p>整个过程不需要写一行代码。无论是询问最新的信息，还是直接丢一个链接让模型总结，现在都可以在 OpenWebUI 中完成。</p>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://blogs.bing.com/search/2026/6/Announcing-Microsoft-Web-IQ/">Announcing Microsoft Web IQ</a></li>
<li><a href="https://www.microsoft.com/en-us/WebIQ">Microsoft Web IQ</a></li>
<li><a href="https://docs.openwebui.com/features/chat-conversations/web-search/providers/microsoft-web-iq">Open WebUI: Microsoft Web IQ</a></li>
<li><a href="https://docs.openwebui.com/features/chat-conversations/web-search/agentic-search">Open WebUI: Agentic Web Search &amp; URL Fetching</a></li>
<li><a href="https://docs.openwebui.com/features/chat-conversations/web-search/providers/bing">Open WebUI: Bing</a></li>
<li><a href="https://docs.openwebui.com/getting-started/advanced-topics/hardening">Open WebUI: Hardening</a></li>
</ul>
]]></content:encoded></item><item><title>Jev vs Laya：闭源与开源 System One 决策模型的对比与选型</title><link>https://wilsonwu.me/zh/blog/2026/jev-vs-laya/</link><pubDate>Wed, 23 Sep 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/jev-vs-laya/</guid><description>&lt;p&gt;&lt;img alt="Jev vs Laya：闭源云端与开源本地的决策模型" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/jev-vs-laya/1-jev-vs-laya.png"&gt;&lt;/p&gt;
&lt;h2 id="引言"&gt;引言&lt;/h2&gt;
&lt;p&gt;2026 年 9 月，AI 圈突然流行起一类“不会说话”的模型。&lt;/p&gt;
&lt;p&gt;9 月 15 日，由前 OpenAI 研究员 Diogo Almeida 创办的 TypeSafe AI 发布了 &lt;strong&gt;Jev&lt;/strong&gt;：它不聊天、不写文章、也不生成代码，只接收一段状态（state）和一组预先定义好的问题，然后直接返回带有校准概率的结构化决策。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="Jev vs Laya：闭源云端与开源本地的决策模型" loading="lazy" src="/zh/blog/2026/jev-vs-laya/1-jev-vs-laya.png"></p>
<h2 id="引言">引言</h2>
<p>2026 年 9 月，AI 圈突然流行起一类“不会说话”的模型。</p>
<p>9 月 15 日，由前 OpenAI 研究员 Diogo Almeida 创办的 TypeSafe AI 发布了 <strong>Jev</strong>：它不聊天、不写文章、也不生成代码，只接收一段状态（state）和一组预先定义好的问题，然后直接返回带有校准概率的结构化决策。</p>
<p>仅仅三天后，9 月 18 日，独立研究者 Nandakishor M（Convai Innovations）开源了 <strong>Laya</strong>。它同样是一个非自回归的 System 1 决策模型，采用 Apache 2.0 协议，权重可以直接从 Hugging Face 下载，三天内 GitHub 星标就超过了 6000，被社区称为“开源版 Jev”。</p>
<p>一边是背靠 4000 万美元种子轮、开箱即用的闭源 API，一边是 4.2 亿参数、一张 T4 甚至一台 MacBook 就能跑起来的开源模型。社交媒体上“Laya 反超 Jev”“Jev 的护城河只维持了两天”之类的说法很多，但如果把双方公开的资料仔细读一遍，就会发现结论远没有这么简单。</p>
<p>本文将从以下几个方面展开：</p>
<ol>
<li>什么是决策模型，它要解决什么问题</li>
<li>Jev 与 Laya 各自的技术路线</li>
<li>核心差异与基准数据解读</li>
<li>两者各自的优势与劣势</li>
<li>适用场景与选型建议</li>
</ol>
<h2 id="什么是决策模型system-one-model">什么是决策模型（System One Model）</h2>
<h3 id="1-名字的由来">1. 名字的由来</h3>
<p>“System One”来自丹尼尔·卡尼曼的《思考，快与慢》：System 1 是快速、直觉式的判断，System 2 是缓慢、审慎的推理。在这个比喻下，生成式大模型（尤其是推理模型）更像 System 2，而决策模型负责的是毫秒级的“直觉反应”。</p>
<p>顺带一提，Jev 这个名字来自经济学家威廉·斯坦利·杰文斯（William Stanley Jevons）。TypeSafe 认为机器智能会重演“杰文斯悖论”：就像蒸汽机效率提升反而带动了煤炭需求一样，智能的单价每下降一个数量级，都会解锁更多数量级的新用例。</p>
<h3 id="2-用大模型做选择题的痛点">2. 用大模型做“选择题”的痛点</h3>
<p>在真实的 AI 工程里，大量决策其实都是“小事”：</p>
<ul>
<li>这张工单应该分给哪个团队？</li>
<li>这封邮件是不是钓鱼邮件？</li>
<li>这段提示词有没有注入攻击？</li>
<li>Agent 下一步该调用哪个工具？上一步执行成功了吗？</li>
</ul>
<p>今天常见的做法，是把这些问题交给一个擅长写作的大模型，再从它生成的文本里把答案“抠”出来。这会带来一系列问题：</p>
<table>
	<thead>
			<tr>
					<th>问题</th>
					<th>表现</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>延迟高</td>
					<td>逐 token 生成，动辄几百毫秒到几秒，推理模型更慢</td>
			</tr>
			<tr>
					<td>成本高</td>
					<td>输入输出都要计费，输出 token 通常更贵</td>
			</tr>
			<tr>
					<td>需要解析</td>
					<td>要写 JSON 解析、Schema 校验和失败重试逻辑</td>
			</tr>
			<tr>
					<td>类型错误</td>
					<td>可能多输出一段解释，甚至返回一个根本不存在的选项</td>
			</tr>
			<tr>
					<td>置信度不可信</td>
					<td>模型写出的 <code>&quot;confidence&quot;: 0.95</code> 只是一段“听起来很自信”的文本，缺乏统计意义上的校准</td>
			</tr>
	</tbody>
</table>
<h3 id="3-决策模型的思路">3. 决策模型的思路</h3>
<p>决策模型干脆把“生成”这一步删掉了。TypeSafe 用一句话概括它：</p>

<blockquote>
  <p><strong>Unstructured state in, typed probabilistic decisions out.</strong></p>
<p>非结构化的状态输入，带概率的类型化决策输出。</p>

</blockquote>
<p><img alt="生成式大模型 vs System One 决策模型" loading="lazy" src="/zh/blog/2026/jev-vs-laya/2-llm-vs-system-one.png"></p>
<p>调用方只需要提交两部分内容：</p>
<ul>
<li><code>state</code>：需要判断的当前状态，可以是字符串、JSON 对象或文本数组</li>
<li><code>questions</code>：一组类型化问题，每个问题的候选答案都由调用方提前定义</li>
</ul>
<p>模型并行地回答所有问题，只返回数字和概率。Jev 与 Laya 采用了完全相同的三种问题原语：</p>
<table>
	<thead>
			<tr>
					<th>原语</th>
					<th>回答的问题</th>
					<th>返回内容</th>
					<th>典型用途</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>choice</code></td>
					<td>从给定选项中选一个</td>
					<td>选中项、各选项概率、confidence</td>
					<td>工单路由、意图识别、工具选择</td>
			</tr>
			<tr>
					<td><code>score</code></td>
					<td>在有序等级上打分</td>
					<td>概率加权的期望分数、等级分布、confidence</td>
					<td>紧急程度、情绪强度、风险等级</td>
			</tr>
			<tr>
					<td><code>noul</code></td>
					<td>是 / 否判断</td>
					<td>“是”的概率（0～1）</td>
					<td>钓鱼检测、越狱检测、是否申请退款</td>
			</tr>
	</tbody>
</table>
<p>由于输出空间被提前限定，模型<strong>不可能返回类型错误或凭空编造的选项</strong>。但需要注意：<strong>类型安全不等于判断正确</strong>，它仍然可能在合法的选项里选错，这一点后文会反复提到。</p>
<h3 id="4-它在系统中的位置">4. 它在系统中的位置</h3>
<p>决策模型并不是用来取代大模型的，而是把大模型从一些“本不该由它来做”的位置上挪开。一个比较合理的分工是：</p>
<pre tabindex="0"><code>代码（确定性逻辑）
  └─ 流程编排、权限控制、数学与日期计算、执行动作
        │  遇到需要语义理解的“模糊 if 语句”
        ▼
决策模型（System 1：Jev / Laya）
  └─ 分类、路由、打分、校验、护栏，返回类型化答案 + 概率
        │  置信度不足，或需要生成文本 / 复杂推理
        ▼
大模型（System 2：GPT / Claude / DeepSeek 等）
  └─ 长上下文理解、规划、写作、代码生成
</code></pre><p>有了校准过的置信度，程序的控制流就不再只有“是 / 否”两种状态，而可以分成三档：<strong>高置信度自动执行，中置信度交给更强的模型复核或请求用户确认，低置信度转人工</strong>。这也是决策模型真正的价值所在：速度只是表象，可以被写进 <code>if</code> 语句的置信度才是关键。</p>
<p><img alt="基于置信度的三档分流" loading="lazy" src="/zh/blog/2026/jev-vs-laya/3-confidence-routing.png"></p>
<h2 id="两位主角">两位主角</h2>
<h3 id="jevtypesafe-ai-的闭源云端服务">Jev：TypeSafe AI 的闭源云端服务</h3>
<p><strong>基本信息</strong></p>
<ul>
<li>发布时间：2026 年 9 月 15 日，以 early access 形式开放</li>
<li>团队背景：TypeSafe AI 是一家旧金山公司，创始人 Diogo Almeida 曾在 Google Brain 和 OpenAI 从事研究，是 InstructGPT 论文和 GPT-4 技术报告的作者之一。发布当天，公司同时宣布了由 DCVC 领投的 4000 万美元种子轮融资</li>
<li>产品形态：闭源，只提供 API（<code>POST https://api.typesafe.ai/v1/systemone</code>）以及 Python / JavaScript SDK</li>
<li>当前版本：Jev 1.13（<code>jev-1.13.0</code>），别名 <code>jev-latest</code></li>
</ul>
<p><strong>技术路线</strong></p>
<p>TypeSafe 声称为自动化场景重建了一整套技术栈：全新的模型架构、追求极致效率的并行采样器，以及一种名为 <strong>RLCD（Reinforcement Learning for Calibrated Decisions，面向校准决策的强化学习）</strong> 的训练方法。与 RLHF 优化“人类偏好”、RLVR 优化“可验证奖励”不同，RLCD 的目标是让模型在给出答案的同时，给出<strong>认知上诚实的概率</strong>。</p>
<p>不过，TypeSafe 目前并没有公开模型架构、参数规模和训练数据。社区有不少人认为它更接近 BERT 这类 encoder 模型，但这只是猜测。</p>
<p><strong>关键规格</strong></p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>规格</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>价格</td>
					<td>输入 $0.042 / 百万 token，输出免费</td>
			</tr>
			<tr>
					<td>延迟</td>
					<td>官方称端到端 70～500 ms</td>
			</tr>
			<tr>
					<td>上下文</td>
					<td>每个请求 64k token，其中 state 加最长的单个问题不超过 32k token</td>
			</tr>
			<tr>
					<td>输入</td>
					<td>仅支持文本（字符串、JSON 对象、文本数组），不支持图像、音频和视频</td>
			</tr>
			<tr>
					<td>选项数量</td>
					<td><code>choice</code> 最多支持 255 个选项</td>
			</tr>
			<tr>
					<td>速率限制</td>
					<td>250,000 token/秒、1,200 次请求/分钟（官方称会动态调整）</td>
			</tr>
			<tr>
					<td>语言</td>
					<td>英语是主要训练语言，中文等 CJK 文本可用，但准确率可能下降</td>
			</tr>
			<tr>
					<td>定制</td>
					<td>不支持微调或 LoRA，所有客户共用同一套权重</td>
			</tr>
	</tbody>
</table>
<p>按这个定价计算，如果每次判断平均消耗 1000 个输入 token，100 万次判断的费用只有约 42 美元。</p>
<p><strong>生态</strong></p>
<p>Jev 发布后迅速走红：LangChain 很快发布了集成指南，Vercel 把它接入了 AI Gateway，社区也涌现出不少实验项目，比如用 Jev 给 Claude Code 的工具调用输出做相关性打分、进而压缩上下文的 fast-jev-compaction。</p>
<h3 id="layaconvai-innovations-的开源本地模型">Laya：Convai Innovations 的开源本地模型</h3>
<p><strong>基本信息</strong></p>
<ul>
<li>发布时间：2026 年 9 月 18 日</li>
<li>作者：独立研究者 Nandakishor M（Convai Innovations）</li>
<li>开源协议：Apache 2.0，代码、权重、训练方法和微调 Notebook 全部公开</li>
<li>获取方式：<code>pip install laya</code>（要求 Python 3.10+），权重托管在 Hugging Face 的 <code>convaiinnovations/laya</code></li>
</ul>
<p>Laya 的诞生还有一段插曲。作者在 2025 年就先后发表过两篇相关论文（arXiv:2503.23303、arXiv:2510.01237），研究用强化学习训练非自回归的决策模型，最早用于预测销售对话的转化率。看到 Jev 以“突破”之名发布后，他把旧方案的架构缺陷全部修了一遍，做出了这个通用的开源版本。</p>
<p><strong>模型架构</strong></p>
<p>Laya 采用“双向编码器 + 决策头”的结构，总参数量 421M：</p>
<pre tabindex="0"><code>State（文本或 JSON）+ 类型化问题与选项
        │ 拼接为：[CLS] 问题 [SEP] [MASK] 选项1 [MASK] 选项2 ... [SEP] state [SEP]
        ▼
ModernBERT-large 编码器（395M，28 层双向注意力）
        ▼
决策头 Transformer（约 25M，2 层）
        ├─ 选项打分器：取每个 [MASK] 位置的隐状态 → logit → 温度缩放 → softmax
        └─ Act / Escalate 头：基于 [CLS] 与分布统计量，判断“自动执行”还是“升级处理”
</code></pre><p>每个选项前面放一个 <code>[MASK]</code> 标记，经过双向编码器之后，从这些标记的位置取出隐状态进行打分。同一个 state 上的多个问题会组成一个 batch，在一次前向传播中完成计算。</p>
<p><strong>三个检查点 + 路由器</strong></p>
<table>
	<thead>
			<tr>
					<th>检查点</th>
					<th>编码器</th>
					<th>参数量</th>
					<th>默认上下文</th>
					<th>用途</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>laya</code></td>
					<td>ModernBERT-large</td>
					<td>421M</td>
					<td>512</td>
					<td>英文</td>
			</tr>
			<tr>
					<td><code>laya-multilingual</code></td>
					<td>mmBERT-base</td>
					<td>322M</td>
					<td>1024</td>
					<td>100+ 种语言，速度约快 2 倍</td>
			</tr>
			<tr>
					<td><code>laya-typed-decisions</code></td>
					<td>ModernBERT-large</td>
					<td>421M</td>
					<td>1024</td>
					<td>针对 typed-decisions 工作流微调</td>
			</tr>
	</tbody>
</table>
<p>Laya 内置了一个纯 Python 实现的 <code>Router</code>，会在前向计算之前用不到 1 毫秒的时间检测文本的书写系统和语言，然后自动选择合适的检查点。之所以需要它，是因为英文检查点在非拉丁文字上会“自信地犯错”：在高棉语测试中，它的准确率为 0，置信度却高达 0.952。这种情况下，靠置信度阈值是拦不住的。</p>
<p><strong>开放的 RLCD 实现</strong></p>
<p>Laya 沿用了 RLCD 这个名字，并公开了具体做法：用<strong>严格适当评分规则</strong>（strictly proper scoring rules，由对数评分、球面评分和排序概率评分组合而成）作为奖励，采用 GRPO 风格的组内基线策略梯度进行训练，完全不使用监督交叉熵损失。严格适当评分规则的特点是：只有当模型报告真实的概率时，才能拿到最高的期望奖励，从而“逼”模型说实话。</p>
<p>Act / Escalate 头则用一个代价矩阵来训练：判对奖励 +1，判错惩罚 -3，升级人工惩罚 -0.5。由此可以推导出，只有当正确率超过 62.5% 时，自动执行才比升级人工更划算，模型也就学会了“没把握就上交”。</p>
<p>此外，Laya 还提供了开箱即用的工作流预设（模型路由、提示词护栏、内容审核、工单分诊），以及一个可以在 Kaggle 免费 2×T4 GPU 上运行的微调 Notebook：在约 3 万个问题上训练 4 个 epoch，耗时 4～5 小时。</p>
<h2 id="核心差异一览">核心差异一览</h2>
<p><img alt="Jev 云端 API vs Laya 本地部署" loading="lazy" src="/zh/blog/2026/jev-vs-laya/4-cloud-vs-local.png"></p>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Jev</th>
					<th>Laya</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>发布方</td>
					<td>TypeSafe AI（商业公司）</td>
					<td>Convai Innovations（独立研究者）</td>
			</tr>
			<tr>
					<td>开源</td>
					<td>闭源，仅提供 API</td>
					<td>Apache 2.0，权重与代码全部开放</td>
			</tr>
			<tr>
					<td>部署</td>
					<td>云端托管（服务目前位于美国西海岸）</td>
					<td>本地、私有云或离线环境，GPU / Mac MPS / CPU 均可运行</td>
			</tr>
			<tr>
					<td>架构</td>
					<td>未公开</td>
					<td>ModernBERT / mmBERT 编码器 + 决策头</td>
			</tr>
			<tr>
					<td>参数规模</td>
					<td>未公开</td>
					<td>322M～421M</td>
			</tr>
			<tr>
					<td>训练方法</td>
					<td>RLCD（细节未公开）</td>
					<td>RLCD（严格适当评分规则 + 组基线策略梯度，已公开）</td>
			</tr>
			<tr>
					<td>问题原语</td>
					<td>choice / score / noul</td>
					<td>choice / score / noul，请求格式几乎一致</td>
			</tr>
			<tr>
					<td>上下文长度</td>
					<td>64k token</td>
					<td>默认 512 / 1024 token（编码器最高支持 8192）</td>
			</tr>
			<tr>
					<td>选项数量</td>
					<td>最多 255 个</td>
					<td>超过 20 个时效果明显下降</td>
			</tr>
			<tr>
					<td>零样本能力</td>
					<td>强，开箱即用</td>
					<td>弱，基础检查点需要微调</td>
			</tr>
			<tr>
					<td>定制方式</td>
					<td>只能通过 state、instructions、criteria 调整</td>
					<td>可以在自有数据上微调，并拟合校准温度</td>
			</tr>
			<tr>
					<td>多语言</td>
					<td>以英语为主</td>
					<td>多语言检查点 + 自动路由，51 种语言中有 45 种达到可用水平</td>
			</tr>
			<tr>
					<td>单问题延迟</td>
					<td>官方 70～500 ms，第三方实测 p50 为 236～276 ms</td>
					<td>T4 GPU 上约 33～40 ms</td>
			</tr>
			<tr>
					<td>成本</td>
					<td>输入 $0.042 / 百万 token</td>
					<td>模型免费，只需自备算力</td>
			</tr>
			<tr>
					<td>数据合规</td>
					<td>数据需发送到 TypeSafe，企业版支持零数据保留</td>
					<td>数据不出本地</td>
			</tr>
			<tr>
					<td>运维</td>
					<td>免运维</td>
					<td>需要自行部署、扩缩容、监控和管理版本</td>
			</tr>
	</tbody>
</table>
<h2 id="基准数据怎么看">基准数据怎么看</h2>
<h3 id="1-laya-公布的对比数据">1. Laya 公布的对比数据</h3>
<p>Laya 的 README 中给出了一张与 Jev 1.13.0 的对比表（Laya 一侧为经过 Router 路由后的实测结果）：</p>
<table>
	<thead>
			<tr>
					<th>指标</th>
					<th>Jev 1.13.0</th>
					<th>Laya（路由）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>typed-decisions 准确率（2000 个决策）</td>
					<td>0.727</td>
					<td><strong>0.766</strong></td>
			</tr>
			<tr>
					<td>AG News 准确率（4 类）</td>
					<td>0.910</td>
					<td><strong>0.950</strong></td>
			</tr>
			<tr>
					<td>DAIR Emotion 准确率（6 类）</td>
					<td>0.480</td>
					<td><strong>0.595</strong></td>
			</tr>
			<tr>
					<td>Banking77 准确率（72 / 77 类）</td>
					<td><strong>0.870</strong></td>
					<td>0.425</td>
			</tr>
			<tr>
					<td>ECE 校准误差（越低越好）</td>
					<td>0.246</td>
					<td><strong>0.081</strong></td>
			</tr>
			<tr>
					<td>单问题 p50 延迟</td>
					<td>236～276 ms</td>
					<td><strong>32.8 ms</strong></td>
			</tr>
			<tr>
					<td>成本</td>
					<td>$0.042 / 百万 token</td>
					<td>自托管 $0</td>
			</tr>
	</tbody>
</table>
<p>乍一看，Laya 几乎全面领先。但如果只看这张表就下结论，很容易踩坑。</p>
<h3 id="2-解读数据时必须注意的五点">2. 解读数据时必须注意的五点</h3>
<p><strong>第一，Jev 的数据并非同场实测。</strong> Laya 作者明确说明，自己没有 TypeSafe 的 API 权限，表中 Jev 的数字全部引用自第三方公开的结果，样本量和提示词都不相同。</p>
<p><strong>第二，Laya 的高分来自针对性微调。</strong> typed-decisions 上的 0.766 是 <code>laya-typed-decisions</code> 在该基准自己的训练集上微调后取得的成绩。未经微调的两个基础检查点只有 0.34～0.36，接近随机猜测（0.318），甚至低于“每题都猜最常见类别”的基线（0.461），而 Jev 不做任何训练就能拿到 0.727。作者自己也说得很直白：<strong>Laya 是一个适合特化的快速底座，而不是零样本决策引擎。</strong> 换句话说，这张表比的其实是“特化后的专才”和“零样本的通才”。</p>
<p><strong>第三，校准优势需要额外的温度拟合。</strong> 表中 0.081 的 ECE 是用领域留出数据重新拟合温度之后的结果，出厂时为 0.466。而在同一个 typed-decisions 基准上，Laya 微调检查点的 ECE 为 0.213，反而高于 Jev 的 0.144；在衡量概率分布与参考分布吻合程度的“软准确率”上，Jev 也以 0.580 领先 Laya 的 0.471。当然 Jev 也并非完美：Laya 作者引用的数据显示，在 DAIR Emotion 上，Jev 对 16% 的样本给真实标签分配了 0 概率，这对依赖置信度做分流的系统来说是个硬伤。</p>
<p><strong>第四，延迟对比的本质是部署方式的差异。</strong> Jev 的延迟包含从客户端到 TypeSafe 云端的网络往返（TypeSafe 的评测大多从美国西海岸发起），而 Laya 的 32.8 ms 是在本地 T4 GPU 上的推理耗时。对于国内或其他地区的用户，Jev 的实际延迟还要叠加跨境网络的开销；反过来，如果把 Laya 部署成远程服务，同样要算上网络开销。</p>
<p><strong>第五，Jev 自己的宣传数据同样需要打折。</strong> TypeSafe 官网上“快 193.6 倍、便宜 444.6 倍”的数字来自其团队设计的 4 套工作流评测，参考答案取自 GPT-6 Astra 和 Fable 5.1 两个前沿模型的平均结果，官方也坦言这些数字处于真实收益的上限。自动化服务公司 AY Automate 发布的第三方测试则显示：Jev 比几款对照模型快约 2～3.6 倍；成本方面，相比便宜的小模型约节省 4.7～7.5 倍，相比昂贵的前沿模型才能达到 40～49 倍。</p>
<h3 id="3-社区实测">3. 社区实测</h3>
<p>发布后短短几天，社区就做了不少对比测试，大多集中在高频交互场景：</p>
<ul>
<li><strong>贪吃蛇</strong>：在同一套决策逻辑下，本地运行的 421M Laya 在 30 秒内平均每秒决策 86.5 次，P50 延迟约 9 ms；云端 Jev 每秒只能决策 3.2 次，API 往返约 317 ms</li>
<li><strong>M5 Pro 测试</strong>：两者的中位耗时分别为 15.3 ms 和 298.1 ms，差距接近 20 倍</li>
<li><strong>Apple Silicon 移植</strong>：社区项目 laya-mlx 把模型移植到 MLX，运行时最高约占 1GB 内存，在 M3 Max 上运行贪吃蛇可以达到每秒 60 次决策</li>
</ul>
<p>这些测试体现的主要是“本地推理”与“云端往返”的差异。对于每秒需要几十次决策的实时场景，这个差距是决定性的；但对于一次工单分流来说，30 ms 和 300 ms 的区别，用户几乎感知不到。</p>
<h3 id="4-小结">4. 小结</h3>
<p>比较公允的结论是：<strong>在开箱即用、长上下文、选项复杂的场景下，Jev 更强；在有标注数据的垂直任务上，经过微调的 Laya 可以做到更快、更准、更便宜。</strong></p>
<h2 id="jev-的优势与劣势">Jev 的优势与劣势</h2>
<h3 id="优势">优势</h3>
<ol>
<li><strong>开箱即用，零样本能力强</strong>：不需要任何训练数据，写好 state 和问题就能直接使用。TypeSafe 称 Jev 在 System One 类任务上达到了与现有前沿 LLM 相近的智能水平；Karpathy 也评价说，它处在 LLM 帕累托曲线上一个长期被低估的位置：不需要复杂思考，只需要在低延迟下给出足够可靠的判断。</li>
<li><strong>长上下文与高基数选项</strong>：64k 的上下文可以直接放入长邮件串、合同条款或 Agent 执行轨迹；<code>choice</code> 最多支持 255 个选项，在 Banking77 这类细粒度意图分类上仍有 0.870 的准确率。</li>
<li><strong>出厂即校准</strong>：不需要自己拟合温度，返回的概率可以直接用于阈值分流；官方文档还给出了按风险等级设置不同阈值的最佳实践。</li>
<li><strong>免运维，生态成熟</strong>：托管服务加官方 SDK，已经接入 LangChain、Vercel AI Gateway 等生态，团队不需要操心 GPU、扩缩容和模型升级。</li>
<li><strong>单价极低</strong>：按每次 1000 个输入 token 估算，100 万次判断约 42 美元，对大多数业务来说，模型费用已经不是瓶颈。</li>
</ol>
<h3 id="劣势">劣势</h3>
<ol>
<li><strong>闭源黑盒</strong>：架构、参数量和训练数据都没有公开。决策模型本身也不输出解释，正如 Simon Willison 所提醒的，Jev 最终留给开发者的可能只有一个数字，判断出错时很难追问原因。</li>
<li><strong>数据必须出域</strong>：所有 state 都要发送到 TypeSafe 的云端，零数据保留（ZDR）仅面向企业客户，这对金融、医疗、政企等场景是硬伤。另据媒体报道，Jev 目前尚未面向中国大陆开放。</li>
<li><strong>网络延迟无法消除</strong>：几百毫秒的往返对于业务工作流已经足够快，但对于游戏 AI、实时风控这类每秒几十次决策的场景仍然太慢。</li>
<li><strong>无法微调</strong>：所有客户共用同一套权重，只能通过 state、instructions 和 criteria 来“调教”，在高度垂直的领域可能达不到专用模型的效果。</li>
<li><strong>英语优先</strong>：官方明确英语是主要训练语言，中文场景一定要先用自己的数据验证。</li>
<li><strong>早期产品的不确定性</strong>：速率限制仍在动态调整；官方预计价格只降不升，但也坦言暂时无法证明当前价格没有补贴；<code>jev-latest</code> 别名会随新版本移动，调好的阈值可能需要重新标定；此外还有厂商锁定的风险。</li>
<li><strong>已知的能力“锯齿”</strong>：官方文档专门列出了 Jev 1.13 的多种失效模式，包括过于字面地理解问题、不擅长计数和数学运算、日期比较不可靠、多跳的间接推理效果差、state 中无关内容过多会降低准确率、容易被 state 中的对抗内容（如提示词注入）带偏等。甚至一些“常识性”的一致性也无法保证：对同一个问题和它的否定分别提问，两个 noul 的概率之和可能是 1.19，而不是 1。</li>
</ol>
<h2 id="laya-的优势与劣势">Laya 的优势与劣势</h2>
<h3 id="优势-1">优势</h3>
<ol>
<li><strong>完全开源</strong>：Apache 2.0 协议，架构、训练方法、评测脚本和微调 Notebook 全部公开，可审计、可复现、可二次开发，没有厂商锁定。</li>
<li><strong>数据不出域</strong>：可以部署在本地、私有云甚至离线环境中，天然满足数据驻留和隐私合规的要求。</li>
<li><strong>极低延迟、高吞吐</strong>：T4 上单个问题约 33～40 ms，多语言检查点批量推理时每题约 7 ms，单卡吞吐约 100～330 题/秒，在实时交互类应用中优势明显。</li>
<li><strong>边际成本接近零</strong>：按单张 T4 每秒 100～300 题估算，100 万次判断只需要 1～3 个 GPU 小时。</li>
<li><strong>可微调、可特化</strong>：在自有数据上微调后可以超越 Jev（typed-decisions 上 0.766 对 0.727），而且整个流程在 Kaggle 的免费 GPU 上就能完成。</li>
<li><strong>多语言与自动路由</strong>：<code>laya-multilingual</code> 覆盖 100 多种语言，Router 会在推理前自动选择检查点，对中文等非英语场景更友好（但仍需自测）。</li>
<li><strong>面向生产的设计</strong>：Act / Escalate 头可以直接给出“自动执行还是升级人工”的建议；内置了护栏、审核、分诊等预设；文档还坦诚地列出了自身的各项短板。</li>
</ol>
<h3 id="劣势-1">劣势</h3>
<ol>
<li><strong>零样本能力弱</strong>：基础检查点在 typed-decisions 上接近随机水平，想要好的效果，就必须准备标注数据并进行微调。没有数据的团队拿到手的，基本上只是一个“待训练的底座”。</li>
<li><strong>上下文短</strong>：英文检查点默认只有 512 token（留给 state 的约 320 token），多语言和 typed-decisions 检查点为 1024 token（留给 state 的约 768 token）。长文档、长对话需要先做截断、摘要或分块。编码器最高支持 8192 token，但调大之后延迟会上升，效果也需要自行验证。</li>
<li><strong>高基数选项吃力</strong>：所有选项共享一个固定的 token 预算，77 个选项时每个选项只能分到 3～4 个 token，Banking77 的准确率随之跌到 0.425。需要调大 <code>head_max_len</code>、先用 embedding 做候选筛选（<code>predict_shortlist</code>），或者拆成“粗分类 + 细分类”两级问题。</li>
<li><strong>校准需要额外工作</strong>：出厂的检查点偏向“过度自信”，需要用领域内的留出数据拟合温度（ECE 可以从 0.466 降到 0.081）；多语言检查点出厂时则完全没有拟合温度。</li>
<li><strong>score 是最弱的原语</strong>：有序打分任务的表现最差，例如 SST-5 五级情感分类的准确率只有 0.372。</li>
<li><strong>语言陷阱</strong>：英文检查点在非拉丁文字上会高置信度地犯错，必须使用 Router 或手动选对检查点。另外，默认的懒加载模式下切换语言会触发 7～10 秒的模型重载，生产环境需要开启预加载。</li>
<li><strong>运维与项目成熟度</strong>：需要自己负责 GPU 资源、扩缩容、监控和模型版本管理；项目刚刚发布，主要由独立研究者维护，长期演进存在不确定性。</li>
</ol>
<h2 id="适用场景与选型建议">适用场景与选型建议</h2>
<h3 id="1-场景对照表">1. 场景对照表</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>推荐</th>
					<th>理由</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>快速验证 PoC，手上没有标注数据</td>
					<td>Jev</td>
					<td>零样本即可使用，几分钟就能接入</td>
			</tr>
			<tr>
					<td>细粒度意图分类（几十上百个选项）</td>
					<td>Jev</td>
					<td>最多支持 255 个选项；Laya 需要额外做候选筛选或分级设计</td>
			</tr>
			<tr>
					<td>长文本判断（合同、长邮件串、Agent 执行轨迹）</td>
					<td>Jev</td>
					<td>64k 上下文，而 Laya 默认只有 512 / 1024</td>
			</tr>
			<tr>
					<td>云端 Agent 的工具选择、完成判定、重试决策</td>
					<td>Jev</td>
					<td>通用性强，与 LangChain、Vercel 等生态集成度高</td>
			</tr>
			<tr>
					<td>数据不能出域（金融、医疗、政企、境内业务）</td>
					<td>Laya</td>
					<td>本地部署，数据不离开自己的环境</td>
			</tr>
			<tr>
					<td>高频实时决策（游戏 AI、实时风控、端侧应用）</td>
					<td>Laya</td>
					<td>毫秒级本地推理，没有网络往返</td>
			</tr>
			<tr>
					<td>超大规模批处理（海量分类、打标、特征提取）</td>
					<td>Laya</td>
					<td>边际成本接近零</td>
			</tr>
			<tr>
					<td>有大量标注数据的垂直任务（垃圾邮件、钓鱼检测、工单分诊、内容审核）</td>
					<td>Laya（微调后）</td>
					<td>特化之后更快、更准、更便宜</td>
			</tr>
			<tr>
					<td>多语言，尤其是中文等非拉丁文字</td>
					<td>Laya（多语言检查点 + Router）</td>
					<td>Jev 以英语为主；但两者都需要用自己的数据验证</td>
			</tr>
			<tr>
					<td>网关层的提示词护栏</td>
					<td>Laya</td>
					<td>本地低延迟，适合作为第一道过滤；但需要配合其他防护手段</td>
			</tr>
	</tbody>
</table>
<p>关于最后一点再多说一句：Laya 在未参与训练的提示词注入测试集上的准确率约为 0.698，Jev 的官方文档也承认 state 中的对抗内容可能带偏判断。决策模型可以作为一道低成本的前置过滤，但不能作为唯一的安全防线。</p>
<h3 id="2-选型时要回答的几个问题">2. 选型时要回答的几个问题</h3>
<p>可以按下面的顺序依次回答：</p>
<pre tabindex="0"><code>1. 数据能否发送到第三方云（以及境外）？
   └─ 不能 → Laya（硬约束，其他因素都要让路）
2. 是否有标注数据，并且愿意投入微调？
   └─ 没有 → Jev
3. 是否需要每秒几十次以上的实时决策，或者要在端侧运行？
   └─ 是 → Laya
4. state 是否很长（超过 1k token），或者选项是否很多（超过 20 个）？
   └─ 是 → Jev（或 Laya + 分块 / 候选筛选）
5. 月调用量是否达到数千万级？
   └─ 是 → 倾向 Laya；否则 Jev 的总拥有成本通常更低
</code></pre><p>关于成本，可以简单算一笔账：假设每次判断消耗 1000 个输入 token，Jev 每 100 万次判断约 42 美元；一台 T4 实例全天候运行，按每小时约 0.5 美元估算，每月约 360 美元，相当于 Jev 约 850 万次判断的费用，这还没算高可用冗余和运维人力。所以单纯从成本角度看，只有当调用量足够大时，自托管 Laya 才有明显优势。更多时候，选择 Laya 的理由应该是<strong>延迟、合规和可定制性</strong>，而不仅仅是省钱。</p>
<h3 id="3-组合使用不必二选一">3. 组合使用：不必二选一</h3>
<p>在实际项目中，两者完全可以组合使用：</p>
<ol>
<li><strong>阶段式迁移</strong>：先用 Jev 快速上线，验证问题拆解、选项设计和置信度阈值是否合理，同时沉淀真实数据和人工复核的标签；当调用量上来或合规要求收紧时，再用这些人工标签微调 Laya，迁移到自托管。由于两者的请求格式（state + questions，以及 choice / score / noul 的 instructions、criteria 结构）几乎一致，迁移成本很低。需要注意的是，如果打算直接把 Jev 的输出当作训练标签，请先确认 TypeSafe 的服务条款；Laya 作者也提醒过，用模型生成的标签来训练，只会把新模型校准到“老师”的错误上。</li>
<li><strong>分层级联</strong>：本地 Laya 处理高频、简单、短文本的判断；低置信度或长上下文的请求升级给 Jev；Jev 仍然拿不准的，再交给推理型大模型或人工处理。</li>
<li><strong>按数据敏感度路由</strong>：涉及个人隐私或敏感业务数据的请求走本地 Laya，其余请求走 Jev。</li>
</ol>
<h3 id="4-其他选择">4. 其他选择</h3>
<p>除了这两者，社区还出现了一条“零训练复刻”的路线：直接读取现成开源 LLM 在候选项上的 token 概率来模拟 Jev 的输出。例如 SemIf 用一个现成的 4B 开源模型直接读取概率，据称与 Jev 的结论一致率在 85% 左右；Kev 则基于 Qwen 3.5 制作了 0.8B、4B 和 9B 三个 Jev-like 模型。这类方案在门槛和效果上介于两者之间，适合想先低成本尝鲜的团队。</p>
<h2 id="代码示例同一组问题两种调用方式">代码示例：同一组问题，两种调用方式</h2>
<p>由于 Jev 和 Laya 的问题格式几乎一致，同一组 <code>state</code> 和 <code>questions</code> 可以同时用于两者：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>state <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;I was charged twice for order A-104. Please refund the duplicate.&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;order&#34;</span>: {<span style="color:#e6db74">&#34;charges&#34;</span>: [<span style="color:#ae81ff">49</span>, <span style="color:#ae81ff">49</span>]},
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>questions <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;department&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Which team should handle this request?&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;criteria&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;billing&#34;</span>: <span style="color:#e6db74">&#34;Payments, invoices, and refunds&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;technical&#34;</span>: <span style="color:#e6db74">&#34;Bugs and integrations&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;account&#34;</span>: <span style="color:#e6db74">&#34;Account access and profile issues&#34;</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;urgency&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;How urgent is this request?&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;criteria&#34;</span>: [<span style="color:#e6db74">&#34;Not urgent&#34;</span>, <span style="color:#e6db74">&#34;Soon&#34;</span>, <span style="color:#e6db74">&#34;Critical or blocking&#34;</span>],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;refund_requested&#34;</span>: {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Does the message request a refund?&#34;</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>调用云端的 Jev（固定版本号，避免别名升级后调好的阈值失效）：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> requests
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>resp <span style="color:#f92672">=</span> requests<span style="color:#f92672">.</span>post(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://api.typesafe.ai/v1/systemone&#34;</span>,
</span></span><span style="display:flex;"><span>    headers<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;Authorization&#34;</span>: <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Bearer </span><span style="color:#e6db74">{</span>os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#39;TYPESAFE_API_KEY&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>},
</span></span><span style="display:flex;"><span>    json<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;model&#34;</span>: <span style="color:#e6db74">&#34;jev-1.13.0&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: state, <span style="color:#e6db74">&#34;questions&#34;</span>: questions},
</span></span><span style="display:flex;"><span>    timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">5</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>resp<span style="color:#f92672">.</span>raise_for_status()
</span></span><span style="display:flex;"><span>answers <span style="color:#f92672">=</span> resp<span style="color:#f92672">.</span>json()[<span style="color:#e6db74">&#34;answers&#34;</span>]
</span></span></code></pre></div><p>在本地运行 Laya：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> laya <span style="color:#f92672">import</span> Router
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 生产环境建议预加载，避免切换语言时重新加载模型</span>
</span></span><span style="display:flex;"><span>router <span style="color:#f92672">=</span> Router(preload<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>answers <span style="color:#f92672">=</span> router<span style="color:#f92672">.</span>predict(state, questions)[<span style="color:#e6db74">&#34;answers&#34;</span>]
</span></span></code></pre></div><p>两者返回的结构基本相同：<code>choice</code> 包含 <code>choice</code>、<code>probabilities</code> 和 <code>confidence</code>，<code>score</code> 包含期望分数、等级概率分布和 <code>confidence</code>，<code>noul</code> 则直接返回“是”的概率。后续的置信度分流逻辑可以完全复用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>dept <span style="color:#f92672">=</span> answers[<span style="color:#e6db74">&#34;department&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> dept[<span style="color:#e6db74">&#34;confidence&#34;</span>] <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0.9</span>:
</span></span><span style="display:flex;"><span>    route_to_team(dept[<span style="color:#e6db74">&#34;choice&#34;</span>])     <span style="color:#75715e"># 高置信度：自动执行</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">elif</span> dept[<span style="color:#e6db74">&#34;confidence&#34;</span>] <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0.6</span>:
</span></span><span style="display:flex;"><span>    review_with_llm(state, dept)      <span style="color:#75715e"># 中置信度：交给更强的模型复核</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>    send_to_human(state)              <span style="color:#75715e"># 低置信度：转人工</span>
</span></span></code></pre></div><p>这里的 0.9 和 0.6 只是示意，真实的阈值一定要用自己的数据来标定。</p>
<h2 id="落地时的共同注意事项">落地时的共同注意事项</h2>
<p>无论选择哪一个，以下几点都同样适用：</p>
<ol>
<li><strong>类型安全 ≠ 判断正确</strong>：校准描述的是一组预测的统计性质，并不保证单个答案正确，阈值必须用自己的数据标定。</li>
<li><strong>把问题拆小</strong>：不要问“这张工单该怎么处理”，而是拆成“是否申请退款”“紧急程度如何”“属于哪条业务线”等原子问题，再在代码里组合。</li>
<li><strong>计算交给代码</strong>：计数、数学运算、日期比较等确定性逻辑不要交给模型。</li>
<li><strong>精简 state</strong>：只放与问题相关的字段，无关内容会降低准确率；Laya 的上下文更短，这一点尤为关键。</li>
<li><strong>不可逆操作要兜底</strong>：付款、删除数据、医疗或合规审批等操作，不能仅凭一个概率就自动执行。</li>
<li><strong>防范注入</strong>：state 虽然是数据，但其中的恶意内容仍可能影响判断，不要把决策模型当作唯一的安全防线。</li>
<li><strong>固定版本，持续监控</strong>：固定模型版本（如 <code>jev-1.13.0</code> 或指定的 Laya 检查点），升级后重新标定阈值；上线后持续记录概率分布、抽样复核，并监控校准漂移。</li>
</ol>
<h2 id="总结">总结</h2>
<p>用一句话概括：</p>

<blockquote>
  <p><strong>Jev 是“开箱即用的云端通才”，Laya 是“可以特化的本地专才”。</strong></p>

</blockquote>
<p><img alt="开箱即用的通才 vs 可以特化的专才" loading="lazy" src="/zh/blog/2026/jev-vs-laya/5-generalist-vs-specialist.png"></p>
<ul>
<li>如果你追求快速上线、手上没有标注数据，或者面对长上下文和复杂的选项空间，Jev 是更省心的选择；</li>
<li>如果你有数据、有合规要求、需要毫秒级的实时决策或海量调用，并且有能力运维一个小模型，Laya 更值得投入。</li>
</ul>
<p>两者并不是替代关系，而是同一条“决策光谱”上的不同档位。更值得关注的是这场对决背后的趋势：AI 系统正在从“一个大模型包打天下”走向分工协作，<strong>代码负责骨架，决策模型负责快速判断，大模型负责深度思考</strong>。Jev 定义了这个品类，Laya 则在三天之内证明了开源社区可以迅速跟上。对开发者来说，这意味着工具箱里又多了一种更快、更便宜、也更可控的构建模块。</p>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">TypeSafe AI：Introducing System One Models &amp; Jev</a></li>
<li><a href="https://docs.typesafe.ai/models">TypeSafe 文档：Models</a></li>
<li><a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13">TypeSafe 文档：Jev 1.13 jaggedness</a></li>
<li><a href="https://docs.typesafe.ai/confidence">TypeSafe 文档：Confidence</a></li>
<li><a href="https://evals.typesafe.ai/">TypeSafe Workflow Evals</a></li>
<li><a href="https://github.com/NandhaKishorM/laya">Laya GitHub 仓库</a></li>
<li><a href="https://huggingface.co/convaiinnovations/laya">Laya Hugging Face 模型页</a></li>
<li><a href="https://dev.to/nandakishor_m_6cc0adfde9f/i-built-non-autoregressive-decision-models-a-year-ago-then-a-frontier-lab-called-it-a-18me">Nandakishor M：I Built Non-Autoregressive Decision Models a Year Ago. Then a Frontier Lab Called It a &ldquo;Breakthrough&rdquo;.</a></li>
<li><a href="https://www.huxiu.com/article/4893173.html">虎嗅：号称快200倍的Jev模型，拿不下LLM的大结果</a></li>
</ul>
]]></content:encoded></item><item><title>使用 Envoy Ratelimit 构建支持 RPM/TPM 的分布式 AI 限流服务器</title><link>https://wilsonwu.me/zh/blog/2026/envoy-ai-ratelimit/</link><pubDate>Mon, 17 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/envoy-ai-ratelimit/</guid><description>&lt;p&gt;&lt;img alt="概览" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/envoy-ai-ratelimit/1-overview.png"&gt;&lt;/p&gt;
&lt;p&gt;在普通 HTTP API 中，我们通常只需要限制“每分钟请求数”。但对于大模型接口，仅限制请求数量远远不够：一次请求可能只消耗几十个 Token，也可能消耗数万个 Token。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="概览" loading="lazy" src="/zh/blog/2026/envoy-ai-ratelimit/1-overview.png"></p>
<p>在普通 HTTP API 中，我们通常只需要限制“每分钟请求数”。但对于大模型接口，仅限制请求数量远远不够：一次请求可能只消耗几十个 Token，也可能消耗数万个 Token。</p>
<p>因此，一个实用的 AI 网关通常需要同时支持：</p>
<ul>
<li><strong>RPM（Requests Per Minute）</strong>：每分钟请求数。</li>
<li><strong>TPM（Tokens Per Minute）</strong>：每分钟 Token 数。</li>
<li>按租户、API Key、模型或套餐分别计费。</li>
<li>多个 Envoy 实例共享同一份额度。</li>
<li>在高并发条件下避免各实例独立计数造成额度放大。</li>
<li>返回统一的 <code>429 Too Many Requests</code> 和剩余额度信息。</li>
</ul>
<p>本文介绍如何使用 <a href="https://github.com/envoyproxy/ratelimit">envoyproxy/ratelimit</a>、Redis 和 Envoy 构建一套分布式 AI 限流系统。</p>
<h2 id="为什么不能只使用-envoy-local-rate-limit">为什么不能只使用 Envoy Local Rate Limit</h2>
<p>Envoy 提供了 Local Rate Limit Filter，但它的计数器默认位于当前 Envoy 进程中。</p>
<p>假设我们有 4 个 Envoy 实例，并为某个租户配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>RPM = 100
</span></span></code></pre></div><p>如果每个 Envoy 都独立维护 100 RPM，那么整个集群实际上可能放行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>4 × 100 = 400 RPM
</span></span></code></pre></div><p>这显然不是我们想要的结果。</p>
<p>Envoy 的 Global Rate Limit Filter 可以把限流请求发送给外部 Rate Limit Service。<code>envoyproxy/ratelimit</code> 正是 Envoy 官方生态中常用的全局限流服务实现，它将计数器存放在 Redis 中。</p>
<p>整体架构如下：</p>
<p><img alt="架构" loading="lazy" src="/zh/blog/2026/envoy-ai-ratelimit/2-architect.png"></p>
<p>这里增加了一个很薄的 <strong>AI Quota Adapter</strong>，主要用于解决两个问题：</p>
<ol>
<li>从请求体中解析模型、租户和 Token 数量。</li>
<li>分别使用不同的 <code>hits_addend</code> 检查 RPM 和 TPM。</li>
</ol>
<h2 id="为什么-ai-tpm-需要额外的适配层">为什么 AI TPM 需要额外的适配层</h2>
<p>Envoy 的普通限流规则通常把一次 HTTP 请求视为一次命中：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>hits_addend = 1
</span></span></code></pre></div><p>这适合 RPM，但不适合 TPM。</p>
<p>例如下面两个请求：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>请求 A：消耗 200 Token
</span></span><span style="display:flex;"><span>请求 B：消耗 20,000 Token
</span></span></code></pre></div><p>如果两者都只增加一次计数，TPM 就没有意义。TPM 必须让一次请求按照 Token 数增加计数器：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>hits_addend = token_cost
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>RPM 请求：hits_addend = 1
</span></span><span style="display:flex;"><span>TPM 请求：hits_addend = 3200
</span></span></code></pre></div><p>Rate Limit Service 的 <code>hits_addend</code> 通常作用于整个 <code>RateLimitRequest</code>，因此 RPM 和 TPM 需要使用两次限流调用，或者由自定义适配服务组合处理。</p>
<p>推荐的处理顺序是：</p>
<ol>
<li>检查并增加 RPM，<code>hits_addend=1</code>。</li>
<li>RPM 允许后，再检查并增加 TPM，<code>hits_addend=token_cost</code>。</li>
<li>任意一个返回 <code>OVER_LIMIT</code>，适配层向 Envoy 返回 429。</li>
</ol>
<p>这种模式下，每个维度都由 Redis 原子计数，多个 Envoy 实例不会各自放大额度。</p>
<p>需要注意：两次调用并不是跨计数器事务。RPM 已增加后，TPM 可能拒绝请求。因此要提前明确计数语义：</p>
<ul>
<li>RPM 统计所有进入限流流程的请求尝试；或者</li>
<li>自行扩展服务，使用一段 Redis Lua 脚本同时检查 RPM 和 TPM。</li>
</ul>
<p>多数系统接受第一种语义，因为它更简单，也能避免恶意用户通过大量超大请求免费消耗网关资源。</p>
<h2 id="设计限流-descriptor">设计限流 Descriptor</h2>
<p>限流维度通常包括：</p>
<ul>
<li><code>tenant</code>：租户、组织或 API Key。</li>
<li><code>model</code>：模型名称。</li>
<li><code>metric</code>：<code>rpm</code> 或 <code>tpm</code>。</li>
</ul>
<p>最终生成的 Descriptor 可以表示为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant = tenant-a
</span></span><span style="display:flex;"><span>model  = gpt-4.1
</span></span><span style="display:flex;"><span>metric = rpm
</span></span></code></pre></div><p>以及：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant = tenant-a
</span></span><span style="display:flex;"><span>model  = gpt-4.1
</span></span><span style="display:flex;"><span>metric = tpm
</span></span></code></pre></div><p>这样可以实现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant-a + gpt-4.1 -&gt; 60 RPM
</span></span><span style="display:flex;"><span>tenant-a + gpt-4.1 -&gt; 100,000 TPM
</span></span></code></pre></div><p>如果不希望按模型拆分额度，也可以只保留：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant + metric
</span></span></code></pre></div><p>例如所有模型共享同一份租户额度。</p>
<h2 id="配置-envoyproxyratelimit">配置 envoyproxy/ratelimit</h2>
<p>创建 <code>config/config.yaml</code>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">domain</span>: <span style="color:#ae81ff">ai-gateway</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">tenant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">model</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">value</span>: <span style="color:#ae81ff">rpm</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">value</span>: <span style="color:#ae81ff">tpm</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">100000</span>
</span></span></code></pre></div><p>这里 <code>tenant</code> 和 <code>model</code> 没有指定固定值，可用于根据请求中的 Descriptor 值进行分桶。实际部署前，应使用所选 ratelimit 版本提供的配置校验工具检查配置。</p>
<p>适配层发送 RPM Descriptor：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>[
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;tenant-a&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;gpt-4.1&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;rpm&#34;</span>}
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>发送 TPM Descriptor：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>[
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;tenant-a&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;gpt-4.1&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;tpm&#34;</span>}
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>Descriptor 的顺序必须与配置中的嵌套路径一致。</p>
<h3 id="不同套餐额度">不同套餐额度</h3>
<p>如果不同租户拥有不同额度，可以增加套餐维度：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant -&gt; plan -&gt; model -&gt; metric
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">domain</span>: <span style="color:#ae81ff">ai-gateway</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">tenant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">plan</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">value</span>: <span style="color:#ae81ff">standard</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">model</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">rpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">tpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">100000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">plan</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">value</span>: <span style="color:#ae81ff">enterprise</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">model</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">rpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">tpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">2000000</span>
</span></span></code></pre></div><p>请求中的 <code>tenant</code> 用于隔离计数器，<code>plan</code> 用于选择额度。</p>
<p>不要只使用 <code>plan</code> 作为计数维度，否则所有 Standard 用户可能共享同一个计数器。</p>
<h2 id="五使用-docker-compose-部署">五、使用 Docker Compose 部署</h2>
<p>下面是一个简化示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">redis</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">redis:7-alpine</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">redis-server</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">appendonly</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;yes&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">redis-data:/data</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;6379:6379&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ratelimit</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">envoyproxy/ratelimit:&lt;固定版本号&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>: <span style="color:#ae81ff">/bin/ratelimit</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">LOG_LEVEL</span>: <span style="color:#ae81ff">info</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">REDIS_SOCKET_TYPE</span>: <span style="color:#ae81ff">tcp</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">REDIS_URL</span>: <span style="color:#ae81ff">redis:6379</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">RUNTIME_ROOT</span>: <span style="color:#ae81ff">/data</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">RUNTIME_SUBDIRECTORY</span>: <span style="color:#ae81ff">ratelimit</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">RUNTIME_WATCH_ROOT</span>: <span style="color:#e6db74">&#34;false&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">USE_STATSD</span>: <span style="color:#e6db74">&#34;false&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./config:/data/ratelimit</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8081:8081&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;6070:6070&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">depends_on</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">redis</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">redis-data</span>:
</span></span></code></pre></div><p>生产环境不要直接使用 <code>master</code> 或浮动标签，应固定到经过验证的 release 版本。</p>
<p>不同版本的镜像端口和环境变量可能存在差异，因此部署时应以对应版本仓库中的示例配置为准。</p>
<p>启动：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>查看日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose logs -f ratelimit
</span></span></code></pre></div><p>生产环境可以运行多个 ratelimit 副本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ratelimit-1 ─┐
</span></span><span style="display:flex;"><span>ratelimit-2 ─┼── Redis
</span></span><span style="display:flex;"><span>ratelimit-3 ─┘
</span></span></code></pre></div><p>ratelimit 服务本身可以保持无状态，只要所有副本：</p>
<ul>
<li>使用相同的配置。</li>
<li>指向同一个 Redis 逻辑集群。</li>
<li>使用一致的 Redis Key 前缀或命名空间。</li>
</ul>
<h2 id="ai-quota-adapter-的核心逻辑">AI Quota Adapter 的核心逻辑</h2>
<p>适配层可以实现为 Envoy <code>ext_authz</code> 服务。它接收 Envoy 转发过来的请求信息，完成身份识别、Token 计算和额度检查。</p>
<p>下面是简化后的 Go 伪代码：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">CheckAIQuota</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ctx</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Context</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tenant</span> <span style="color:#66d9ef">string</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">model</span> <span style="color:#66d9ef">string</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tokenCost</span> <span style="color:#66d9ef">uint32</span>,
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">error</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 第一次调用：RPM</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">rpmResp</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">rateLimitClient</span>.<span style="color:#a6e22e">ShouldRateLimit</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ctx</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitRequest</span>{
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Domain</span>: <span style="color:#e6db74">&#34;ai-gateway&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Descriptors</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor</span>{
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">Entries</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor_Entry</span>{
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">tenant</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">model</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#e6db74">&#34;rpm&#34;</span>},
</span></span><span style="display:flex;"><span>                    },
</span></span><span style="display:flex;"><span>                },
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">HitsAddend</span>: <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">rpmResp</span>.<span style="color:#a6e22e">OverallCode</span> <span style="color:#f92672">==</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitResponse_OVER_LIMIT</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">ErrRPMExceeded</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 第二次调用：TPM</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tpmResp</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">rateLimitClient</span>.<span style="color:#a6e22e">ShouldRateLimit</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ctx</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitRequest</span>{
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Domain</span>: <span style="color:#e6db74">&#34;ai-gateway&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Descriptors</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor</span>{
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">Entries</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor_Entry</span>{
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">tenant</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">model</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#e6db74">&#34;tpm&#34;</span>},
</span></span><span style="display:flex;"><span>                    },
</span></span><span style="display:flex;"><span>                },
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">HitsAddend</span>: <span style="color:#a6e22e">tokenCost</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">tpmResp</span>.<span style="color:#a6e22e">OverallCode</span> <span style="color:#f92672">==</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitResponse_OVER_LIMIT</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">ErrTPMExceeded</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>实际代码中的 protobuf 包名和字段类型应以项目锁定的 Envoy API 版本为准。</p>
<p>适配层的完整处理流程如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>1. 从 Authorization/API Key 中识别 tenant
</span></span><span style="display:flex;"><span>2. 从 JSON 请求体中读取 model
</span></span><span style="display:flex;"><span>3. 使用模型对应的 tokenizer 计算 input_tokens
</span></span><span style="display:flex;"><span>4. 根据策略计算 token_cost
</span></span><span style="display:flex;"><span>5. 调用 RLS 检查 RPM
</span></span><span style="display:flex;"><span>6. 调用 RLS 检查 TPM
</span></span><span style="display:flex;"><span>7. 都允许：返回 OK
</span></span><span style="display:flex;"><span>8. 任意超限：返回 429
</span></span></code></pre></div><h2 id="在-envoy-中接入适配层">在 Envoy 中接入适配层</h2>
<p>如果适配层实现 Envoy 的 <code>ext_authz</code> gRPC 协议，可以在 Envoy 中配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">http_filters</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">envoy.filters.http.ext_authz</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">typed_config</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;@type&#34;: </span><span style="color:#ae81ff">type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">transport_api_version</span>: <span style="color:#ae81ff">V3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">grpc_service</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">envoy_grpc</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">cluster_name</span>: <span style="color:#ae81ff">ai_quota_adapter</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">timeout</span>: <span style="color:#ae81ff">0.</span><span style="color:#ae81ff">5s</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">with_request_body</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max_request_bytes</span>: <span style="color:#ae81ff">1048576</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">allow_partial_message</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pack_as_bytes</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">failure_mode_allow</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">envoy.filters.http.router</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">typed_config</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;@type&#34;: </span><span style="color:#ae81ff">type.googleapis.com/envoy.extensions.filters.http.router.v3.Router</span>
</span></span></code></pre></div><p>对应的 Cluster：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">clusters</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ai_quota_adapter</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">STRICT_DNS</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">connect_timeout</span>: <span style="color:#ae81ff">0.</span><span style="color:#ae81ff">25s</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">http2_protocol_options</span>: {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">load_assignment</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">cluster_name</span>: <span style="color:#ae81ff">ai_quota_adapter</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">endpoints</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">lb_endpoints</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#f92672">endpoint</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">address</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">socket_address</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">address</span>: <span style="color:#ae81ff">ai-quota-adapter</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">port_value</span>: <span style="color:#ae81ff">9000</span>
</span></span></code></pre></div><p>因为 Token 计算需要读取 JSON 请求体，所以需要配置 <code>with_request_body</code>。</p>
<p>注意限制最大请求体，避免攻击者提交超大 Prompt 导致 Envoy 或适配层内存压力。</p>
<h2 id="tpm-应该如何计算">TPM 应该如何计算</h2>
<p>RPM 的定义比较简单，一次请求增加 1。</p>
<p>TPM 更复杂，因为请求发出时通常只知道输入 Token，尚不知道模型最终会产生多少输出 Token。</p>
<h3 id="方案一只计算输入-token">方案一：只计算输入 Token</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>token_cost = input_tokens
</span></span></code></pre></div><p>优点：</p>
<ul>
<li>请求开始前就能精确计算。</li>
<li>实现最简单。</li>
<li>非流式和流式接口都适用。</li>
</ul>
<p>缺点：</p>
<ul>
<li>无法限制输出 Token。</li>
<li>用户可能通过很短的 Prompt 请求大量输出。</li>
</ul>
<p>适合将 TPM 定义为“输入 Token 配额”的系统。</p>
<h3 id="方案二预留最大输出-token">方案二：预留最大输出 Token</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>token_cost = input_tokens + requested_max_output_tokens
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>input_tokens = 1200
</span></span><span style="display:flex;"><span>max_tokens   = 4096
</span></span><span style="display:flex;"><span>token_cost   = 5296
</span></span></code></pre></div><p>优点：</p>
<ul>
<li>在调用模型前就能做严格准入控制。</li>
<li>不会因为输出 Token 未知而突破额度。</li>
<li>适合流式响应。</li>
</ul>
<p>缺点：</p>
<ul>
<li>实际只输出 300 Token 时仍预留了 4096 Token。</li>
<li>如果不实现退款，额度利用率会偏低。</li>
</ul>
<p>这是最容易实现“严格不超额”的方案。</p>
<p>网关必须对 <code>max_tokens</code> 设置服务端上限，不能完全信任客户端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>effective_max_tokens = min(client_max_tokens, server_model_limit)
</span></span></code></pre></div><p>否则客户端可以提交异常大的 <code>max_tokens</code>，让自己或整个租户迅速耗尽配额。</p>
<h3 id="方案三先预留再按实际用量结算">方案三：先预留，再按实际用量结算</h3>
<p>完整思路是：</p>
<ol>
<li>请求开始时预留：</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>input_tokens + max_output_tokens
</span></span></code></pre></div><ol start="2">
<li>响应结束后得到实际输出 Token。</li>
<li>退还未使用部分：</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>refund = max_output_tokens - actual_output_tokens
</span></span></code></pre></div><p>但 <code>envoyproxy/ratelimit</code> 的标准限流调用主要面向正向增加计数，并不天然提供完整的“预留—提交—退款”事务模型。</p>
<p>如果业务必须支持精确退款，通常需要：</p>
<ul>
<li>在适配层实现额外的 Redis Lua 脚本。</li>
<li>使用独立的预留记录和过期时间。</li>
<li>请求结束后进行 commit/refund。</li>
<li>使用请求 ID 保证幂等。</li>
</ul>
<p>此时 <code>envoyproxy/ratelimit</code> 仍可负责 RPM 或基础 TPM，但结算逻辑已经属于定制配额服务。</p>
<h3 id="方案四流式增量扣费">方案四：流式增量扣费</h3>
<p>对于流式输出，可以：</p>
<ol>
<li>请求开始时扣除输入 Token。</li>
<li>每生成一批输出 Token，再增加一次 TPM 计数。</li>
<li>一旦超过额度，终止上游流。</li>
</ol>
<p>例如每 32 或 128 个 Token 扣一次。</p>
<p>这种方式需要能观察响应流，因此仅靠请求阶段的 <code>ext_authz</code> 不够，需要：</p>
<ul>
<li>Envoy <code>ext_proc</code>；</li>
<li>Wasm Filter；</li>
<li>自定义 HTTP Filter；</li>
<li>或由 AI Gateway 自身代理并读取流式响应。</li>
</ul>
<p>增量批次越小，控制越精确，但 gRPC 和 Redis 调用次数也越多。</p>
<h2 id="多-envoy-实例如何保证共享额度">多 Envoy 实例如何保证共享额度</h2>
<p>假设一个租户拥有：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>RPM = 100
</span></span><span style="display:flex;"><span>TPM = 200,000
</span></span></code></pre></div><p>请求可能随机落到不同 Envoy：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Request 1 -&gt; Envoy A
</span></span><span style="display:flex;"><span>Request 2 -&gt; Envoy B
</span></span><span style="display:flex;"><span>Request 3 -&gt; Envoy C
</span></span></code></pre></div><p>但所有限流请求最终都会访问同一套 Redis 计数器：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ai-gateway / tenant-a / gpt-4.1 / rpm
</span></span><span style="display:flex;"><span>ai-gateway / tenant-a / gpt-4.1 / tpm
</span></span></code></pre></div><p>因此额度是整个集群共享的，而不是每个 Envoy 各有一份。</p>
<p>可以横向扩展的部分包括：</p>
<ul>
<li>Envoy。</li>
<li>AI Quota Adapter。</li>
<li>envoyproxy/ratelimit。</li>
</ul>
<p>不能随意拆分的是同一个配额 Key 的计数状态。如果使用多个完全独立的 Redis，则必须确保相同租户、模型和指标始终路由到同一个分片，否则额度又会被放大。</p>
<h2 id="返回标准限流响应">返回标准限流响应</h2>
<p>发生超限时，适配层应向 Envoy 返回 429：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#66d9ef">HTTP</span><span style="color:#f92672">/</span><span style="color:#ae81ff">1.1</span> <span style="color:#ae81ff">429</span> <span style="color:#a6e22e">Too Many Requests</span>
</span></span><span style="display:flex;"><span>Content-Type<span style="color:#f92672">:</span> <span style="color:#ae81ff">application/json</span>
</span></span><span style="display:flex;"><span>Retry-After<span style="color:#f92672">:</span> <span style="color:#ae81ff">12</span>
</span></span></code></pre></div><p>响应体可以采用 OpenAI 风格：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;error&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;Rate limit exceeded for tenant tenant-a&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;rate_limit_error&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;code&#34;</span>: <span style="color:#e6db74">&#34;tpm_limit_exceeded&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>建议分别暴露 RPM 和 TPM 信息：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Limit-Requests: 60
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Remaining-Requests: 12
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Reset-Requests: 18
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Limit-Tokens: 100000
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Remaining-Tokens: 24580
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Reset-Tokens: 18
</span></span></span></code></pre></div><p>为了避免客户端形成同步重试风暴，SDK 应使用带随机抖动的指数退避：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sleep = random(0, min(cap, base × 2^retry_count))
</span></span></code></pre></div><h2 id="精准限流需要明确的边界">“精准限流”需要明确的边界</h2>
<p>使用 Redis 共享计数器可以解决多实例额度放大的问题，但“精准”仍然需要明确具体含义。</p>
<h3 id="1-它通常是固定时间窗">1. 它通常是固定时间窗</h3>
<p>配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span></code></pre></div><p>通常表示分钟级固定时间窗，不等同于严格的滑动窗口。</p>
<p>用户可能在一个窗口结束前发送 100 次请求，并在新窗口开始后立刻再发送 100 次。从任意连续 60 秒观察，流量可能接近配置值的两倍。</p>
<p>因此，该方案能够保证：</p>

<blockquote>
  <p>所有 Envoy 实例共享同一个固定时间窗计数器。</p>

</blockquote>
<p>但它不自动等于：</p>

<blockquote>
  <p>任意连续 60 秒都绝不超过限制。</p>

</blockquote>
<p>如果业务要求严格滑动窗口，需要使用 Redis Sorted Set、滑动窗口计数器或专门的配额算法。</p>
<h3 id="2-输出-token-在请求开始时未知">2. 输出 Token 在请求开始时未知</h3>
<p>如果 TPM 包含输出 Token，则无法在生成之前知道最终消耗量。</p>
<p>所谓严格控制通常意味着：</p>
<ul>
<li>使用 <code>max_tokens</code> 预留；或者</li>
<li>在流式生成过程中增量扣费。</li>
</ul>
<p>单纯在响应结束后上报实际 Token，只能用于计费和统计，不能阻止当前请求突破 TPM。</p>
<h3 id="3-rpm-和-tpm-不是一个原子事务">3. RPM 和 TPM 不是一个原子事务</h3>
<p>标准 RLS 请求中的 <code>hits_addend</code> 是统一的，而 RPM 和 TPM 需要不同增量，因此通常需要两次调用。</p>
<p>如果必须保证“RPM 和 TPM 同时检查、同时增加、任意失败都不修改”，需要定制服务并使用单个 Redis Lua 脚本完成事务。</p>
<h2 id="生产环境中的关键问题">生产环境中的关键问题</h2>
<h3 id="1-请求重试和重复扣费">1. 请求重试和重复扣费</h3>
<p>Rate Limit Service 的计数调用通常不是业务级幂等操作。</p>
<p>如果适配层因为超时重试同一个请求：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>第一次调用已经写入 Redis，但响应丢失
</span></span><span style="display:flex;"><span>第二次调用再次增加计数
</span></span></code></pre></div><p>就会产生重复扣费。</p>
<p>建议：</p>
<ul>
<li>限流调用不要盲目自动重试。</li>
<li>使用 Envoy 的 <code>x-request-id</code> 或业务请求 ID。</li>
<li>对严格计费场景，在适配层中实现请求幂等记录。</li>
<li>幂等记录过期时间至少覆盖限流窗口和最大请求时长。</li>
</ul>
<h3 id="2-故障时-fail-open-还是-fail-closed">2. 故障时 Fail Open 还是 Fail Closed</h3>
<p>当 Redis 或 ratelimit 服务不可用时，可以选择：</p>
<p><strong>Fail Open：</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>限流系统不可用时继续放行
</span></span></code></pre></div><p>优点是可用性高，缺点是可能导致成本失控。</p>
<p><strong>Fail Closed：</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>限流系统不可用时拒绝请求
</span></span></code></pre></div><p>优点是保护成本，缺点是限流组件故障会影响业务。</p>
<p>AI 推理通常成本较高，面向外部用户的付费 API 更适合 Fail Closed。内部低风险环境可以考虑短时间 Fail Open，并配合熔断和告警。</p>
<h3 id="3-tokenizer-必须与模型匹配">3. Tokenizer 必须与模型匹配</h3>
<p>不同模型的 Tokenizer 可能不同。不能使用简单的字符数除以固定比例来实现严格 TPM。</p>
<p>应该维护：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>model -&gt; tokenizer
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>gpt-family       -&gt; cl100k/o200k 类 tokenizer
</span></span><span style="display:flex;"><span>llama-family     -&gt; 对应 SentencePiece/BPE tokenizer
</span></span><span style="display:flex;"><span>自定义模型        -&gt; 模型实际 tokenizer.json
</span></span></code></pre></div><p>如果 tokenizer 不可用，可以做保守估算，但必须预留误差：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>estimated_tokens = ceil(raw_estimate × safety_factor)
</span></span></code></pre></div><h3 id="4-规范化模型名称">4. 规范化模型名称</h3>
<p>客户端可能使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>gpt-4.1
</span></span><span style="display:flex;"><span>gpt-4.1-2025-04-14
</span></span><span style="display:flex;"><span>my-gpt-alias
</span></span></code></pre></div><p>限流前应将它们映射到统一的配额名称：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>my-gpt-alias -&gt; gpt-4.1
</span></span></code></pre></div><p>否则用户可能通过切换别名绕过同一模型的额度。</p>
<h3 id="5-不要信任客户端提供的-token-数">5. 不要信任客户端提供的 Token 数</h3>
<p>不能让客户端直接提交：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-Token-Cost: 1
</span></span></span></code></pre></div><p>然后按该值扣费。</p>
<p>Token 数必须由可信组件计算，或者由上游模型返回后进行校验。客户端声明只能作为参考，不能作为计费依据。</p>
<h3 id="6-配置发布必须一致">6. 配置发布必须一致</h3>
<p>如果多个 ratelimit 副本加载了不同配置，可能出现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>副本 A：RPM 100
</span></span><span style="display:flex;"><span>副本 B：RPM 200
</span></span></code></pre></div><p>即使 Redis 相同，结果也会变得不可预测。</p>
<p>生产环境应：</p>
<ul>
<li>使用固定版本配置。</li>
<li>进行启动前校验。</li>
<li>滚动发布时保持向后兼容。</li>
<li>为配置添加版本号和监控指标。</li>
</ul>
<h2 id="监控指标">监控指标</h2>
<p>至少应监控：</p>
<h3 id="envoy">Envoy</h3>
<ul>
<li>ext_authz 调用次数。</li>
<li>ext_authz 超时和失败次数。</li>
<li>HTTP 429 数量。</li>
<li>上游请求数和并发数。</li>
<li>请求体缓冲失败次数。</li>
</ul>
<h3 id="ai-quota-adapter">AI Quota Adapter</h3>
<ul>
<li>RPM 允许和拒绝次数。</li>
<li>TPM 允许和拒绝次数。</li>
<li>Token 计算耗时。</li>
<li>RLS gRPC 延迟。</li>
<li>Tokenizer 加载失败次数。</li>
<li>按模型和租户聚合的 Token 消耗。</li>
<li>重复请求或幂等命中次数。</li>
</ul>
<h3 id="ratelimit">Ratelimit</h3>
<ul>
<li><code>OK</code>、<code>OVER_LIMIT</code>、错误数量。</li>
<li>Redis 请求延迟。</li>
<li>Redis 连接池使用率。</li>
<li>配置加载状态。</li>
<li>各限流规则命中次数。</li>
</ul>
<h3 id="redis">Redis</h3>
<ul>
<li>CPU 和内存。</li>
<li>命令延迟。</li>
<li>连接数。</li>
<li>Key 数量。</li>
<li>Key 过期速率。</li>
<li>主从复制延迟。</li>
<li>Failover 次数。</li>
</ul>
<p>高基数租户 ID 不适合直接作为 Prometheus Label。否则租户数量较多时会产生严重的指标基数问题。按租户的详细用量更适合写入日志、ClickHouse 或专门的计量系统。</p>
<h2 id="推荐的最终实现">推荐的最终实现</h2>
<p>对于大多数 AI API 网关，可以采用下面的组合：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Envoy
</span></span><span style="display:flex;"><span>  └── ext_authz
</span></span><span style="display:flex;"><span>        └── AI Quota Adapter
</span></span><span style="display:flex;"><span>              ├── 解析租户和模型
</span></span><span style="display:flex;"><span>              ├── 使用可信 tokenizer 计算 input_tokens
</span></span><span style="display:flex;"><span>              ├── 规范化并限制 max_tokens
</span></span><span style="display:flex;"><span>              ├── RPM：RLS hits_addend = 1
</span></span><span style="display:flex;"><span>              └── TPM：RLS hits_addend =
</span></span><span style="display:flex;"><span>                       input_tokens + max_output_tokens
</span></span><span style="display:flex;"><span>                        └── envoyproxy/ratelimit
</span></span><span style="display:flex;"><span>                              └── Redis
</span></span></code></pre></div><p>这一方案具备以下特征：</p>
<ul>
<li>多 Envoy 实例共享统一额度。</li>
<li>RPM 和 TPM 分别计数。</li>
<li>支持按租户、模型、套餐隔离。</li>
<li>在请求进入模型服务之前完成准入控制。</li>
<li>通过预留最大输出 Token，保证不会因为流式输出突破 TPM。</li>
<li>Envoy、适配层和 ratelimit 服务都可以横向扩展。</li>
</ul>
<p>如果还需要按照实际输出 Token 精确结算，则在此基础上增加：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>预留记录 + 响应用量采集 + commit/refund + 请求幂等
</span></span></code></pre></div><p>此时系统已经从“限流服务”演进为一套完整的“分布式 AI 配额与计量服务”。</p>
<h2 id="总结">总结</h2>
<p><code>envoyproxy/ratelimit</code> 非常适合解决多 Envoy 实例之间的全局共享限流问题。它通过 Redis 集中维护计数器，避免每个网关实例独立计算额度。</p>
<p>对于 AI 场景，需要在标准 HTTP 限流之上增加 Token 感知能力：</p>
<ul>
<li>RPM 使用 <code>hits_addend=1</code>。</li>
<li>TPM 使用 <code>hits_addend=token_cost</code>。</li>
<li>使用租户、模型和指标构建 Descriptor。</li>
<li>请求阶段通过输入 Token或最大输出 Token预留完成准入。</li>
<li>多 Envoy、多个适配层和多个 RLS 副本共享同一个 Redis 计数空间。</li>
</ul>
<p>需要特别注意的是，分布式共享计数、固定时间窗、RPM/TPM 组合事务以及实际输出 Token 结算是四个不同问题。理解这些边界，才能构建一套既能保护模型成本、又能在高并发环境中稳定运行的 AI 限流系统。</p>
]]></content:encoded></item><item><title>OpenWebUI 数据库迁移实践：从 VM 自建 PostgreSQL 平滑迁移到 Azure 托管 PostgreSQL</title><link>https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/</link><pubDate>Thu, 13 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/</guid><description>&lt;p&gt;&lt;img alt="迁移" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/1-overview.png"&gt;&lt;/p&gt;
&lt;p&gt;随着 OpenWebUI 中的用户、对话、文件和知识库数据不断增长，原先部署在 Azure VM 上的自建 PostgreSQL 带来了越来越多的运维工作，包括数据库补丁、备份、故障恢复、存储监控和安全维护。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="迁移" loading="lazy" src="/zh/blog/2026/migrate-openwebui-to-azure-pg/1-overview.png"></p>
<p>随着 OpenWebUI 中的用户、对话、文件和知识库数据不断增长，原先部署在 Azure VM 上的自建 PostgreSQL 带来了越来越多的运维工作，包括数据库补丁、备份、故障恢复、存储监控和安全维护。</p>
<p>为了降低维护成本并提高数据库可靠性，我将 <strong>OpenWebUI 的业务数据库</strong>从 VM 本地 PostgreSQL 迁移到了 <strong>Azure Database for PostgreSQL Flexible Server</strong>。</p>
<p>这次迁移采用了：</p>

<blockquote>
  <p>在线全量迁移 + 数据一致性验证 + 活跃对话增量同步 + 快速切换</p>

</blockquote>
<p>全量数据库导出和恢复期间，OpenWebUI 始终保持在线。最终切换仅涉及一次很短的 Docker 容器重建，用户侧基本无感。</p>

<blockquote>
  <p>本文中的服务器地址、数据库名称、用户名、密码和资源标识均已隐藏或替换为占位符。</p>

</blockquote>
<h2 id="迁移背景">迁移背景</h2>
<p>OpenWebUI 以 Docker 容器形式运行在 Azure VM 中，最初使用安装在同一台 VM 上的 PostgreSQL。</p>
<p>迁移前的架构如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>Azure VM
</span></span><span style="display:flex;"><span>  ├── OpenWebUI Docker 容器
</span></span><span style="display:flex;"><span>  │      └── 连接 VM 宿主机 PostgreSQL
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  └── PostgreSQL
</span></span><span style="display:flex;"><span>         └── 数据保存在 VM 本地磁盘
</span></span></code></pre></div><p>这种部署方式比较简单，但随着应用逐渐稳定运行，数据库维护也成为了新的负担。</p>
<p>需要自行处理的工作包括：</p>
<ul>
<li>PostgreSQL 安全更新和补丁</li>
<li>数据库备份及备份轮换</li>
<li>数据恢复和恢复演练</li>
<li>磁盘容量监控</li>
<li>数据库服务监控</li>
<li>PostgreSQL 版本升级</li>
<li>故障处理</li>
<li>网络和访问权限管理</li>
</ul>
<p>对于 OpenWebUI 这样的应用来说，数据库是重要的状态组件，但并不是应用的核心运维目标。因此，将 PostgreSQL 迁移到 Azure 托管服务是一个更合理的选择。</p>
<h2 id="迁移后的架构">迁移后的架构</h2>
<p>迁移后的架构如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>Azure VM
</span></span><span style="display:flex;"><span>  └── OpenWebUI Docker 容器
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          ├── Private Endpoint
</span></span><span style="display:flex;"><span>          │       └── Azure Database for PostgreSQL
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          └── 宿主机持久化目录
</span></span><span style="display:flex;"><span>                  ├── uploads
</span></span><span style="display:flex;"><span>                  ├── vector_db
</span></span><span style="display:flex;"><span>                  └── cache
</span></span></code></pre></div><p>最终实现了：</p>
<ul>
<li>OpenWebUI 使用 Azure 托管 PostgreSQL</li>
<li>数据库通过 Private Endpoint 私网访问</li>
<li>数据库连接启用 TLS</li>
<li>PostgreSQL 不再占用 VM 本地端口和磁盘</li>
<li>OpenWebUI 上传文件和向量数据实现持久化挂载</li>
<li>Azure 自动执行数据库备份</li>
<li>VM 上只保留 PostgreSQL 客户端，用于日常管理 Azure 数据库</li>
</ul>
<p><img alt="架构" loading="lazy" src="/zh/blog/2026/migrate-openwebui-to-azure-pg/2-architecture.png"></p>
<h2 id="为什么选择-azure-database-for-postgresql">为什么选择 Azure Database for PostgreSQL</h2>
<h3 id="1-降低数据库运维成本">1. 降低数据库运维成本</h3>
<p>Azure Database for PostgreSQL Flexible Server 是一项托管数据库服务。</p>
<p>与 VM 自建 PostgreSQL 相比，Azure 可以帮助处理大量底层工作，例如：</p>
<ul>
<li>数据库基础设施维护</li>
<li>自动备份</li>
<li>事务日志备份</li>
<li>时间点恢复</li>
<li>监控指标采集</li>
<li>存储管理</li>
<li>计划性维护</li>
<li>可选高可用</li>
<li>可选只读副本</li>
</ul>
<p>迁移完成后，VM 只需要负责运行 OpenWebUI，数据库生命周期则交给 Azure 管理。</p>
<h3 id="2-自动备份和时间点恢复">2. 自动备份和时间点恢复</h3>
<p>Azure PostgreSQL 提供内置自动备份，并持续备份事务日志，支持时间点恢复（Point-in-Time Restore，PITR）。</p>
<p>当前环境配置了自动备份保留策略。发生误操作时，可以在保留期内创建一台恢复到指定时间点的新服务器。</p>
<p>与自行编写 <code>pg_dump</code> 和 Cron 脚本相比，Azure 托管备份具有以下优势：</p>
<ul>
<li>不需要维护备份脚本</li>
<li>不需要手动轮换备份文件</li>
<li>不会占用 VM 本地磁盘</li>
<li>可以恢复到保留期内的特定时间</li>
<li>备份操作由平台统一管理</li>
</ul>
<p>需要注意的是，Azure 内置备份主要用于平台恢复，不等同于可随意下载的逻辑备份文件。</p>
<p>如果有以下需求，仍建议额外定期执行 <code>pg_dump</code>：</p>
<ul>
<li>长期离线归档</li>
<li>跨平台恢复</li>
<li>数据审计</li>
<li>跨订阅或跨云迁移</li>
<li>保留期超过平台备份策略</li>
</ul>
<h3 id="3-使用-private-endpoint-私网访问">3. 使用 Private Endpoint 私网访问</h3>
<p>本次迁移没有将 PostgreSQL 直接暴露到公网，而是为 Azure PostgreSQL 配置了 Private Endpoint。</p>
<p>OpenWebUI 访问数据库时，数据库域名通过 Private DNS 解析到 VNet 中的私有 IP：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Azure PostgreSQL FQDN
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>Private DNS
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>Private Endpoint 私有 IP
</span></span></code></pre></div><p>从 OpenWebUI 容器内部进行了两层验证：</p>
<ol>
<li>DNS 是否解析到私有地址</li>
<li>TCP 5432 是否可以通过私网访问</li>
</ol>
<p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec -i open-webui python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import socket
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">host = &#34;&lt;AZURE_POSTGRESQL_PRIVATE_FQDN&gt;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">port = 5432
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">addresses = sorted({
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    item[4][0]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    for item in socket.getaddrinfo(
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        host,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        port,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        type=socket.SOCK_STREAM
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    )
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">})
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Resolved addresses:&#34;, addresses)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">with socket.create_connection((host, port), timeout=5) as sock:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    print(&#34;TCP connection: OK&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    print(&#34;Connected peer:&#34;, sock.getpeername())
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>数据库安全性并不只是来自 Private Endpoint，而是来自以下措施的组合：</p>
<ul>
<li>Private Endpoint</li>
<li>Private DNS</li>
<li>VNet 隔离</li>
<li>NSG 和路由控制</li>
<li>禁用或限制公网访问</li>
<li>PostgreSQL 身份认证</li>
<li>TLS 链路加密</li>
<li>最小权限数据库账号</li>
</ul>
<h3 id="4-数据库连接启用-tls">4. 数据库连接启用 TLS</h3>
<p>OpenWebUI 的数据库连接串启用了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sslmode=require
</span></span></code></pre></div><p>脱敏后的连接串格式如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgresql://&lt;APP_USER&gt;:&lt;PASSWORD&gt;@&lt;AZURE_POSTGRESQL_FQDN&gt;:5432/&lt;APP_DATABASE&gt;?sslmode=require
</span></span></code></pre></div><p>迁移前还通过 OpenWebUI 使用的 Python 和 SQLAlchemy 环境实际验证了 TLS：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> create_engine, text
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>engine <span style="color:#f92672">=</span> create_engine(os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#34;DATABASE_URL&#34;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">with</span> engine<span style="color:#f92672">.</span>connect() <span style="color:#66d9ef">as</span> conn:
</span></span><span style="display:flex;"><span>    ssl_enabled <span style="color:#f92672">=</span> conn<span style="color:#f92672">.</span>execute(text(<span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SELECT ssl
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        FROM pg_stat_ssl
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        WHERE pid = pg_backend_pid()
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>))<span style="color:#f92672">.</span>scalar_one()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;TLS enabled:&#34;</span>, ssl_enabled)
</span></span></code></pre></div><p>如果安全要求更高，可以进一步使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sslmode=verify-full
</span></span></code></pre></div><p>这种模式不仅加密连接，还会验证证书和服务器主机名，但需要正确配置 CA 证书，并使用与证书匹配的服务器 FQDN。</p>
<h3 id="5-更容易扩展可靠性能力">5. 更容易扩展可靠性能力</h3>
<p>迁移到 Azure PostgreSQL 后，可以根据 OpenWebUI 的使用规模继续配置：</p>
<ul>
<li>计算规格扩缩容</li>
<li>存储扩容</li>
<li>可用区高可用</li>
<li>只读副本</li>
<li>慢查询分析</li>
<li>数据库连接监控</li>
<li>CPU、内存和存储告警</li>
<li>更长的备份保留期</li>
<li>异地恢复策略</li>
</ul>
<p>这使 OpenWebUI 的数据库层从“单 VM 上的本地服务”升级为独立、可管理的托管数据服务。</p>
<h2 id="迁移目标让-openwebui-尽量保持在线">迁移目标：让 OpenWebUI 尽量保持在线</h2>
<p>传统迁移通常采用以下流程：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>停止应用
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>导出数据库
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>恢复目标数据库
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>修改连接配置
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>重新启动应用
</span></span></code></pre></div><p>这种方式虽然简单，但数据库越大，停机时间越长。</p>
<p>本次迁移采用了不同策略：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>在线全量备份
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>在线恢复到 Azure PostgreSQL
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>验证结构、权限和数据
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>同步迁移期间新增的活跃对话
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>快速重建 OpenWebUI 容器
</span></span></code></pre></div><p>在以下阶段，OpenWebUI 都保持在线：</p>
<ul>
<li>全量 <code>pg_dump</code></li>
<li>Azure PostgreSQL 数据恢复</li>
<li>表结构验证</li>
<li>数据量对比</li>
<li>Private Endpoint 验证</li>
<li>TLS 验证</li>
<li>增量数据同步</li>
</ul>
<p>最终只有更换 <code>DATABASE_URL</code> 并重建容器时发生了短暂连接中断。</p>
<p>因此，更准确地说，这是一种：</p>

<blockquote>
  <p><strong>近零停机、用户侧近乎无感知的 OpenWebUI 数据库迁移方案。</strong></p>

</blockquote>
<h2 id="迁移前检查-openwebui">迁移前检查 OpenWebUI</h2>
<h3 id="1-确认-openwebui-使用的数据库类型">1. 确认 OpenWebUI 使用的数据库类型</h3>
<p>首先检查 OpenWebUI 容器环境变量：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect open-webui <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format <span style="color:#e6db74">&#39;{{range .Config.Env}}{{println .}}{{end}}&#39;</span> |
</span></span><span style="display:flex;"><span>grep -E <span style="color:#e6db74">&#39;DATABASE_URL|DATA_DIR|WEBUI_SECRET_KEY&#39;</span>
</span></span></code></pre></div><p>确认 OpenWebUI 已经使用 PostgreSQL，而不是默认 SQLite：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DATABASE_URL=postgresql://&lt;LOCAL_USER&gt;:***@&lt;LOCAL_HOST&gt;:5432/&lt;LOCAL_DATABASE&gt;
</span></span></code></pre></div><p>由于源端和目标端都是 PostgreSQL，因此可以直接使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>pg_dump → pg_restore
</span></span></code></pre></div><p>不需要使用 SQLite 到 PostgreSQL 的转换工具。</p>
<h3 id="2-检查源数据库">2. 检查源数据库</h3>
<p>检查 PostgreSQL 版本和数据库大小：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d &lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">SELECT
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  current_database(),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  current_setting(&#39;server_version&#39;),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  pg_size_pretty(
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    pg_database_size(current_database())
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  );
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>同时检查源数据库使用了哪些扩展：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d &lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">SELECT extname, extversion
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">FROM pg_extension
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">ORDER BY extname;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>本次源数据库没有使用额外的第三方扩展，因此降低了跨环境迁移的兼容性风险。</p>
<h3 id="3-检查-openwebui-的本地文件">3. 检查 OpenWebUI 的本地文件</h3>
<p>数据库并不是 OpenWebUI 的全部数据。</p>
<p>检查容器挂载后发现，原容器没有配置持久化 volume，而 <code>/app/backend/data</code> 中已经存在大量数据：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/app/backend/data
</span></span><span style="display:flex;"><span>├── cache
</span></span><span style="display:flex;"><span>├── uploads
</span></span><span style="display:flex;"><span>└── vector_db
</span></span></code></pre></div><p>这些内容包括：</p>
<ul>
<li>用户上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>其他应用本地状态</li>
</ul>
<p>它们不会进入 PostgreSQL 的 <code>pg_dump</code>，因此必须单独处理。</p>
<p>这也是本次迁移中一个非常重要的发现：</p>

<blockquote>
  <p>迁移 OpenWebUI 的 PostgreSQL，并不等于迁移了 OpenWebUI 的全部数据。</p>

</blockquote>
<h2 id="准备-azure-postgresql">准备 Azure PostgreSQL</h2>
<h3 id="1-创建独立应用账号">1. 创建独立应用账号</h3>
<p>不建议让 OpenWebUI 长期使用 Azure PostgreSQL 管理员账号。</p>
<p>在目标服务器中创建专用角色：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">ROLE</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span> LOGIN;
</span></span></code></pre></div><p>通过 <code>psql</code> 交互方式设置密码，可以避免密码出现在 Shell 历史记录中：</p>
<pre tabindex="0"><code class="language-psql" data-lang="psql">\password &lt;APP_USER&gt;
</code></pre><p>然后创建目标数据库：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">DATABASE</span> <span style="color:#f92672">&lt;</span>APP_DATABASE<span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">OWNER</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span></code></pre></div><h3 id="2-验证应用账号">2. 验证应用账号</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;host=&lt;AZURE_POSTGRESQL_FQDN&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   port=5432 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   dbname=&lt;APP_DATABASE&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   user=&lt;APP_USER&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   sslmode=require&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -W <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;SELECT current_user, current_database();&#34;</span>
</span></span></code></pre></div><p>同时确认目标数据库为空：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> information_schema.tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> table_schema <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> table_type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;BASE TABLE&#39;</span>;
</span></span></code></pre></div><h3 id="3-处理-public-schema-权限">3. 处理 <code>public</code> schema 权限</h3>
<p>第一次测试恢复时遇到了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ERROR: permission denied for schema public
</span></span></code></pre></div><p>原因是应用账号虽然可以连接数据库，但没有在 <code>public</code> schema 中创建对象的权限。</p>
<p>使用管理员账号连接目标数据库后执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">SCHEMA</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">OWNER</span> <span style="color:#66d9ef">TO</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GRANT</span> <span style="color:#66d9ef">USAGE</span>, <span style="color:#66d9ef">CREATE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">SCHEMA</span> <span style="color:#66d9ef">public</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">TO</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span></code></pre></div><p>这个问题说明，仅验证账号“能否登录”还不够，还需要确认该账号能否：</p>
<ul>
<li>创建表</li>
<li>创建索引</li>
<li>创建序列</li>
<li>创建约束</li>
<li>执行 OpenWebUI 的 Alembic 数据库迁移</li>
</ul>
<h2 id="在线执行全量数据库迁移">在线执行全量数据库迁移</h2>
<h3 id="1-在线导出-openwebui-数据库">1. 在线导出 OpenWebUI 数据库</h3>
<p>PostgreSQL 的 <code>pg_dump</code> 会基于一致性事务快照导出数据，因此可以在 OpenWebUI 继续运行时执行。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres pg_dump <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --dbname<span style="color:#f92672">=</span>&lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format<span style="color:#f92672">=</span>custom <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-owner <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-acl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --file<span style="color:#f92672">=</span>/tmp/openwebui-pre-migration.dump
</span></span></code></pre></div><p>参数含义：</p>
<ul>
<li><code>--format=custom</code>：使用 PostgreSQL 自定义归档格式</li>
<li><code>--no-owner</code>：不恢复源端对象 owner</li>
<li><code>--no-acl</code>：不恢复源端权限</li>
<li><code>--file</code>：指定备份文件</li>
</ul>
<p>检查归档是否有效：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pg_restore <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --list <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp/openwebui-pre-migration.dump |
</span></span><span style="display:flex;"><span>head -30
</span></span></code></pre></div><p>自定义格式会保留：</p>
<ul>
<li>表结构</li>
<li>表数据</li>
<li>主键</li>
<li>唯一约束</li>
<li>索引</li>
<li>外键</li>
<li>序列</li>
<li>Alembic 迁移版本</li>
</ul>
<h3 id="2-恢复到-azure-postgresql">2. 恢复到 Azure PostgreSQL</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pg_restore <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --host<span style="color:#f92672">=</span>&lt;AZURE_POSTGRESQL_FQDN&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --port<span style="color:#f92672">=</span><span style="color:#ae81ff">5432</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --username<span style="color:#f92672">=</span>&lt;APP_USER&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --dbname<span style="color:#f92672">=</span>&lt;APP_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-owner <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-acl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --exit-on-error <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --verbose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp/openwebui-pre-migration.dump
</span></span></code></pre></div><p>使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>--exit-on-error
</span></span></code></pre></div><p>可以确保遇到恢复错误时立即停止，避免忽略关键问题。</p>
<p>恢复成功后更新统计信息：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;host=&lt;AZURE_POSTGRESQL_FQDN&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   port=5432 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   dbname=&lt;APP_DATABASE&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   user=&lt;APP_USER&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   sslmode=require&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -W <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;ANALYZE;&#34;</span>
</span></span></code></pre></div><h2 id="对比源端与目标端数据">对比源端与目标端数据</h2>
<p>恢复完成后，需要对 OpenWebUI 的核心表进行对比。</p>
<p>示例查询：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">&#34;user&#34;</span>) <span style="color:#66d9ef">AS</span> users,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> chat) <span style="color:#66d9ef">AS</span> chats,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> chat_message) <span style="color:#66d9ef">AS</span> chat_messages,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> file) <span style="color:#66d9ef">AS</span> files,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> knowledge) <span style="color:#66d9ef">AS</span> knowledge,
</span></span><span style="display:flex;"><span>  (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> information_schema.tables
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> table_schema <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">AND</span> table_type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;BASE TABLE&#39;</span>
</span></span><span style="display:flex;"><span>  ) <span style="color:#66d9ef">AS</span> tables;
</span></span></code></pre></div><p>重点检查：</p>
<ul>
<li>用户数量</li>
<li>对话数量</li>
<li>消息数量</li>
<li>文件记录数量</li>
<li>知识库数量</li>
<li>业务表数量</li>
<li>Alembic 版本</li>
<li>表 owner</li>
<li>序列状态</li>
</ul>
<p>对比时发现，目标端的对话消息比源端少了少量记录。</p>
<p>这并不是恢复失败，而是因为：</p>

<blockquote>
  <p>在全量备份和恢复过程中，OpenWebUI 仍然在线，当前迁移操作的对话还在不断产生新消息。</p>

</blockquote>
<p>因此，下一步需要同步全量快照之后新增的数据。</p>
<h2 id="同步迁移期间新增的活跃对话">同步迁移期间新增的活跃对话</h2>
<p>本次迁移期间，主要只有当前操作对话仍在更新，因此没有再次恢复整个数据库，而是只同步当前活跃对话。</p>
<p>涉及两张表：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>chat
</span></span><span style="display:flex;"><span>chat_message
</span></span></code></pre></div><h3 id="1-找到最近更新的对话">1. 找到最近更新的对话</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.id,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.title,
</span></span><span style="display:flex;"><span>  to_timestamp(<span style="color:#66d9ef">c</span>.updated_at) <span style="color:#66d9ef">AS</span> updated_at,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">count</span>(cm.id) <span style="color:#66d9ef">AS</span> message_count
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> chat <span style="color:#66d9ef">c</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LEFT</span> <span style="color:#66d9ef">JOIN</span> chat_message cm
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ON</span> cm.chat_id <span style="color:#f92672">=</span> <span style="color:#66d9ef">c</span>.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GROUP</span> <span style="color:#66d9ef">BY</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.id,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.title,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.updated_at
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> <span style="color:#66d9ef">c</span>.updated_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">5</span>;
</span></span></code></pre></div><p>确定活跃对话后，后续脚本通过环境变量或参数接收对话 ID，而不是将其直接写入公开文档：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>CHAT_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;&lt;ACTIVE_CHAT_ID&gt;&#34;</span>
</span></span></code></pre></div><h3 id="2-使用一致性快照导出对话数据">2. 使用一致性快照导出对话数据</h3>
<p>为了保证 <code>chat</code> 和 <code>chat_message</code> 来自同一个时间点，使用只读、可重复读事务：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span> <span style="color:#66d9ef">TRANSACTION</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ISOLATION</span> <span style="color:#66d9ef">LEVEL</span> <span style="color:#66d9ef">REPEATABLE</span> <span style="color:#66d9ef">READ</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">READ</span> <span style="color:#66d9ef">ONLY</span>;
</span></span></code></pre></div><p>然后在同一事务中导出：</p>
<pre tabindex="0"><code class="language-psql" data-lang="psql">\copy (
  SELECT *
  FROM chat
  WHERE id = &#39;&lt;ACTIVE_CHAT_ID&gt;&#39;
) TO &#39;/tmp/chat.csv&#39;
WITH (FORMAT CSV);
</code></pre><pre tabindex="0"><code class="language-psql" data-lang="psql">\copy (
  SELECT *
  FROM chat_message
  WHERE chat_id = &#39;&lt;ACTIVE_CHAT_ID&gt;&#39;
  ORDER BY created_at, id
) TO &#39;/tmp/chat-messages.csv&#39;
WITH (FORMAT CSV);
</code></pre><p>最后提交：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></div><p>需要注意：<code>psql</code> 的 <code>\copy</code> 是客户端元命令，复杂字段列表最好写在同一行，否则可能出现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>\copy: parse error at end of line
</span></span></code></pre></div><h3 id="3-在-azure-中使用临时表和-upsert">3. 在 Azure 中使用临时表和 UPSERT</h3>
<p>目标端创建临时 staging 表：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> TEMP <span style="color:#66d9ef">TABLE</span> stage_chat
</span></span><span style="display:flex;"><span>(<span style="color:#66d9ef">LIKE</span> <span style="color:#66d9ef">public</span>.chat <span style="color:#66d9ef">INCLUDING</span> <span style="color:#66d9ef">DEFAULTS</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> TEMP <span style="color:#66d9ef">TABLE</span> stage_chat_message
</span></span><span style="display:flex;"><span>(<span style="color:#66d9ef">LIKE</span> <span style="color:#66d9ef">public</span>.chat_message <span style="color:#66d9ef">INCLUDING</span> <span style="color:#66d9ef">DEFAULTS</span>);
</span></span></code></pre></div><p>将 CSV 导入临时表后，对 <code>chat</code> 主记录执行 UPSERT：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.chat (...)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stage_chat
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> CONFLICT (id) <span style="color:#66d9ef">DO</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">SET</span>
</span></span><span style="display:flex;"><span>  title              <span style="color:#f92672">=</span> EXCLUDED.title,
</span></span><span style="display:flex;"><span>  updated_at         <span style="color:#f92672">=</span> EXCLUDED.updated_at,
</span></span><span style="display:flex;"><span>  chat               <span style="color:#f92672">=</span> EXCLUDED.chat,
</span></span><span style="display:flex;"><span>  meta               <span style="color:#f92672">=</span> EXCLUDED.meta,
</span></span><span style="display:flex;"><span>  current_message_id <span style="color:#f92672">=</span> EXCLUDED.current_message_id;
</span></span></code></pre></div><p>对消息表执行类似操作：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.chat_message (...)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stage_chat_message
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> CONFLICT (id) <span style="color:#66d9ef">DO</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">SET</span>
</span></span><span style="display:flex;"><span>  content         <span style="color:#f92672">=</span> EXCLUDED.content,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">output</span>          <span style="color:#f92672">=</span> EXCLUDED.<span style="color:#66d9ef">output</span>,
</span></span><span style="display:flex;"><span>  status_history  <span style="color:#f92672">=</span> EXCLUDED.status_history,
</span></span><span style="display:flex;"><span>  error           <span style="color:#f92672">=</span> EXCLUDED.error,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">usage</span>           <span style="color:#f92672">=</span> EXCLUDED.<span style="color:#66d9ef">usage</span>,
</span></span><span style="display:flex;"><span>  done            <span style="color:#f92672">=</span> EXCLUDED.done,
</span></span><span style="display:flex;"><span>  updated_at      <span style="color:#f92672">=</span> EXCLUDED.updated_at,
</span></span><span style="display:flex;"><span>  context_summary <span style="color:#f92672">=</span> EXCLUDED.context_summary;
</span></span></code></pre></div><p>为了让 Azure 中该对话的消息集合与源快照完全一致，还会删除目标端多余记录：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">FROM</span> <span style="color:#66d9ef">public</span>.chat_message target
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> target.chat_id <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&lt;ACTIVE_CHAT_ID&gt;&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">EXISTS</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> stage_chat_message staged
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> staged.id <span style="color:#f92672">=</span> target.id
</span></span><span style="display:flex;"><span>  );
</span></span></code></pre></div><p>整个导入和 UPSERT 过程放在一个事务中执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- 创建临时表
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- 导入数据
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- UPSERT
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- 清理多余记录
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></div><p>如果任何步骤失败，事务会自动回滚，不会留下部分同步数据。</p>
<h2 id="最终快速切换-openwebui">最终快速切换 OpenWebUI</h2>
<p>完成在线全量迁移后，最终操作为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>最后同步一次当前活跃对话
</span></span><span style="display:flex;"><span>              ↓
</span></span><span style="display:flex;"><span>重建 OpenWebUI 容器
</span></span><span style="display:flex;"><span>              ↓
</span></span><span style="display:flex;"><span>新容器连接 Azure PostgreSQL
</span></span></code></pre></div><p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>./sync-active-chat-to-azure.sh <span style="color:#f92672">&amp;&amp;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  up -d --force-recreate
</span></span></code></pre></div><p>完整数据库已经提前迁移并验证，因此最终阶段不需要再次等待几百 MB 数据恢复，只需要同步少量增量记录并快速重建容器。</p>
<p>这将切换窗口缩短到了容器启动所需的时间。</p>
<h2 id="安全保存-openwebui-数据库连接串">安全保存 OpenWebUI 数据库连接串</h2>
<p>数据库连接串没有直接写入 Compose 文件，而是放在独立环境文件中：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>open-webui-azure-db.env
</span></span></code></pre></div><p>文件内容形式：</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">DATABASE_URL=postgresql://&lt;APP_USER&gt;:&lt;URL_ENCODED_PASSWORD&gt;@&lt;AZURE_POSTGRESQL_FQDN&gt;:5432/&lt;APP_DATABASE&gt;?sslmode=require
</code></pre><p>文件权限设置为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chmod <span style="color:#ae81ff">600</span> open-webui-azure-db.env
</span></span></code></pre></div><p>如果密码包含特殊字符，需要先进行 URL 编码：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import getpass
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import urllib.parse
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">password = getpass.getpass(&#34;Database password: &#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(urllib.parse.quote(password, safe=&#34;&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>新的 Compose 文件示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">open-webui</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/open-webui/open-webui:&lt;VERSION&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">open-webui</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">always</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8080:8080&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">env_file</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./open-webui.env</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./open-webui-azure-db.env</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/opt/open-webui/data:/app/backend/data</span>
</span></span></code></pre></div><h2 id="持久化-openwebui-本地文件">持久化 OpenWebUI 本地文件</h2>
<p>迁移前，OpenWebUI 的 <code>/app/backend/data</code> 没有挂载到宿主机。</p>
<p>先将数据复制出来：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo install <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -m <span style="color:#ae81ff">700</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -un<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -g <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -gn<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /opt/open-webui/data
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker cp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  open-webui:/app/backend/data/. <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /opt/open-webui/data/
</span></span></code></pre></div><p>在 Compose 中加入：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">/opt/open-webui/data:/app/backend/data</span>
</span></span></code></pre></div><p>这样，即使未来重建或升级 OpenWebUI 容器，以下内容仍会保留：</p>
<ul>
<li>上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>其他本地应用数据</li>
</ul>
<p>迁移后验证：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect open-webui <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format <span style="color:#e6db74">&#39;{{range .Mounts}}{{println .Source &#34;-&gt;&#34; .Destination &#34;rw=&#34; .RW}}{{end}}&#39;</span>
</span></span></code></pre></div><p>预期：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/opt/open-webui/data -&gt; /app/backend/data rw=true
</span></span></code></pre></div><h2 id="迁移后的验证">迁移后的验证</h2>
<h3 id="1-openwebui-健康检查">1. OpenWebUI 健康检查</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -sS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o /dev/null <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -w <span style="color:#e6db74">&#39;HTTP %{http_code}\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --max-time <span style="color:#ae81ff">10</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  http://127.0.0.1:8080/health
</span></span></code></pre></div><p>预期：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>HTTP 200
</span></span></code></pre></div><h3 id="2-验证-openwebui-当前数据库">2. 验证 OpenWebUI 当前数据库</h3>
<p>为了避免输出密码，只解析连接串中的非敏感部分：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec -i open-webui python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import os
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">from urllib.parse import urlsplit
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">u = urlsplit(os.environ[&#34;DATABASE_URL&#34;])
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Database host configured:&#34;, bool(u.hostname))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Database port:&#34;, u.port)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;TLS required:&#34;, &#34;sslmode=require&#34; in u.query)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>验证重点：</p>
<ul>
<li>Host 已不再指向 VM 本地 PostgreSQL</li>
<li>端口为 5432</li>
<li>已启用 <code>sslmode=require</code></li>
</ul>
<h3 id="3-检查启动日志">3. 检查启动日志</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker logs <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --since 15m <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  open-webui 2&gt;&amp;<span style="color:#ae81ff">1</span> |
</span></span><span style="display:flex;"><span>grep -Ei <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;error|fatal|exception|traceback|permission denied|database|postgres|alembic|ssl|timeout&#39;</span>
</span></span></code></pre></div><p>正常情况下只会看到类似：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
</span></span><span style="display:flex;"><span>INFO [alembic.runtime.migration] Will assume transactional DDL.
</span></span></code></pre></div><p>不应出现：</p>
<ul>
<li>数据库连接失败</li>
<li>密码认证失败</li>
<li>TLS 错误</li>
<li>Schema 权限错误</li>
<li>Alembic 迁移失败</li>
<li>Python Traceback</li>
</ul>
<h2 id="卸载-vm-本地-postgresql">卸载 VM 本地 PostgreSQL</h2>
<p>确认 OpenWebUI 已稳定连接 Azure PostgreSQL 后，可以删除 VM 上的本地 PostgreSQL。</p>
<p>首先确认：</p>
<ul>
<li>OpenWebUI 健康检查正常</li>
<li>数据库连接已切换</li>
<li>Azure PostgreSQL 中存在最新数据</li>
<li>自动备份策略已经启用</li>
<li>本地 PostgreSQL 不再有应用连接</li>
</ul>
<p>停止并删除本地集群：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo pg_dropcluster <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --stop <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  &lt;POSTGRESQL_VERSION&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  main
</span></span></code></pre></div><p>卸载 PostgreSQL 服务端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt-get purge -y <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-&lt;POSTGRESQL_VERSION&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-contrib
</span></span></code></pre></div><p>删除遗留数据和配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo rm -rf <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /etc/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/log/postgresql
</span></span></code></pre></div><p>可以继续保留 PostgreSQL 客户端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgresql-client
</span></span><span style="display:flex;"><span>postgresql-client-common
</span></span></code></pre></div><p>这样仍然可以从 VM 使用 <code>psql</code> 管理 Azure PostgreSQL。</p>
<p>最终检查本地 5432：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> sudo ss -lntp | grep -q <span style="color:#e6db74">&#39;:5432&#39;</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>  sudo ss -lntp | grep <span style="color:#e6db74">&#39;:5432&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;No local PostgreSQL listener&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><h2 id="迁移过程中的经验总结">迁移过程中的经验总结</h2>
<h3 id="1-数据库迁移不等于-openwebui-全部数据迁移">1. 数据库迁移不等于 OpenWebUI 全部数据迁移</h3>
<p>OpenWebUI 除了 PostgreSQL，还可能在 <code>/app/backend/data</code> 中保存：</p>
<ul>
<li>上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>本地状态文件</li>
</ul>
<p>只执行 <code>pg_dump</code> 并不能迁移这些内容。</p>
<h3 id="2-不要只测试端口连通">2. 不要只测试端口连通</h3>
<p><code>nc</code> 成功只能证明 TCP 5432 可达，并不能证明：</p>
<ul>
<li>PostgreSQL 用户可以登录</li>
<li>密码正确</li>
<li>TLS 正常</li>
<li>数据库权限正确</li>
<li>OpenWebUI 使用的驱动兼容</li>
</ul>
<p>应至少完成以下验证：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DNS → TCP → PostgreSQL 登录 → TLS → SQL 查询 → OpenWebUI 实际连接
</span></span></code></pre></div><h3 id="3-数据库-owner-不代表一定拥有-schema-创建权限">3. 数据库 owner 不代表一定拥有 schema 创建权限</h3>
<p>即使应用用户是数据库 owner，也可能遇到：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>permission denied for schema public
</span></span></code></pre></div><p>迁移前应验证应用账号是否具备：</p>
<ul>
<li><code>USAGE</code></li>
<li><code>CREATE</code></li>
<li>表和序列所有权</li>
<li>Alembic DDL 权限</li>
</ul>
<h3 id="4-在线全量备份后必须处理增量写入">4. 在线全量备份后必须处理增量写入</h3>
<p><code>pg_dump</code> 提供的是开始备份时的一致性快照。</p>
<p>备份期间 OpenWebUI 新产生的对话和消息不会自动进入已经完成的 dump。因此在线迁移必须增加以下机制之一：</p>
<ul>
<li>最终停写后重新完整备份</li>
<li>逻辑复制</li>
<li>CDC</li>
<li>应用双写</li>
<li>按业务对象执行增量同步</li>
</ul>
<p>本次采用的是“全量快照 + 活跃对话 UPSERT”。</p>
<h3 id="5-不要在迁移时顺便升级-openwebui">5. 不要在迁移时顺便升级 OpenWebUI</h3>
<p>本次迁移始终保持 OpenWebUI 镜像版本不变。</p>
<p>如果同时升级应用和迁移数据库，一旦发生问题，很难快速判断原因来自：</p>
<ul>
<li>PostgreSQL 版本</li>
<li>数据迁移</li>
<li>Azure 网络</li>
<li>数据库权限</li>
<li>OpenWebUI 版本</li>
<li>Alembic schema 变化</li>
</ul>
<p>更稳妥的做法是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>先迁移数据库并稳定运行
</span></span><span style="display:flex;"><span>          ↓
</span></span><span style="display:flex;"><span>再单独升级 OpenWebUI
</span></span></code></pre></div><h2 id="这算不算真正的零停机">这算不算真正的零停机？</h2>
<p>从用户体验看，这次迁移接近零停机：</p>
<ul>
<li>在线完成全量备份</li>
<li>在线恢复目标数据库</li>
<li>在线完成数据验证</li>
<li>在线同步活跃对话</li>
<li>仅在最后重建了一次 OpenWebUI 容器</li>
</ul>
<p>但如果采用严格工程定义，真正的零停机还需要满足：</p>
<ul>
<li>任意时刻都能接受新连接</li>
<li>最后时刻写入绝不丢失</li>
<li>没有单实例容器重启窗口</li>
<li>客户端连接完全不中断</li>
<li>具备自动流量切换能力</li>
</ul>
<p>更大规模的 OpenWebUI 部署可以考虑：</p>
<ul>
<li>PostgreSQL 逻辑复制</li>
<li>Azure Database Migration Service</li>
<li>CDC 增量迁移</li>
<li>应用双写</li>
<li>蓝绿部署</li>
<li>多个 OpenWebUI 实例</li>
<li>反向代理健康检查</li>
<li>连接排空和流量切换</li>
</ul>
<p>对于单 VM、单 OpenWebUI 容器和中小规模数据库，本次采用的方案在复杂度、风险和停机时间之间取得了较好的平衡。</p>
<h2 id="迁移后的日常运维">迁移后的日常运维</h2>
<p>启动 OpenWebUI：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  up -d
</span></span></code></pre></div><p>查看状态：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  ps
</span></span></code></pre></div><p>查看日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  logs -f --tail<span style="color:#f92672">=</span><span style="color:#ae81ff">200</span>
</span></span></code></pre></div><p>健康检查：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -sS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o /dev/null <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -w <span style="color:#e6db74">&#39;HTTP %{http_code}\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  http://127.0.0.1:8080/health
</span></span></code></pre></div><p>日常还应重点监控 Azure PostgreSQL 的：</p>
<ul>
<li>CPU 使用率</li>
<li>内存使用情况</li>
<li>存储空间</li>
<li>活跃连接数</li>
<li>失败连接数</li>
<li>查询延迟</li>
<li>自动备份状态</li>
<li>维护事件</li>
<li>Private Endpoint 状态</li>
</ul>
<h2 id="总结">总结</h2>
<p>这次迁移将 OpenWebUI 的数据库从 Azure VM 自建 PostgreSQL 平滑迁移到了 Azure Database for PostgreSQL Flexible Server。</p>
<p>最终实现了：</p>
<ul>
<li>OpenWebUI 数据库由 Azure 托管</li>
<li>通过 Private Endpoint 私网访问</li>
<li>数据库连接启用 TLS</li>
<li>自动备份和时间点恢复</li>
<li>OpenWebUI 本地文件持久化</li>
<li>VM 本地 PostgreSQL 完全下线</li>
<li>本地 5432 端口关闭</li>
<li>全量迁移期间应用持续在线</li>
<li>最终切换对用户影响极低</li>
</ul>
<p>本次最关键的经验是：</p>

<blockquote>
  <p>对 OpenWebUI 进行低停机数据库迁移，不应只关注 <code>pg_dump</code> 和 <code>pg_restore</code>，还必须同时考虑增量写入、Private Endpoint、TLS、数据库权限、Alembic 迁移以及 <code>/app/backend/data</code> 中的本地文件。</p>

</blockquote>
<p>通过“<strong>在线全量迁移、目标端预验证、活跃数据增量同步、快速容器切换</strong>”，可以在不引入复杂 CDC 平台的情况下，将中小规模 OpenWebUI 平滑迁移到 Azure 托管 PostgreSQL。</p>
]]></content:encoded></item><item><title>在 vLLM Semantic Router 中使用 Microsoft Foundry 远程 Embedding 推理</title><link>https://wilsonwu.me/zh/blog/2026/azure-foundry-embeddings-with-vllm-sr/</link><pubDate>Mon, 03 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/azure-foundry-embeddings-with-vllm-sr/</guid><description>&lt;h2 id="1-引言"&gt;1. 引言&lt;/h2&gt;
&lt;p&gt;在 vLLM Semantic Router 的设计中，Embedding 推理是驱动语义路由的核心信号之一。&lt;/p&gt;
&lt;p&gt;官方教程已经演示了如何使用&lt;strong&gt;远程 Embedding 推理提供商（Remote Embedding Providers）&lt;/strong&gt;替代本地 Embedding 推理服务：&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="1-引言">1. 引言</h2>
<p>在 vLLM Semantic Router 的设计中，Embedding 推理是驱动语义路由的核心信号之一。</p>
<p>官方教程已经演示了如何使用<strong>远程 Embedding 推理提供商（Remote Embedding Providers）</strong>替代本地 Embedding 推理服务：</p>
<p>👉 <a href="https://vllm-sr.ai/docs/tutorials/global/remote-embeddings/">https://vllm-sr.ai/docs/tutorials/global/remote-embeddings/</a></p>
<p>然而，在实际生产环境中，一个更为关键的问题随之而来：</p>

<blockquote>
  <p>如何使用企业级、可扩展的 Embedding 推理服务来支持语义路由？</p>

</blockquote>
<p>本文基于官方教程中的场景，并结合 <strong>Microsoft Foundry Embedding 模型</strong>，演示如何构建一个具备以下特性的智能路由系统：</p>
<ul>
<li>无需 GPU</li>
<li>无需在本地部署 Embedding 模型</li>
<li>生产就绪</li>
</ul>
<h2 id="2-回顾远程-embedding-推理提供商">2. 回顾：远程 Embedding 推理提供商</h2>
<p>在 vLLM Semantic Router 中，路由器运行在 CPU 上，不依赖 GPU 推理能力。</p>
<p>通过远程 Embedding 推理提供商：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>路由器 → 远程 Embedding 推理 API → 向量
</span></span></code></pre></div><p>我们可以：</p>
<ul>
<li>将嵌入计算完全卸载到云服务</li>
<li>统一多模型的语义能力</li>
<li>解耦路由与推理</li>
</ul>
<h2 id="3-教程场景沿用官方测试场景">3. 教程场景（沿用官方测试场景）</h2>
<p>在官方教程中，核心流程如下：</p>
<p><img alt="远程嵌入流程" loading="lazy" src="/zh/blog/2026/azure-foundry-embeddings-with-vllm-sr/1-remote-embeddings-flow.png"></p>
<h3 id="第-1-步定义语义路由">第 1 步：定义语义路由</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">routing</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">decisions</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">math</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">when</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">semantic_similarity</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">text</span>: <span style="color:#e6db74">&#34;math question&#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">threshold</span>: <span style="color:#ae81ff">0.8</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">general</span>
</span></span></code></pre></div><h3 id="第-2-步发送请求">第 2 步：发送请求</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl http://localhost:8888/v1/chat/completions <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#e6db74">&#39;{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;model&#34;: &#34;MoM&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;What is 1+1?&#34;}]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  }&#39;</span>
</span></span></code></pre></div><p>路由器将执行以下操作：</p>
<ol>
<li>生成嵌入向量</li>
<li>执行语义匹配</li>
<li>选择目标模型</li>
</ol>
<h2 id="4-第-0-步在-microsoft-foundry-中创建-embedding-模型">4. 第 0 步：在 Microsoft Foundry 中创建 Embedding 模型</h2>
<p>在与 vLLM Semantic Router 集成之前，我们首先需要在 Microsoft Foundry 中部署一个 Embedding 模型。</p>
<h3 id="41-创建-foundry-项目">4.1 创建 Foundry 项目</h3>
<ol>
<li>前往 <strong>Microsoft Foundry 门户</strong></li>
<li>创建新项目（或选择现有项目）</li>
<li>确保该项目已关联 Azure 订阅</li>
</ol>
<h3 id="42-部署嵌入模型">4.2 部署嵌入模型</h3>
<p>在模型目录中：</p>
<ol>
<li>搜索 <strong>Embedding 模型</strong></li>
<li>选择受支持的模型（例如 <code>text-embedding-3-small</code>）</li>
<li>单击 <strong>Deploy（部署）</strong></li>
</ol>
<p>部署完成后，你将获得：</p>
<ul>
<li>Endpoint URL</li>
<li>API Key</li>
<li>Model Name</li>
</ul>
<p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Endpoint: https://&lt;your-foundry-resource&gt;.inference.ai.azure.com
</span></span><span style="display:flex;"><span>Model: text-embedding-3-small
</span></span><span style="display:flex;"><span>API Key: ******
</span></span></code></pre></div><h3 id="43-验证-embedding-推理-api">4.3 验证 Embedding 推理 API</h3>
<p>你可以直接测试该终结点：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl https://&lt;your-foundry-endpoint&gt;/v1/embeddings <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -H <span style="color:#e6db74">&#34;api-key: &lt;your-api-key&gt;&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -H <span style="color:#e6db74">&#34;Content-Type: application/json&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#e6db74">&#39;{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;input&#34;: &#34;Hello world&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  }&#39;</span>
</span></span></code></pre></div><p>如果调用成功，你将收到包含向量的响应。</p>
<h2 id="5-升级使用-microsoft-foundry-替代本地-embedding-模型">5. 升级：使用 Microsoft Foundry 替代本地 Embedding 模型</h2>
<p>现在，我们将教程中的 Embedding 推理提供商替换为：</p>

<blockquote>
  <p>✅ Microsoft Foundry Embedding 模型</p>

</blockquote>
<p><img alt="Microsoft Foundry Embedding 模型" loading="lazy" src="/zh/blog/2026/azure-foundry-embeddings-with-vllm-sr/2-microsoft-foundry-embedding-model.png"></p>
<h2 id="6-为什么选择-microsoft-foundry-embedding-模型">6. 为什么选择 Microsoft Foundry Embedding 模型</h2>
<p>Microsoft Foundry 提供：</p>
<ul>
<li>高质量 Embedding 模型（更强的语义理解能力）</li>
<li>托管 API（无需自行部署）</li>
<li>全球可用性与企业级 SLA</li>
<li>与 Azure AI 生态系统无缝集成</li>
</ul>
<p>更重要的是：</p>

<blockquote>
  <p>它可以直接充当 Semantic Router 的“语义大脑”</p>

</blockquote>
<h2 id="7-集成将-microsoft-foundry-用作远程-embedding-推理提供商">7. 集成：将 Microsoft Foundry 用作远程 Embedding 推理提供商</h2>
<h3 id="71-配置-embedding-推理提供商">7.1 配置 Embedding 推理提供商</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">providers</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">embedding</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">remote</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">provider</span>: <span style="color:#ae81ff">openai-compatible</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">base_url</span>: <span style="color:#ae81ff">https://&lt;your-foundry-endpoint&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">api_key</span>: <span style="color:#ae81ff">&lt;your-api-key&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">model</span>: <span style="color:#ae81ff">text-embedding-3-small</span>
</span></span></code></pre></div><p>说明：</p>
<ul>
<li>Foundry 提供兼容 OpenAI 的 API</li>
<li>可以直接复用 Semantic Router 的远程 Embedding 推理提供商</li>
<li>无需额外适配</li>
</ul>
<h3 id="72-路由器工作流">7.2 路由器工作流</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户请求
</span></span><span style="display:flex;"><span>   ↓
</span></span><span style="display:flex;"><span>Semantic Router
</span></span><span style="display:flex;"><span>   ↓
</span></span><span style="display:flex;"><span>Microsoft Foundry Embedding 推理 API
</span></span><span style="display:flex;"><span>   ↓
</span></span><span style="display:flex;"><span>向量相似度计算
</span></span><span style="display:flex;"><span>   ↓
</span></span><span style="display:flex;"><span>路由决策
</span></span><span style="display:flex;"><span>   ↓
</span></span><span style="display:flex;"><span>目标模型（vLLM / API）
</span></span></code></pre></div><h2 id="8-相比官方教程的主要改进">8. 相比官方教程的主要改进</h2>
<table>
	<thead>
			<tr>
					<th>能力</th>
					<th>原始方案</th>
					<th>Microsoft Foundry 方案</th>
			</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>多区域</td>
			</tr>
			<tr>
					<td>语义质量</td>
					<td>中等</td>
					<td>企业级</td>
			</tr>
	</tbody>
</table>
<h2 id="9-实际价值">9. 实际价值</h2>
<h3 id="-1-真正的无-gpu-架构">🚀 1. 真正的“无 GPU 架构”</h3>
<p>路由器本身运行在 CPU 上。</p>
<p>Embedding 模型与推理能力完全外部化：</p>
<ul>
<li>Foundry → Embedding 模型</li>
<li>vLLM / API → 推理</li>
</ul>
<h3 id="-2-更准确的语义路由">🧠 2. 更准确的语义路由</h3>
<p>在教程场景中：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>&#34;What is 1+1?&#34; → math 路由
</span></span><span style="display:flex;"><span>&#34;Tell me a joke&#34; → general 路由
</span></span></code></pre></div><p>使用 Foundry 嵌入后，可以获得：</p>
<ul>
<li>更稳定的语义理解</li>
<li>更好的多语言支持</li>
<li>对边界场景更准确的判断</li>
</ul>
<h3 id="-3-统一的多模型入口">🌐 3. 统一的多模型入口</h3>
<p>结合 Semantic Router：</p>
<ul>
<li>Microsoft Foundry（Embedding 模型）</li>
<li>vLLM（自托管推理）</li>
<li>OpenAI / 其他 API</li>
</ul>
<p>从而实现真正的：</p>

<blockquote>
  <p><strong>多提供商智能路由</strong></p>

</blockquote>
<h2 id="10-架构洞察">10. 架构洞察</h2>
<p>此次升级的本质是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>升级前：
</span></span><span style="display:flex;"><span>路由器 = 基础设施 + 智能
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>升级后：
</span></span><span style="display:flex;"><span>路由器 = 智能
</span></span><span style="display:flex;"><span>Foundry = AI 能力
</span></span></code></pre></div><p>由此形成清晰的分层架构：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>[ AI 能力层 ] → Microsoft Foundry
</span></span><span style="display:flex;"><span>[ 路由层    ] → Semantic Router
</span></span><span style="display:flex;"><span>[ 推理层    ] → vLLM / APIs
</span></span></code></pre></div><h2 id="11-总结">11. 总结</h2>
<p>通过将 Microsoft Foundry 与官方远程 Embedding 模型教程相结合，我们实现了一套生产就绪的架构：</p>
<ul>
<li>无需本地 Embedding 模型</li>
<li>无需 GPU</li>
<li>高质量的语义能力</li>
<li>企业级可靠性</li>
</ul>
<p>这不仅是一次功能增强，更是 Semantic Router 的一次关键演进：</p>

<blockquote>
  <p>从“本地工具”演进为“云原生 AI 路由层”</p>

</blockquote>
]]></content:encoded></item><item><title>用 llm-d-inference-sim 模拟大模型推理：无 GPU 环境下的最佳验证工具</title><link>https://wilsonwu.me/zh/blog/2026/llm-inference-simulation/</link><pubDate>Wed, 08 Jul 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/llm-inference-simulation/</guid><description>&lt;p&gt;&lt;img alt="LLM 推理模拟" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/llm-inference-simulation/1-llm-inference-simulation.png"&gt;&lt;/p&gt;
&lt;p&gt;在大模型应用快速落地的过程中，&lt;strong&gt;推理服务的工程化能力&lt;/strong&gt;往往比模型本身更复杂：模型调度、路由策略、限流、灰度发布、网关治理等问题，都需要真实的推理接口进行验证。然而，大模型推理依赖 GPU 和高成本算力资源，使得开发和测试门槛居高不下。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="LLM 推理模拟" loading="lazy" src="/zh/blog/2026/llm-inference-simulation/1-llm-inference-simulation.png"></p>
<p>在大模型应用快速落地的过程中，<strong>推理服务的工程化能力</strong>往往比模型本身更复杂：模型调度、路由策略、限流、灰度发布、网关治理等问题，都需要真实的推理接口进行验证。然而，大模型推理依赖 GPU 和高成本算力资源，使得开发和测试门槛居高不下。</p>
<p>如果你希望在<strong>没有 GPU 或资源受限的环境中验证推理架构设计</strong>，那么这个工具值得关注：</p>
<p><a href="https://github.com/llm-d/llm-d-inference-sim?utm_source=chatgpt.com">llm-d-inference-sim GitHub 项目</a></p>
<p>本文将系统介绍这个工具的能力、设计思路，以及它在实际工程中的应用价值。</p>
<h2 id="工具定位为推理工程而生的模拟器">工具定位：为推理工程而生的“模拟器”</h2>
<p><strong>llm-d-inference-sim</strong> 是一个专门用于模拟大模型推理服务的工具，其核心能力包括：</p>
<ul>
<li>提供与真实 LLM 推理服务一致的接口（如 OpenAI-style API）</li>
<li>接收推理请求（prompt / messages）</li>
<li>按配置生成“模拟响应”</li>
<li>支持多实例、多模型模拟部署</li>
</ul>
<p>简单来说，它并不真正执行模型推理，而是：</p>

<blockquote>
  <p><strong>模拟“推理服务行为”，而非“推理计算本身”</strong></p>

</blockquote>
<h2 id="为什么需要推理模拟">为什么需要推理模拟？</h2>
<p>在真实项目中，我们经常遇到以下问题：</p>
<h3 id="1-推理资源昂贵">1. 推理资源昂贵</h3>
<ul>
<li>GPU 成本高（A100 / H100）</li>
<li>推理服务部署复杂（vLLM / TensorRT-LLM / SGLang）</li>
<li>测试环境难以复刻生产规模</li>
</ul>
<h3 id="2-工程验证依赖真实服务">2. 工程验证依赖真实服务</h3>
<p>很多关键能力必须依赖推理接口验证，例如：</p>
<ul>
<li>API 网关路由策略（Envoy / Istio）</li>
<li>多模型调度与 fallback 机制</li>
<li>embedding + intent routing</li>
<li>限流 / 熔断 / 超时控制</li>
<li>灰度发布与 A/B testing</li>
</ul>
<p>但这些能力<strong>并不依赖模型“正确性”</strong>，只依赖：</p>

<blockquote>
  <p>“是否有一个行为类似 LLM 的服务存在”</p>

</blockquote>
<h2 id="llm-d-inference-sim-的核心价值">llm-d-inference-sim 的核心价值</h2>
<h3 id="1-零-gpu-依赖">1. 零 GPU 依赖</h3>
<p>开发者可以在以下环境直接使用：</p>
<ul>
<li>本地 MacBook</li>
<li>CI/CD 环境</li>
<li>Kubernetes 测试集群</li>
<li>云主机（无 GPU）</li>
</ul>
<p>无需部署任何真实模型。</p>
<h3 id="2-高度可控的响应模拟">2. 高度可控的响应模拟</h3>
<p>可以模拟：</p>
<ul>
<li>固定响应（用于稳定测试）</li>
<li>延迟（模拟推理耗时）</li>
<li>Token 生成节奏（Streaming）</li>
<li>错误返回（测试容错）</li>
</ul>
<p>这使它非常适合：</p>

<blockquote>
  <p><strong>压测、容错测试、链路验证</strong></p>

</blockquote>
<h3 id="3-支持多模型实例">3. 支持多模型实例</h3>
<p>你可以快速部署多个“模拟模型”：</p>
<ul>
<li>gpt-4 模拟服务</li>
<li>embedding 模型模拟</li>
<li>不同延迟 / 不同性能 profile</li>
</ul>
<p>用于验证：</p>
<ul>
<li>多模型路由策略</li>
<li>智能网关（AI Gateway）</li>
<li>fallback 逻辑</li>
</ul>
<h3 id="4-与真实-api-兼容">4. 与真实 API 兼容</h3>
<p>接口风格通常兼容：</p>
<ul>
<li>OpenAI API（/v1/chat/completions）</li>
<li>streaming 输出</li>
</ul>
<p>这意味着：</p>

<blockquote>
  <p><strong>可以无缝替换真实模型服务</strong></p>

</blockquote>
<h2 id="典型使用场景">典型使用场景</h2>
<h3 id="场景-1ai-网关--服务网格验证">场景 1：AI 网关 / 服务网格验证</h3>
<p>例如使用 Envoy / Istio 构建 AI Gateway：</p>
<ul>
<li>基于路径或 header 路由不同模型</li>
<li>embedding-based routing</li>
<li>Canary 发布</li>
</ul>
<p>问题：没有真实模型怎么办？</p>
<p>使用 llm-d-inference-sim 模拟多个模型后端，即可验证完整链路。</p>
<h3 id="场景-2多模型调度系统开发">场景 2：多模型调度系统开发</h3>
<p>在做类似以下系统时：</p>
<ul>
<li>vLLM-aware scheduler</li>
<li>KV Cache aware routing</li>
<li>模型负载均衡</li>
</ul>
<p>你需要：</p>
<ul>
<li>多个模型 endpoint</li>
<li>不同响应延迟</li>
<li>可控行为</li>
</ul>
<p>模拟器可以快速构造这些环境。</p>
<h3 id="场景-3cicd-自动化测试">场景 3：CI/CD 自动化测试</h3>
<p>真实模型服务难以接入 CI：</p>
<ul>
<li>成本高</li>
<li>不稳定</li>
<li>启动慢</li>
</ul>
<p>使用模拟器：</p>
<ul>
<li>启动快速</li>
<li>行为可预测</li>
<li>支持 mock response</li>
</ul>
<p>非常适合集成测试（integration test）</p>
<h3 id="场景-4产品演示--demo-环境">场景 4：产品演示 / Demo 环境</h3>
<p>在售前或内部 Demo 中：</p>
<ul>
<li>不需要真实模型</li>
<li>只需要“看起来能跑”</li>
</ul>
<p>使用模拟器可以：</p>
<ul>
<li>快速部署多个模型服务</li>
<li>构造稳定演示环境</li>
<li>避免 GPU 成本</li>
</ul>
<h2 id="工作原理简析">工作原理简析</h2>
<p>llm-d-inference-sim 的设计可以理解为：</p>
<h3 id="请求流程">请求流程</h3>
<ol>
<li>接收 HTTP 请求（如 <code>/v1/chat/completions</code>）</li>
<li>解析输入（prompt/messages）</li>
<li>根据配置规则生成响应</li>
<li>返回 JSON 或 streaming 输出</li>
</ol>
<h3 id="模拟能力通常包括">模拟能力通常包括</h3>
<ul>
<li>
<p><strong>Response 模板</strong></p>
<ul>
<li>固定文本</li>
<li>参数化生成</li>
</ul>
</li>
<li>
<p><strong>Latency 注入</strong></p>
<ul>
<li>模拟推理耗时（如 200ms / 2s）</li>
</ul>
</li>
<li>
<p><strong>Streaming</strong></p>
<ul>
<li>分块返回 token</li>
<li>模拟真实 LLM 输出节奏</li>
</ul>
</li>
<li>
<p><strong>错误注入</strong></p>
<ul>
<li>500 / timeout</li>
<li>测试重试机制</li>
</ul>
</li>
</ul>
<h2 id="与真实推理服务的关系">与真实推理服务的关系</h2>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>模拟器</th>
					<th>真实模型（vLLM 等）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>GPU</td>
					<td>❌ 不需要</td>
					<td>✅ 必需</td>
			</tr>
			<tr>
					<td>成本</td>
					<td>极低</td>
					<td>高</td>
			</tr>
			<tr>
					<td>响应真实性</td>
					<td>❌ 无语义能力</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>接口兼容性</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>工程验证能力</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
	</tbody>
</table>

<blockquote>
  <p><strong>模拟器 ≠ 替代模型，而是补齐工程验证环节</strong></p>

</blockquote>
<h2 id="最佳实践建议">最佳实践建议</h2>
<h3 id="1-与网关结合使用">1. 与网关结合使用</h3>
<p>推荐组合：</p>
<ul>
<li>Envoy / Istio</li>
<li>AI Gateway</li>
<li>llm-d-inference-sim</li>
</ul>
<p>构建完整推理流量链路</p>
<h3 id="2-构造多模型拓扑">2. 构造多模型拓扑</h3>
<p>例如：</p>
<ul>
<li>model-a（低延迟）</li>
<li>model-b（高质量，高延迟）</li>
<li>model-fallback</li>
</ul>
<p>验证智能路由策略</p>
<h3 id="3-注入异常场景">3. 注入异常场景</h3>
<p>主动模拟：</p>
<ul>
<li>timeout</li>
<li>高延迟</li>
<li>错误返回</li>
</ul>
<p>提升系统鲁棒性</p>
<h3 id="4-用于开发早期阶段">4. 用于开发早期阶段</h3>
<p>在以下阶段优先使用模拟器：</p>
<ul>
<li>架构设计</li>
<li>API 设计</li>
<li>路由策略验证</li>
</ul>
<p>等到后期再接入真实模型。</p>
<h2 id="总结">总结</h2>
<p><strong>llm-d-inference-sim</strong> 解决的是一个被低估但关键的问题：</p>

<blockquote>
  <p>在没有模型的情况下，如何推进大模型工程化？</p>

</blockquote>
<p>它的核心价值不在于“模拟模型”，而在于：</p>
<ul>
<li>降低开发门槛</li>
<li>提升验证效率</li>
<li>解耦算力与工程</li>
</ul>
<p>对于正在构建 AI 基础设施（AI Gateway / 推理平台 / 多模型调度系统）的团队来说，这是一个非常实用的工具。</p>
]]></content:encoded></item><item><title>PPO vs GRPO：两种主流 LLM 强化学习范式的对比与选择</title><link>https://wilsonwu.me/zh/blog/2026/ppo-vs-grpo/</link><pubDate>Sun, 28 Jun 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/ppo-vs-grpo/</guid><description>&lt;h2 id="引言"&gt;引言&lt;/h2&gt;
&lt;p&gt;在大模型后训练（Post-training）阶段，强化学习（RLHF / RLAIF）已经成为决定模型能力上限的关键因素之一。近期，GLM-5.2 在训练算法上从 GLM-5.1 使用的 GRPO（Generalized Reward Policy Optimization）切换到更经典的 PPO（Proximal Policy Optimization），并带来了明显的效果提升。&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="引言">引言</h2>
<p>在大模型后训练（Post-training）阶段，强化学习（RLHF / RLAIF）已经成为决定模型能力上限的关键因素之一。近期，GLM-5.2 在训练算法上从 GLM-5.1 使用的 GRPO（Generalized Reward Policy Optimization）切换到更经典的 PPO（Proximal Policy Optimization），并带来了明显的效果提升。</p>
<p>这一变化并非简单的“算法替换”，而是一次在<strong>稳定性、泛化能力以及训练可控性</strong>上的系统性升级。</p>
<p><img alt="PPO vs GRPO" loading="lazy" src="/zh/blog/2026/ppo-vs-grpo/1-ppo-vs-grpo.png"></p>
<p>本文将从三个层面展开分析：</p>
<ol>
<li>PPO 与 GRPO 的核心原理</li>
<li>两种算法的关键差异</li>
<li>为什么 PPO 能带来“质的提升”</li>
</ol>
<h2 id="ppoproximal-policy-optimization原理">PPO（Proximal Policy Optimization）原理</h2>
<h3 id="1-背景">1. 背景</h3>
<p>PPO 是 OpenAI 在 2017 年提出的一种策略梯度方法，是 TRPO（Trust Region Policy Optimization）的工程化简化版本，目前已经成为 RLHF 训练中的事实标准。</p>
<h3 id="2-核心思想">2. 核心思想</h3>
<p>PPO 的核心目标是：</p>

<blockquote>
  <p><strong>在优化策略的同时，限制新旧策略之间的偏移，防止训练不稳定。</strong></p>

</blockquote>
<p>其优化目标函数为：</p>

      <div class="math-display"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msup><mi>L</mi><mrow><mi>P</mi><mi>P</mi><mi>O</mi></mrow></msup><mo stretchy="false">(</mo><mi>θ</mi><mo stretchy="false">)</mo><mo>=</mo><mi mathvariant="double-struck">E</mi><mrow><mo fence="true">[</mo><mi>min</mi><mo>⁡</mo><mrow><mo fence="true">(</mo><msub><mi>r</mi><mi>t</mi></msub><mo stretchy="false">(</mo><mi>θ</mi><mo stretchy="false">)</mo><msub><mi>A</mi><mi>t</mi></msub><mo separator="true">,</mo><mtext> clip</mtext><mo stretchy="false">(</mo><msub><mi>r</mi><mi>t</mi></msub><mo stretchy="false">(</mo><mi>θ</mi><mo stretchy="false">)</mo><mo separator="true">,</mo><mn>1</mn><mo>−</mo><mi>ϵ</mi><mo separator="true">,</mo><mn>1</mn><mo>+</mo><mi>ϵ</mi><mo stretchy="false">)</mo><msub><mi>A</mi><mi>t</mi></msub><mo fence="true">)</mo></mrow><mo fence="true">]</mo></mrow></mrow><annotation encoding="application/x-tex">
L^{PPO}(\theta) = \mathbb{E}\left[\min\left(r_t(\theta) A_t,\ \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) A_t\right)\right]
</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1.1413em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal">L</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8913em;"><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.1389em;">P</span><span class="mord mathnormal mtight" style="margin-right:0.1389em;">P</span><span class="mord mathnormal mtight" style="margin-right:0.0278em;">O</span></span></span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathbb">E</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;">[</span><span class="mop">min</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span><span class="mclose">)</span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mpunct">,</span><span class="mspace"> </span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord text"><span class="mord">clip</span></span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span><span class="mclose">)</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mord mathnormal">ϵ</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">1</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mord mathnormal">ϵ</span><span class="mclose">)</span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mclose delimcenter" style="top:0em;">)</span></span><span class="mclose delimcenter" style="top:0em;">]</span></span></span></span></span></span></div><p>其中：</p>
<ul>
<li><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>r</mi><mi>t</mi></msub><mo stretchy="false">(</mo><mi>θ</mi><mo stretchy="false">)</mo><mo>=</mo><mfrac><mrow><msub><mi>π</mi><mi>θ</mi></msub><mo stretchy="false">(</mo><mi>a</mi><mi mathvariant="normal">∣</mi><mi>s</mi><mo stretchy="false">)</mo></mrow><mrow><msub><mi>π</mi><msub><mi>θ</mi><mrow><mi>o</mi><mi>l</mi><mi>d</mi></mrow></msub></msub><mo stretchy="false">(</mo><mi>a</mi><mi mathvariant="normal">∣</mi><mi>s</mi><mo stretchy="false">)</mo></mrow></mfrac></mrow><annotation encoding="application/x-tex"> r_t(\theta) = \frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)} </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:-0.0278em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:1.6357em;vertical-align:-0.6257em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.01em;"><span style="top:-2.655em;"><span class="pstrut" style="height:3em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">π</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3448em;"><span style="top:-2.3488em;margin-left:-0.0359em;margin-right:0.0714em;"><span class="pstrut" style="height:2.5em;"></span><span class="katex-sizing reset-size3 size1 mtight"><span class="mord mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em;">θ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3448em;"><span style="top:-2.3448em;margin-left:-0.0278em;margin-right:0.1em;"><span class="pstrut" style="height:2.6944em;"></span><span class="mord mtight"><span class="mord mathnormal mtight">o</span><span class="mord mathnormal mtight" style="margin-right:0.0197em;">l</span><span class="mord mathnormal mtight">d</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.3496em;"><span></span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.401em;"><span></span></span></span></span></span></span><span class="mopen mtight">(</span><span class="mord mathnormal mtight">a</span><span class="mord mtight">∣</span><span class="mord mathnormal mtight">s</span><span class="mclose mtight">)</span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.485em;"><span class="pstrut" style="height:3em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">π</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3448em;"><span style="top:-2.3488em;margin-left:-0.0359em;margin-right:0.0714em;"><span class="pstrut" style="height:2.5em;"></span><span class="katex-sizing reset-size3 size1 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em;">θ</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.1512em;"><span></span></span></span></span></span></span><span class="mopen mtight">(</span><span class="mord mathnormal mtight">a</span><span class="mord mtight">∣</span><span class="mord mathnormal mtight">s</span><span class="mclose mtight">)</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.6257em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span></li>
<li><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>A</mi><mi>t</mi></msub></mrow><annotation encoding="application/x-tex"> A_t </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">t</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span>：优势函数（Advantage）</li>
<li><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ϵ</mi></mrow><annotation encoding="application/x-tex"> \epsilon </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.4306em;"></span><span class="mord mathnormal">ϵ</span></span></span></span>：裁剪系数（通常 0.1~0.2）</li>
</ul>
<h3 id="3-关键机制">3. 关键机制</h3>
<p>PPO 的稳定性来源于三点：</p>
<h4 id="clipping-机制">Clipping 机制</h4>
<p>限制策略更新幅度，防止“过度优化”。</p>
<h4 id="dvantage-estimationgae">dvantage Estimation（GAE）</h4>
<p>降低方差，提高训练稳定性。</p>
<h4 id="多步更新epoch">多步更新（Epoch）</h4>
<p>在同一批数据上进行多次优化，提高 sample efficiency。</p>
<h3 id="4-在大模型中的作用">4. 在大模型中的作用</h3>
<p>在 LLM 训练中，PPO 通常用于：</p>
<ul>
<li>对齐人类偏好（RLHF）</li>
<li>控制生成风格（安全、格式、推理能力）</li>
<li>平衡 exploration vs exploitation</li>
</ul>
<h2 id="grpogeneralized-reward-policy-optimization原理">GRPO（Generalized Reward Policy Optimization）原理</h2>
<h3 id="1-背景-1">1. 背景</h3>
<p>GRPO 是在 RLHF 场景中提出的一种<strong>去 Value Function（无 critic）</strong>的优化方法，其目标是：</p>

<blockquote>
  <p><strong>简化 PPO 训练流程，降低工程复杂度，同时提升吞吐。</strong></p>

</blockquote>
<h3 id="2-核心思想-1">2. 核心思想</h3>
<p>GRPO 的关键在于：</p>
<ul>
<li>不训练 value model</li>
<li>不计算 advantage（或用简化替代）</li>
<li>直接基于 reward 进行相对优化</li>
</ul>
<p>典型流程：</p>
<ol>
<li>对同一 prompt 采样多个输出（N samples）</li>
<li>使用 reward model 打分</li>
<li>做归一化或排序</li>
<li>使用相对奖励更新策略</li>
</ol>
<p>可表示为：</p>

      <div class="math-display"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msup><mi>L</mi><mrow><mi>G</mi><mi>R</mi><mi>P</mi><mi>O</mi></mrow></msup><mo>=</mo><mi mathvariant="double-struck">E</mi><mrow><mo fence="true">[</mo><mi>log</mi><mo>⁡</mo><msub><mi>π</mi><mi>θ</mi></msub><mo stretchy="false">(</mo><mi>a</mi><mi mathvariant="normal">∣</mi><mi>s</mi><mo stretchy="false">)</mo><mo>⋅</mo><mover accent="true"><mi>R</mi><mo>^</mo></mover><mo stretchy="false">(</mo><mi>a</mi><mo stretchy="false">)</mo><mo fence="true">]</mo></mrow></mrow><annotation encoding="application/x-tex">
L^{GRPO} = \mathbb{E}\left[\log \pi_\theta(a|s) \cdot \hat{R}(a)\right]
</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8913em;"></span><span class="mord"><span class="mord mathnormal">L</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8913em;"><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0077em;">GR</span><span class="mord mathnormal mtight" style="margin-right:0.1389em;">P</span><span class="mord mathnormal mtight" style="margin-right:0.0278em;">O</span></span></span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:1.8em;vertical-align:-0.65em;"></span><span class="mord mathbb">E</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;"><span class="delimsizing size2">[</span></span><span class="mop">lo<span style="margin-right:0.0139em;">g</span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">π</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:-0.0359em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0278em;">θ</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal">a</span><span class="mord">∣</span><span class="mord mathnormal">s</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mord katex-accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.9468em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span></span><span style="top:-3.2523em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.1667em;"><span class="mord">^</span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal">a</span><span class="mclose">)</span><span class="mclose delimcenter" style="top:0em;"><span class="delimsizing size2">]</span></span></span></span></span></span></span></div><p>其中：</p>
<ul>
<li><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mover accent="true"><mi>R</mi><mo>^</mo></mover><mo stretchy="false">(</mo><mi>a</mi><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex"> \hat{R}(a) </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1.1968em;vertical-align:-0.25em;"></span><span class="mord katex-accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.9468em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span></span><span style="top:-3.2523em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.1667em;"><span class="mord">^</span></span></span></span></span></span></span><span class="mopen">(</span><span class="mord mathnormal">a</span><span class="mclose">)</span></span></span></span>：归一化后的 reward（如 rank-based 或 mean-centered）</li>
</ul>
<h3 id="3-关键特点">3. 关键特点</h3>
<h4 id="无-critic-架构">无 Critic 架构</h4>
<p>避免 value model 训练的不稳定性。</p>
<h4 id="batch-内对比学习">Batch 内对比学习</h4>
<p>依赖同一 prompt 下多个样本的“相对优劣”。</p>
<h4 id="高吞吐">高吞吐</h4>
<p>更适合大规模并行训练（尤其是多 GPU 推理采样）。</p>
<h2 id="ppo-vs-grpo核心差异对比">PPO vs GRPO：核心差异对比</h2>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>PPO</th>
					<th>GRPO</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>是否使用 Value Model</td>
					<td>✅ 使用</td>
					<td>❌ 不使用</td>
			</tr>
			<tr>
					<td>Advantage 计算</td>
					<td>✅ GAE</td>
					<td>❌ 无（或简化）</td>
			</tr>
			<tr>
					<td>稳定性</td>
					<td>⭐⭐⭐⭐⭐</td>
					<td>⭐⭐⭐</td>
			</tr>
			<tr>
					<td>训练复杂度</td>
					<td>高</td>
					<td>低</td>
			</tr>
			<tr>
					<td>样本效率</td>
					<td>高</td>
					<td>中</td>
			</tr>
			<tr>
					<td>并行友好性</td>
					<td>中</td>
					<td>高</td>
			</tr>
			<tr>
					<td>对 reward 依赖</td>
					<td>中</td>
					<td>高</td>
			</tr>
			<tr>
					<td>对数据质量敏感性</td>
					<td>中</td>
					<td>高</td>
			</tr>
	</tbody>
</table>
<p><img alt="PPO vs GRPO" loading="lazy" src="/zh/blog/2026/ppo-vs-grpo/2-ppo-vs-grpo.png"></p>
<h2 id="为什么-glm-52-从-grpo-切换到-ppo">为什么 GLM-5.2 从 GRPO 切换到 PPO？</h2>
<p>这是本文的核心问题。</p>
<h3 id="1-grpo-的瓶颈">1. GRPO 的瓶颈</h3>
<p>尽管 GRPO 在工程上更简单，但存在几个关键问题：</p>
<h4 id="reward-噪声放大">Reward 噪声放大</h4>
<p>GRPO 强依赖 reward 的“相对排序”，当：</p>
<ul>
<li>reward model 不够精确</li>
<li>多样本之间差异较小</li>
</ul>
<p>会导致：</p>

<blockquote>
  <p>梯度信号极不稳定</p>

</blockquote>
<h4 id="缺乏长期信用分配credit-assignment">缺乏长期信用分配（Credit Assignment）</h4>
<p>GRPO 没有 value function：</p>
<ul>
<li>无法建模长期回报</li>
<li>对长链推理（CoT）不友好</li>
</ul>
<h4 id="训练容易-collapse">训练容易 collapse</h4>
<p>在某些情况下：</p>
<ul>
<li>模型会过拟合 reward model</li>
<li>输出趋于模式化（mode collapse）</li>
</ul>
<h3 id="2-ppo-的优势在-glm-52-中体现">2. PPO 的优势在 GLM-5.2 中体现</h3>
<h4 id="更稳定的优化路径">更稳定的优化路径</h4>
<p>PPO 的 clipping + advantage：</p>
<ul>
<li>避免策略震荡</li>
<li>保证逐步改进（monotonic improvement）</li>
</ul>
<h4 id="更好的推理能力reasoning">更好的推理能力（Reasoning）</h4>
<p>PPO 的 value function：</p>
<ul>
<li>能对“中间步骤”进行隐式建模</li>
<li>更适合 chain-of-thought / multi-step reasoning</li>
</ul>
<h4 id="对-reward-model-依赖降低">对 reward model 依赖降低</h4>
<p>相比 GRPO：</p>
<ul>
<li>PPO 不完全依赖 reward 排序</li>
<li>对 reward 噪声更鲁棒</li>
</ul>
<h4 id="更强的泛化能力">更强的泛化能力</h4>
<p>PPO 本质上优化的是：</p>

<blockquote>
  <p><strong>策略分布，而不是样本排序</strong></p>

</blockquote>
<p>因此在：</p>
<ul>
<li>未见任务</li>
<li>长文本生成</li>
<li>工具调用</li>
</ul>
<p>等场景中更稳定。</p>
<h2 id="一个直观理解排序-vs-回归">一个直观理解：排序 vs 回归</h2>
<p>可以用一个类比帮助理解：</p>
<ul>
<li>
<p><strong>GRPO：像做“排序学习”</strong></p>
<ul>
<li>哪个答案更好？</li>
<li>强依赖 pairwise / listwise 对比</li>
</ul>
</li>
<li>
<p><strong>PPO：像做“回归优化”</strong></p>
<ul>
<li>当前策略离最优策略有多远？</li>
<li>有连续的优化方向</li>
</ul>
</li>
</ul>
<p>结论：</p>

<blockquote>
  <p>GRPO 更“快”，PPO 更“稳且准”。</p>

</blockquote>
<h2 id="总结">总结</h2>
<p>GLM-5.2 从 GRPO 切换到 PPO，本质上是一次：</p>

<blockquote>
  <p><strong>从工程效率优先 → 模型能力优先 的转变</strong></p>

</blockquote>
<h3 id="关键结论">关键结论</h3>
<ol>
<li>
<p>GRPO 适合：</p>
<ul>
<li>快速训练</li>
<li>大规模并行</li>
<li>reward 明确的任务</li>
</ul>
</li>
<li>
<p>PPO 更适合：</p>
<ul>
<li>高质量对齐</li>
<li>复杂推理任务</li>
<li>长上下文生成</li>
</ul>
</li>
<li>
<p>在当前 LLM 发展阶段：</p>
</li>
</ol>

<blockquote>
  <p><strong>稳定性、泛化能力、推理能力 &gt; 训练吞吐</strong></p>

</blockquote>
<p>因此，PPO 的回归成为一个“必然选择”。</p>
<h2 id="结语">结语</h2>
<p>强化学习算法的选择，正在成为大模型能力差异的关键分水岭。从 GRPO 到 PPO，不只是算法切换，更代表着：</p>

<blockquote>
  <p><strong>大模型训练从“能跑”走向“跑得好、跑得稳”。</strong></p>

</blockquote>
<p>未来我们很可能看到：</p>
<ul>
<li>PPO + DPO 混合范式</li>
<li>或新的 off-policy RL 方法</li>
<li>甚至 reward-free alignment</li>
</ul>
<p>但在当前阶段：</p>

<blockquote>
  <p><strong>PPO 仍然是最稳健的工业级选择。</strong></p>

</blockquote>
]]></content:encoded></item><item><title>让模型自己选模型：Embedding 驱动的 LLM 智能路由机制</title><link>https://wilsonwu.me/zh/blog/2026/llm-smart-router/</link><pubDate>Tue, 26 May 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/llm-smart-router/</guid><description>&lt;p&gt;在多模型并存的 AI 架构中（如 GPT-4 / GPT-4o / 轻量模型 / 垂直模型等），一个核心问题是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;如何在不显式指定模型 ID 的情况下，让系统自动选择最合适的模型？&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;</description><content:encoded><![CDATA[<p>在多模型并存的 AI 架构中（如 GPT-4 / GPT-4o / 轻量模型 / 垂直模型等），一个核心问题是：</p>

<blockquote>
  <p><strong>如何在不显式指定模型 ID 的情况下，让系统自动选择最合适的模型？</strong></p>

</blockquote>
<p>本文介绍一种工程可落地的方案：</p>

<blockquote>
  <p><strong>通过 Embedding 模型计算用户意图 → 在网关层进行语义匹配 → 动态路由到最合适的上游模型服务</strong></p>

</blockquote>
<p>我们将基于：</p>
<ul>
<li>Embedding 模型：Azure OpenAI <code>text-embedding-3-small</code></li>
<li>网关：Envoy</li>
<li>核心机制：向量相似度打分 + 策略路由</li>
</ul>
<h2 id="问题背景与动机">问题背景与动机</h2>
<p>在真实生产环境中，大模型调用通常面临几个挑战：</p>
<h3 id="1-成本与性能权衡">1. 成本与性能权衡</h3>
<table>
	<thead>
			<tr>
					<th>模型类型</th>
					<th>优点</th>
					<th>缺点</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>大模型（如 GPT-4）</td>
					<td>能力强</td>
					<td>成本高 / 延迟高</td>
			</tr>
			<tr>
					<td>小模型（如 GPT-4o-mini）</td>
					<td>快 / 便宜</td>
					<td>能力有限</td>
			</tr>
			<tr>
					<td>专用模型</td>
					<td>精准</td>
					<td>泛化弱</td>
			</tr>
	</tbody>
</table>
<p>不同请求应该走不同模型，而不是“一刀切”。</p>
<h3 id="2-传统做法的局限">2. 传统做法的局限</h3>
<p>常见策略：</p>
<ul>
<li>手动指定 model_id ❌（不智能）</li>
<li>基于规则（if/else）❌（不可扩展）</li>
<li>基于关键词匹配 ❌（语义不鲁棒）</li>
</ul>
<h3 id="3-理想目标">3. 理想目标</h3>
<p>我们希望：</p>

<blockquote>
  <p>用户只输入 prompt，系统自动理解“意图”，并选择最优模型</p>

</blockquote>
<p>这正是 Embedding 可以发挥作用的地方。</p>
<h2 id="核心原理embedding-驱动的语义路由">核心原理：Embedding 驱动的语义路由</h2>
<h3 id="1-embedding-本质">1. Embedding 本质</h3>
<p>Embedding 模型（如 <code>text-embedding-3-small</code>）可以将文本映射为高维向量：</p>
<pre tabindex="0"><code>&#34;写一段 Python 排序代码&#34;
→ [0.012, -0.98, ..., 0.33]  (1536维向量)
</code></pre><p>语义相似的文本，其向量距离更近。</p>
<h3 id="2-路由思路">2. 路由思路</h3>
<p>核心逻辑可以抽象为：</p>
<pre tabindex="0"><code>用户请求 → Embedding → 与“意图模板库”计算相似度 → 选择最佳匹配 → 路由模型
</code></pre><h3 id="3-意图模板intent-prototypes">3. 意图模板（Intent Prototypes）</h3>
<p>我们预先定义一组“意图向量”：</p>
<table>
	<thead>
			<tr>
					<th>意图类型</th>
					<th>示例文本</th>
					<th>目标模型</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>代码生成</td>
					<td>&ldquo;write code&rdquo;, &ldquo;generate function&rdquo;</td>
					<td>GPT-4</td>
			</tr>
			<tr>
					<td>简单问答</td>
					<td>&ldquo;what is&rdquo;, &ldquo;define&rdquo;</td>
					<td>GPT-4o-mini</td>
			</tr>
			<tr>
					<td>翻译</td>
					<td>&ldquo;translate this&rdquo;</td>
					<td>轻量模型</td>
			</tr>
			<tr>
					<td>长文生成</td>
					<td>&ldquo;write blog&rdquo;</td>
					<td>GPT-4</td>
			</tr>
	</tbody>
</table>
<p>这些文本会提前计算 embedding 并缓存。</p>
<h3 id="4-相似度计算">4. 相似度计算</h3>
<p>常用方法：</p>
<ul>
<li>Cosine Similarity（推荐）</li>
</ul>
<pre tabindex="0"><code>similarity = cosine(query_embedding, intent_embedding)
</code></pre><p>选择最高分：</p>
<pre tabindex="0"><code>argmax(similarity_i)
</code></pre><h2 id="系统架构设计">系统架构设计</h2>
<p>整体架构如下：</p>
<pre tabindex="0"><code>                ┌──────────────┐
                │   Client     │
                └──────┬───────┘
                       │
               ┌───────▼────────┐
               │    Envoy 网关   │
               │   (智能路由层)   │
               └───────┬────────┘
                       │
        ┌──────────────┼──────────────┐
        │              │              │
 ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐
 │ GPT-4      │ │ GPT-4o-mini│ │ 专用模型    │
 └────────────┘ └────────────┘ └────────────┘
</code></pre><h2 id="实现逻辑拆解">实现逻辑拆解</h2>
<h3 id="1-请求流程">1. 请求流程</h3>
<h4 id="step-1获取用户请求">Step 1：获取用户请求</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;input&#34;</span>: <span style="color:#e6db74">&#34;帮我写一段 Go 的 HTTP server 示例&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h4 id="step-2调用-embedding-api">Step 2：调用 Embedding API</h4>
<p>使用 Azure OpenAI：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">POST https://{endpoint}/openai/deployments/text-embedding-3-small/embeddings?api-version=2024-02-15-preview
</span></span></span></code></pre></div><p>请求体：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;input&#34;</span>: <span style="color:#e6db74">&#34;帮我写一段 Go 的 HTTP server 示例&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h4 id="step-3计算相似度">Step 3：计算相似度</h4>
<p>伪代码：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">route</span>(query_embedding):
</span></span><span style="display:flex;"><span>    best_score <span style="color:#f92672">=</span> <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    best_model <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> intent <span style="color:#f92672">in</span> intents:
</span></span><span style="display:flex;"><span>        score <span style="color:#f92672">=</span> cosine_similarity(query_embedding, intent<span style="color:#f92672">.</span>embedding)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> score <span style="color:#f92672">&gt;</span> best_score:
</span></span><span style="display:flex;"><span>            best_score <span style="color:#f92672">=</span> score
</span></span><span style="display:flex;"><span>            best_model <span style="color:#f92672">=</span> intent<span style="color:#f92672">.</span>model
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> best_model
</span></span></code></pre></div><h4 id="step-4envoy-动态路由">Step 4：Envoy 动态路由</h4>
<p>将结果写入 Header：</p>
<pre tabindex="0"><code>x-llm-route: gpt-4
</code></pre><p>Envoy 根据 Header 路由：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">routes</span>:
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">match</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">prefix</span>: <span style="color:#e6db74">&#34;/v1/chat&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">headers</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">x-llm-route</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exact_match</span>: <span style="color:#ae81ff">gpt-4</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">route</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cluster</span>: <span style="color:#ae81ff">gpt4_service</span>
</span></span></code></pre></div><h2 id="envoy-网关实现方式">Envoy 网关实现方式</h2>
<p>Envoy 本身不直接支持向量计算，因此需要扩展：</p>
<h3 id="方案-1external-processing推荐">方案 1：External Processing（推荐）</h3>
<p>Envoy 调用外部服务（ext_proc）：</p>
<pre tabindex="0"><code>Envoy → ext_proc 服务 → 返回路由决策 → Envoy 转发
</code></pre><p>ext_proc 负责：</p>
<ul>
<li>调用 embedding</li>
<li>计算相似度</li>
<li>返回 header</li>
</ul>
<h3 id="方案-2wasm-插件">方案 2：WASM 插件</h3>
<p>在 Envoy 内嵌逻辑：</p>
<ul>
<li>优点：低延迟</li>
<li>缺点：开发复杂（Rust/CPP）</li>
</ul>
<h3 id="方案-3sidecar-router简单">方案 3：Sidecar Router（简单）</h3>
<pre tabindex="0"><code>Client → Router Service → Envoy → Model
</code></pre><p>Router 服务负责所有智能逻辑。</p>
<h2 id="优化策略">优化策略</h2>
<h3 id="1-向量缓存">1. 向量缓存</h3>
<p>避免重复调用 embedding：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>cache[input_hash] <span style="color:#f92672">=</span> embedding
</span></span></code></pre></div><h3 id="2-意图分层">2. 意图分层</h3>
<p>避免单层分类过粗：</p>
<pre tabindex="0"><code>一级：代码 / 问答 / 写作
二级：语言 / 长度 / 难度
</code></pre><h3 id="3-阈值控制">3. 阈值控制</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> best_score <span style="color:#f92672">&lt;</span> threshold:
</span></span><span style="display:flex;"><span>    fallback_model <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;gpt-4&#34;</span>
</span></span></code></pre></div><p>防止误判。</p>
<h3 id="4-多模型融合ensemble">4. 多模型融合（Ensemble）</h3>
<p>可扩展为：</p>
<ul>
<li>Top-K 模型候选</li>
<li>A/B 测试</li>
<li>加权路由</li>
</ul>
<h3 id="5-成本感知路由">5. 成本感知路由</h3>
<p>结合：</p>
<ul>
<li>token 预算</li>
<li>用户等级</li>
<li>SLA</li>
</ul>
<p>做策略增强：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> user <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;free&#34;</span>:
</span></span><span style="display:flex;"><span>    prefer_small_model()
</span></span></code></pre></div><h2 id="优势分析">优势分析</h2>
<h3 id="相比规则路由">相比规则路由</h3>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Embedding 路由</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>语义理解</td>
					<td>强</td>
			</tr>
			<tr>
					<td>可扩展性</td>
					<td>高</td>
			</tr>
			<tr>
					<td>维护成本</td>
					<td>低</td>
			</tr>
	</tbody>
</table>
<h3 id="相比手动指定模型">相比手动指定模型</h3>
<ul>
<li>用户体验更好（无需关心模型）</li>
<li>自动最优成本</li>
<li>支持动态策略</li>
</ul>
<h2 id="落地建议">落地建议</h2>
<h3 id="1-初始阶段">1. 初始阶段</h3>
<ul>
<li>5~10 个意图模板即可</li>
<li>手动标注 + 调优</li>
</ul>
<h3 id="2-数据驱动优化">2. 数据驱动优化</h3>
<p>记录：</p>
<ul>
<li>用户请求</li>
<li>路由结果</li>
<li>用户反馈</li>
</ul>
<p>持续优化 intent embedding。</p>
<h3 id="3-与-llm-router-结合">3. 与 LLM Router 结合</h3>
<p>未来可以升级为：</p>

<blockquote>
  <p>用小模型 + embedding 混合决策（Hybrid Router）</p>

</blockquote>
<h2 id="总结">总结</h2>
<p>本文方案的本质是：</p>

<blockquote>
  <p><strong>用 Embedding 替代规则，用向量相似度驱动模型选择</strong></p>

</blockquote>
<p>关键收益：</p>
<ul>
<li>降低成本（智能选择轻量模型）</li>
<li>提升效果（复杂任务走强模型）</li>
<li>架构解耦（路由逻辑与模型解耦）</li>
</ul>
<p>在多模型时代，这种“语义驱动的网关”会成为标准架构组件。</p>
]]></content:encoded></item><item><title>AI 编程进入 Skill 时代：从提示词到“能力封装”的跃迁</title><link>https://wilsonwu.me/zh/blog/2026/ai-skills/</link><pubDate>Wed, 22 Apr 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/ai-skills/</guid><description>&lt;p&gt;&lt;img alt="AI 编程 Skill 时代" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/ai-skills/1-ai-skills.png"&gt;&lt;/p&gt;
&lt;p&gt;在过去一年里，AI 编程的主流交互方式经历了一个明显演进：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Prompt → Prompt Engineering → Context Engineering → &lt;strong&gt;Skill（能力封装）&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;如果说 Prompt 是一次性调用模型能力，那么 &lt;strong&gt;Skill 本质上是在构建“可复用的智能能力模块”&lt;/strong&gt;，它让 AI 从“会回答问题”，进化为“能执行任务”。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="AI 编程 Skill 时代" loading="lazy" src="/zh/blog/2026/ai-skills/1-ai-skills.png"></p>
<p>在过去一年里，AI 编程的主流交互方式经历了一个明显演进：</p>

<blockquote>
  <p>Prompt → Prompt Engineering → Context Engineering → <strong>Skill（能力封装）</strong></p>

</blockquote>
<p>如果说 Prompt 是一次性调用模型能力，那么 <strong>Skill 本质上是在构建“可复用的智能能力模块”</strong>，它让 AI 从“会回答问题”，进化为“能执行任务”。</p>
<p>这篇文章将系统讲清：</p>
<ul>
<li>什么是 AI 编程中的 Skill</li>
<li>Skill 与 Prompt 的本质区别</li>
<li>如何在 VS Code + GitHub Copilot Chat 中使用 Skill 思维</li>
<li>实战案例（工程级）</li>
</ul>
<h2 id="什么是-skill">什么是 Skill？</h2>
<p>在 AI 编程语境下，<strong>Skill = 结构化提示 + 上下文 + 执行约束 + 输出格式</strong></p>
<p>可以用一个更工程化的定义：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Skill = f(Instruction, Context, Constraints, Output Schema)
</span></span></code></pre></div><p>它不是一句 prompt，而是一个“可复用的能力接口”。</p>
<h3 id="对比理解">对比理解</h3>
<table>
	<thead>
			<tr>
					<th>方式</th>
					<th>特点</th>
					<th>问题</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Prompt</td>
					<td>临时输入</td>
					<td>不可复用、不稳定</td>
			</tr>
			<tr>
					<td>Prompt Engineering</td>
					<td>优化表达</td>
					<td>仍然是一次性</td>
			</tr>
			<tr>
					<td>Skill</td>
					<td>能力封装</td>
					<td>可复用、可组合、可治理</td>
			</tr>
	</tbody>
</table>
<h2 id="为什么-skill-很重要">为什么 Skill 很重要？</h2>
<p>在实际开发中，AI 使用很快会遇到几个瓶颈：</p>
<h3 id="1-prompt-不稳定">1. Prompt 不稳定</h3>
<p>同一个问题，换个说法结果不同</p>
<h3 id="2-团队无法复用经验">2. 团队无法复用经验</h3>
<p>每个人都有自己的“私有 prompt”</p>
<h3 id="3-ai-输出不可控">3. AI 输出不可控</h3>
<p>格式、风格、边界难以统一</p>
<h2 id="skill-的价值">Skill 的价值</h2>
<ul>
<li><strong>标准化能力</strong></li>
<li><strong>沉淀团队最佳实践</strong></li>
<li><strong>降低认知负担</strong></li>
<li><strong>提升输出一致性</strong></li>
</ul>
<p>本质上：Skill 是 AI 时代的“函数”或“API”</p>
<h2 id="vs-code-中的-skill-实现方式">VS Code 中的 Skill 实现方式</h2>
<p>虽然 VS Code 本身没有官方“Skill”概念，但可以通过以下机制实现：</p>
<h3 id="1-githubcopilot-instructionsmd">1. <code>.github/copilot-instructions.md</code></h3>
<p>这是最接近“全局 Skill”的机制。</p>
<p>作用：</p>
<ul>
<li>为 Copilot 提供<strong>长期上下文</strong></li>
<li>约束 AI 的行为方式</li>
</ul>
<p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span># Backend Coding Guidelines
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Use clean architecture
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Follow RESTful API design
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Add unit tests for all services
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Use dependency injection
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># Code Style
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Prefer TypeScript over JavaScript
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Use async/await instead of callbacks
</span></span></code></pre></div><p>这相当于一个“全局 Skill 注入器”</p>
<h3 id="2-项目级-skillreadme--projectmd">2. 项目级 Skill（README / project.md）</h3>
<p>你可以在项目中定义：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span># AI Skill: API Generator
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>When generating APIs:
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Use OpenAPI 3.0 spec
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Include request/response schema
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Add validation rules
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> Provide curl examples
</span></span></code></pre></div><h3 id="3-会话级-skillcopilot-chat">3. 会话级 Skill（Copilot Chat）</h3>
<p>在 Chat 中你可以定义一次性 Skill：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>You are a senior backend architect.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Task:
</span></span><span style="display:flex;"><span>- Refactor code to hexagonal architecture
</span></span><span style="display:flex;"><span>- Keep business logic pure
</span></span><span style="display:flex;"><span>- Extract adapters
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Output:
</span></span><span style="display:flex;"><span>- Folder structure
</span></span><span style="display:flex;"><span>- Example code
</span></span></code></pre></div><h2 id="github-copilot-chat-中的-skill-用法">GitHub Copilot Chat 中的 Skill 用法</h2>
<p>Copilot Chat 是 Skill 最直接的使用场景。</p>
<p>下面给你几个<strong>工程级实战例子</strong>。</p>
<h3 id="示例-1代码重构-skill">示例 1：代码重构 Skill</h3>
<h4 id="输入skill">输入（Skill）</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Act as a senior software architect.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Refactor this code:
</span></span><span style="display:flex;"><span>- Apply clean architecture
</span></span><span style="display:flex;"><span>- Separate domain, application, infrastructure
</span></span><span style="display:flex;"><span>- Add dependency inversion
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Output:
</span></span><span style="display:flex;"><span>1. Folder structure
</span></span><span style="display:flex;"><span>2. Refactored code
</span></span><span style="display:flex;"><span>3. Explanation
</span></span></code></pre></div><h4 id="效果">效果</h4>
<p>AI 不再只是“改代码”，而是：</p>
<ul>
<li>输出结构设计</li>
<li>给出分层方案</li>
<li>解释设计决策</li>
</ul>
<p>从“补全工具”变成“架构助手”</p>
<h3 id="示例-2测试生成-skill">示例 2：测试生成 Skill</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>You are a QA engineer.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Generate unit tests:
</span></span><span style="display:flex;"><span>- Use Jest
</span></span><span style="display:flex;"><span>- Cover edge cases
</span></span><span style="display:flex;"><span>- Include mocks
</span></span><span style="display:flex;"><span>- Ensure &gt;80% coverage
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Output:
</span></span><span style="display:flex;"><span>- Test file
</span></span><span style="display:flex;"><span>- Coverage explanation
</span></span></code></pre></div><p>优势：</p>
<ul>
<li>输出结构稳定</li>
<li>测试风格统一</li>
<li>可团队复用</li>
</ul>
<h3 id="示例-3api-文档生成-skill">示例 3：API 文档生成 Skill</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Act as an API documentation generator.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Given code:
</span></span><span style="display:flex;"><span>- Extract endpoints
</span></span><span style="display:flex;"><span>- Generate OpenAPI spec
</span></span><span style="display:flex;"><span>- Add request/response examples
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Output in YAML
</span></span></code></pre></div><p>直接产出可用 API 文档</p>
<h3 id="示例-4代码评审-skill强烈推荐">示例 4：代码评审 Skill（强烈推荐）</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Act as a senior code reviewer.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Review this code:
</span></span><span style="display:flex;"><span>- Check performance issues
</span></span><span style="display:flex;"><span>- Identify security risks
</span></span><span style="display:flex;"><span>- Suggest improvements
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Output:
</span></span><span style="display:flex;"><span>- Issues list
</span></span><span style="display:flex;"><span>- Severity level
</span></span><span style="display:flex;"><span>- Fix suggestions
</span></span></code></pre></div><p>这个 Skill 在团队中极其高价值</p>
<h2 id="进阶skill-组合skill-pipeline">进阶：Skill 组合（Skill Pipeline）</h2>
<p>真正强大的不是单个 Skill，而是<strong>Skill 组合</strong>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>需求 → API Skill → 实现 Skill → 测试 Skill → Review Skill
</span></span></code></pre></div><p>形成一个 AI 开发流水线：</p>
<ol>
<li>生成 API</li>
<li>生成实现</li>
<li>生成测试</li>
<li>自动 review</li>
</ol>
<p>本质：AI 驱动的 Dev Pipeline</p>
<h2 id="最佳实践">最佳实践</h2>
<h3 id="1-skill-必须结构化">1. Skill 必须结构化</h3>
<p>不要写：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>帮我优化代码
</span></span></code></pre></div><p>要写：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Optimize code:
</span></span><span style="display:flex;"><span>- Reduce time complexity
</span></span><span style="display:flex;"><span>- Improve readability
</span></span><span style="display:flex;"><span>- Add comments
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Output:
</span></span><span style="display:flex;"><span>- Before/After
</span></span><span style="display:flex;"><span>- Explanation
</span></span></code></pre></div><h3 id="2-强制输出格式">2. 强制输出格式</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Output in JSON:
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  &#34;issues&#34;: [],
</span></span><span style="display:flex;"><span>  &#34;fixes&#34;: []
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>这对自动化极其重要</p>
<h3 id="3-限定角色role-prompting">3. 限定角色（Role Prompting）</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Act as:
</span></span><span style="display:flex;"><span>- Staff Engineer
</span></span><span style="display:flex;"><span>- Security Expert
</span></span><span style="display:flex;"><span>- Performance Engineer
</span></span></code></pre></div><h3 id="4-skill-应该可复制">4. Skill 应该可复制</h3>
<p>建议统一放在：</p>
<ul>
<li><code>.github/copilot-instructions.md</code></li>
<li><code>docs/ai-skills.md</code></li>
<li>内部 wiki</li>
</ul>
<h2 id="skill-vs-agent很多人会混淆">Skill vs Agent（很多人会混淆）</h2>
<table>
	<thead>
			<tr>
					<th>概念</th>
					<th>本质</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Skill</td>
					<td>单一能力</td>
			</tr>
			<tr>
					<td>Agent</td>
					<td>多 Skill + 自动决策</td>
			</tr>
	</tbody>
</table>
<p>Skill 是积木
Agent 是机器人</p>
<h2 id="总结">总结</h2>
<p>AI 编程正在从：</p>

<blockquote>
  <p>“怎么问 AI” → “怎么设计 AI 能力”</p>

</blockquote>
<p>Skill 的出现意味着：</p>
<ul>
<li>Prompt 不再是核心</li>
<li><strong>能力设计才是核心</strong></li>
<li>AI 正在变成“开发基础设施”</li>
</ul>

<blockquote>
  <p>未来优秀的工程师，不只是写代码的人，而是<strong>设计 AI 能力的人</strong></p>

</blockquote>
]]></content:encoded></item><item><title>Clawdbot：让您在 Azure 上拥有一个强大的 AI 全能助手</title><link>https://wilsonwu.me/zh/blog/2026/clawdbot-on-azure/</link><pubDate>Sun, 25 Jan 2026 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2026/clawdbot-on-azure/</guid><description>&lt;p&gt;&lt;img alt="Clawdbot" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/clawdbot-on-azure/1-clawdbot.png"&gt;&lt;/p&gt;
&lt;p&gt;随着生成式 AI 与智能代理的快速发展，自托管的智能系统正变得越来越受开发者和技术团队关注。&lt;strong&gt;Clawdbot&lt;/strong&gt; 就是这样一款开源、可自托管的个人 AI 助手，它不仅能与用户进行对话，还能执行任务、集成消息平台、提供自动化能力，并可部署在云平台上如 Azure。本文将逐步介绍 Clawdbot 的能力、架构以及如何在 &lt;strong&gt;Microsoft Azure&lt;/strong&gt; 上部署并开始使用它。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="Clawdbot" loading="lazy" src="/zh/blog/2026/clawdbot-on-azure/1-clawdbot.png"></p>
<p>随着生成式 AI 与智能代理的快速发展，自托管的智能系统正变得越来越受开发者和技术团队关注。<strong>Clawdbot</strong> 就是这样一款开源、可自托管的个人 AI 助手，它不仅能与用户进行对话，还能执行任务、集成消息平台、提供自动化能力，并可部署在云平台上如 Azure。本文将逐步介绍 Clawdbot 的能力、架构以及如何在 <strong>Microsoft Azure</strong> 上部署并开始使用它。</p>
<h2 id="什么是-clawdbot">什么是 Clawdbot</h2>
<p><strong>Clawdbot</strong> 是一个开源的个人 AI 助手项目，本质上是一个智能代理框架，它可以：</p>
<ul>
<li>与多种消息平台进行交互，包括 WhatsApp、Telegram、Discord、Slack、Microsoft Teams 等。</li>
<li>承担智能对话、任务执行、自动化工作流的角色，而非单纯聊天机器人。</li>
<li>支持在本地或自托管服务器上运行，数据默认保存在本地，增强隐私保护。</li>
<li>通过插件/技能扩展，实现日程提醒、文件管理、邮件处理、代码生成等功能。</li>
</ul>
<p>Clawdbot 的核心由 <strong>Gateway、Providers（平台接入）、Agents/Skills（智能任务执行单元）</strong>构成。Gateway 负责消息总线、Providers 负责与各消息平台对接，Agents/Skills 则实际执行用户请求。</p>
<h2 id="clawdbot-的主要特性">Clawdbot 的主要特性</h2>
<ol>
<li>
<p><strong>多平台消息接入</strong>
Clawdbot 支持通过连接聊天平台 API，接收来自 WhatsApp、Telegram、Discord、Signal、Slack、Microsoft Teams 等消息，并将这些消息传递给 AI 模型进行处理。</p>
</li>
<li>
<p><strong>隐私和数据控制</strong>
与云端托管的聊天 AI 不同，Clawdbot 可以运行在你自己的服务器或 VPS 上，所有对话历史、配置、凭据都可以按需存储和访问。</p>
</li>
<li>
<p><strong>自动化执行能力</strong>
除了对话，Clawdbot 还能执行具体任务，包括浏览器控制、文件操作、数据处理、发送提醒等，这使其成为一个更为复杂的个人助手平台。</p>
</li>
<li>
<p><strong>可扩展的模型支持</strong>
Clawdbot 支持多种 AI 模型提供者（如 OpenAI GPT、Anthropic Claude，甚至本地 LLM 服务如 Ollama）。</p>
</li>
</ol>
<h2 id="在-azure-vm-部署-clawdbot">在 Azure VM 部署 Clawdbot</h2>
<p>虽然 Clawdbot 支持本地运行或容器平台，但在以下场景中，<strong>Azure VM 是非常合适的部署形态</strong>：</p>
<ol>
<li>
<p><strong>完全控制运行环境</strong></p>
<ul>
<li>自由选择 OS（Ubuntu / Debian）</li>
<li>自由安装 Node.js、Docker、GPU 驱动等</li>
</ul>
</li>
<li>
<p><strong>数据与隐私可控</strong></p>
<ul>
<li>所有对话记录、日志、配置文件仅存在于 VM 内</li>
<li>不依赖第三方 PaaS</li>
</ul>
</li>
<li>
<p><strong>适合长期运行的 Agent</strong></p>
<ul>
<li>7×24 小时在线</li>
<li>可通过 systemd 或 Docker 保证自动重启</li>
</ul>
</li>
<li>
<p><strong>可扩展性良好</strong></p>
<ul>
<li>可横向扩容（多 VM + 负载均衡）</li>
<li>可纵向扩容（更大规格 CPU / GPU）</li>
</ul>
</li>
</ol>
<h2 id="整体部署架构概览">整体部署架构概览</h2>
<p>在 Azure VM 上运行 Clawdbot 的典型架构如下：</p>
<p><img alt="架构" loading="lazy" src="/zh/blog/2026/clawdbot-on-azure/2-architecture.png"></p>
<h2 id="创建-azure-vm">创建 Azure VM</h2>
<h3 id="vm-基础配置建议">VM 基础配置建议</h3>
<ul>
<li><strong>OS</strong>：Ubuntu 22.04 LTS</li>
<li><strong>规格</strong>：
<ul>
<li>轻量使用：Standard B2s / B4ms</li>
<li>多 Agent：D4s_v5 及以上</li>
</ul>
</li>
<li><strong>磁盘</strong>：至少 64 GB</li>
<li><strong>网络</strong>：
<ul>
<li>入站：22（SSH）</li>
<li>入站：3000（或自定义 Clawdbot 端口）</li>
</ul>
</li>
</ul>
<h3 id="登录-vm">登录 VM</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ssh azureuser@&lt;VM_PUBLIC_IP&gt;
</span></span></code></pre></div><h2 id="安装运行时环境">安装运行时环境</h2>
<h3 id="系统依赖">系统依赖</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt update <span style="color:#f92672">&amp;&amp;</span> sudo apt upgrade -y
</span></span><span style="display:flex;"><span>sudo apt install -y git curl build-essential
</span></span></code></pre></div><h3 id="安装-nodejs推荐-20-lts">安装 Node.js（推荐 20 LTS）</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
</span></span><span style="display:flex;"><span>sudo apt install -y nodejs
</span></span><span style="display:flex;"><span>node -v
</span></span><span style="display:flex;"><span>npm -v
</span></span></code></pre></div><h2 id="安装-clawdbot">安装 Clawdbot</h2>
<h3 id="全局-cli--本地运行目录">全局 CLI + 本地运行目录</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo npm install -g clawdbot
</span></span></code></pre></div><p>创建运行目录：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/clawdbot
</span></span><span style="display:flex;"><span>cd ~/clawdbot
</span></span></code></pre></div><p>初始化配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>clawdbot onboard
</span></span></code></pre></div><p>跟着步骤完成各类配置即可：</p>
<p><img alt="配置" loading="lazy" src="/zh/blog/2026/clawdbot-on-azure/3-onboard.png"></p>
<h2 id="启动-clawdbot">启动 Clawdbot</h2>
<h3 id="手动启动验证">手动启动验证</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>clawdbot gateway
</span></span></code></pre></div><p>确认日志中出现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>🦞 Clawdbot 2026.1.24-3 (885167d) — I speak fluent bash, mild sarcasm, and aggressive tab-completion energy.
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>16:18:27 [heartbeat] started
</span></span><span style="display:flex;"><span>16:18:27 [gateway] agent model: openai-codex/gpt-5.1
</span></span><span style="display:flex;"><span>16:18:27 [gateway] listening on ws://127.0.0.1:18789 (PID 36888)
</span></span><span style="display:flex;"><span>16:18:27 [gateway] listening on ws://[::1]:18789
</span></span><span style="display:flex;"><span>16:18:27 [gateway] log file: \tmp\clawdbot\clawdbot-2026-01-27.log
</span></span><span style="display:flex;"><span>16:18:27 [browser/server] Browser control listening on http://127.0.0.1:18791/
</span></span><span style="display:flex;"><span>...
</span></span></code></pre></div><p>通过浏览器打开：<a href="http://127.0.0.1:18789/">http://127.0.0.1:18789/</a></p>
<p><img alt="界面" loading="lazy" src="/zh/blog/2026/clawdbot-on-azure/4-interface.png"></p>
<p>即可开始使用！</p>
<h2 id="结语">结语</h2>
<p>Clawdbot 是一款功能强大的开源 AI 个人助手，可将 AI 交互扩展到多种消息平台，并集成丰富的执行能力。通过将 Clawdbot 部署到 Azure，你可以将它打造成一个企业级、自托管的 AI 服务，提高可用性、安全性和可扩展性。上述步骤覆盖了从创建 Azure Bot 到完整部署 Clawdbot 的全流程，希望能帮助你快速搭建并运行自己的智能助理。</p>
]]></content:encoded></item><item><title>微软 TRELLIS 大语言模型：面向生产级 3D 资产生成的大模型及基于 Azure 部署使用指南</title><link>https://wilsonwu.me/zh/blog/2026/llm-microsoft-trellis-3d/</link><pubDate>Mon, 19 Jan 2026 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2026/llm-microsoft-trellis-3d/</guid><description>&lt;p&gt;&lt;img alt="Trellis-3D" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/llm-microsoft-trellis-3d/1-trellis-3d.png"&gt;&lt;/p&gt;
&lt;p&gt;2025 年末，微软研究院发布了一个面向 3D 内容创作的开源大模型项目 &lt;strong&gt;TRELLIS&lt;/strong&gt;，并伴随学术论文&lt;a href="https://arxiv.org/abs/2412.01506"&gt;《Structured 3D Latents for Scalable and Versatile 3D Generation》&lt;/a&gt;。该项目通过统一的结构化潜在空间与先进的流模型技术，显著提升了文本/图像到 3D 资产生成的质量与灵活性，同时拓展了 3D 模型的多格式输出与编辑能力，成为当前 3D AI 模型生态中的重要技术之一。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="Trellis-3D" loading="lazy" src="/zh/blog/2026/llm-microsoft-trellis-3d/1-trellis-3d.png"></p>
<p>2025 年末，微软研究院发布了一个面向 3D 内容创作的开源大模型项目 <strong>TRELLIS</strong>，并伴随学术论文<a href="https://arxiv.org/abs/2412.01506">《Structured 3D Latents for Scalable and Versatile 3D Generation》</a>。该项目通过统一的结构化潜在空间与先进的流模型技术，显著提升了文本/图像到 3D 资产生成的质量与灵活性，同时拓展了 3D 模型的多格式输出与编辑能力，成为当前 3D AI 模型生态中的重要技术之一。</p>
<p>官方 Github 仓库地址：<a href="https://github.com/microsoft/TRELLIS">https://github.com/microsoft/TRELLIS</a></p>
<h2 id="trellis-是什么核心概念与架构">TRELLIS 是什么？——核心概念与架构</h2>
<p>TRELLIS 是微软构建的<strong>大型 3D 资产生成模型</strong>，支持输入文本提示或图像，并输出高质量的三维模型资产。其技术创新点主要包括：</p>
<ul>
<li><strong>统一结构化潜在表示（SLAT）</strong>：将三维信息编码为一种可扩展的结构化潜在空间表示，使模型能够以统一方式理解和生成不同表现形式的 3D 结果。</li>
<li><strong>整流流（Rectified Flow Transformers）</strong>：专为 SLAT 设计的生成骨干网络，通过适应 sparse 表示提升生成效率与质量。</li>
<li><strong>大规模训练与预训练模型</strong>：模型规模最高达<strong>约 20 亿参数</strong>，并在包含 <strong>50 万多样化三维资产的数据集</strong>上训练，具备强泛化能力。</li>
</ul>
<p>TRELLIS 不仅能生成三维几何，还能捕获复杂纹理与外观信息，使得生成的资产更贴近真实世界中可用的三维内容。</p>
<h2 id="主要功能特性">主要功能特性</h2>
<p>以下是 TRELLIS 的核心功能模块及技术亮点：</p>
<h3 id="多模态输入能力">多模态输入能力</h3>
<p>TRELLIS 支持以下输入条件：</p>
<ul>
<li><strong>文本提示（Text-to-3D）</strong>：通过自然语言描述生成三维资产（提示须清晰准确）。</li>
<li><strong>图像条件（Image-to-3D）</strong>：根据一张或多张图片生成对应三维模型。</li>
</ul>
<p>这种多模态输入支持，使 TRELLIS 适用于从概念设计到真实原型的全流程创作。</p>
<p><img alt="3D" loading="lazy" src="/zh/blog/2026/llm-microsoft-trellis-3d/2-to-3d.png"></p>
<h3 id="灵活的输出格式">灵活的输出格式</h3>
<p>根据下游需求，TRELLIS 输出包括：</p>
<ul>
<li><strong>辐射场（NeRF/Radiance Fields）</strong>：适合渲染与展示</li>
<li><strong>3D 高斯表示</strong>：一种可渲染密度表示</li>
<li><strong>传统网格 Mesh</strong>：可导出为标准 3D 文件格式（例如 GLB/OBJ）用于游戏、AR/VR 等应用</li>
</ul>
<p>这种格式灵活性是模型在实际生产环境中落地的关键能力。</p>
<h3 id="本地三维编辑与变体生成">本地三维编辑与变体生成</h3>
<p>与早期单一生成模型不同，TRELLIS 能：</p>
<ul>
<li><strong>局部编辑</strong>：在已有模型上进行细部修改</li>
<li><strong>生成变体</strong>：在保持基本结构不变的前提下，创建风格、细节不同的多版本输出</li>
</ul>
<p>这种能力特别适合迭代式 3D 内容设计。</p>
<h3 id="支持大规模数据集训练与可扩展性">支持大规模数据集训练与可扩展性</h3>
<p>TRELLIS 在包含多种来源（如 Objaverse、ABO 等）的大规模 3D 数据集上进行训练，使其具备：</p>
<ul>
<li><strong>强泛化能力</strong></li>
<li><strong>更丰富的几何与纹理表现</strong></li>
<li><strong>多任务适配能力</strong></li>
</ul>
<p>这样的数据基础是其较竞品更具优势的根本原因之一。</p>
<h2 id="与其他竞品对比优势与提升点">与其他竞品对比：优势与提升点</h2>
<p>目前市场上其他比较知名的 3D 生成模型（例如 <strong>OpenAI Shap-E</strong>、或研究型模型如 MeshGen 等）多存在以下局限：</p>
<ul>
<li>有的只能输出单一 3D 表示（如内隐函数或点云）</li>
<li>多数不支持本地编辑与多种格式输出</li>
<li>在复杂纹理、细节表现上效果有限</li>
</ul>
<p>相比之下：</p>
<h3 id="trellis-的主要优势包括">TRELLIS 的主要优势包括</h3>
<ol>
<li><strong>结构化潜在表示（SLAT）框架</strong>：支持统一的生成与解码机制，同时兼顾几何与纹理细节。</li>
<li><strong>多格式生成输出</strong>：与只生成单种数据结构的竞品相比更通用。</li>
<li><strong>本地编辑与变体生成</strong>：提升了设计迭代效率。</li>
<li><strong>大规模预训练与更丰富数据集支撑</strong>：提升了模型对稀有或复杂对象的生成能力。</li>
</ol>
<p>总的来说，TRELLIS 更偏向工业级 3D 生成工具，而不只是科研展示或单一生成模型。</p>
<h2 id="部署和使用">部署和使用</h2>
<p>目前，微软尚未在 <strong>Azure AI Foundry / Microsoft Foundry</strong> 中以 Serverless 或托管模型的形式直接提供 TRELLIS。因此，在 Azure 云环境中使用 TRELLIS 的<strong>最可行、也是最稳定的方式</strong>，是基于 <strong>Azure GPU 虚拟机（VM）</strong>进行自托管部署。</p>
<p>这种方式具备以下优势：</p>
<ul>
<li>完全控制模型版本与推理流程</li>
<li>支持大显存 GPU，适合 3D 模型生成场景</li>
<li>可与企业现有 Azure 网络、安全、存储体系无缝集成</li>
</ul>
<h3 id="1-azure-gpu-vm-规格选型建议">1. Azure GPU VM 规格选型建议</h3>
<p>TRELLIS 属于<strong>显存与算力敏感型 3D 生成模型</strong>，对 GPU 资源要求较高，推荐如下规格：</p>
<h4 id="推荐-vm-系列">推荐 VM 系列</h4>
<ul>
<li><strong>Standard_NC A100 v4</strong>（A100 80GB，首选）</li>
<li><strong>Standard_NC A100 v5</strong>（A100 80GB，新一代）</li>
<li><strong>Standard_ND H100 v5</strong>（H100，若需要更高吞吐）</li>
</ul>

<blockquote>
  <p>实践建议：</p>
<ul>
<li><strong>推理 / Demo</strong>：单卡 A100 80GB 即可</li>
<li><strong>高分辨率 / 批量生成 / 编辑任务</strong>：建议单机多卡或更高规格</li>
</ul>

</blockquote>
<h3 id="2-操作系统与基础环境">2. 操作系统与基础环境</h3>
<p>推荐环境组合如下：</p>
<ul>
<li>OS：Ubuntu 20.04 / 22.04 LTS</li>
<li>CUDA：与 VM 驱动匹配的官方版本</li>
<li>Python：3.8 或以上</li>
<li>PyTorch：官方 CUDA 版本</li>
</ul>
<p>Azure Marketplace 中可直接选择 <strong>CUDA + NVIDIA Driver 预装镜像</strong>，可显著降低环境准备成本。</p>
<h3 id="3-trellis-安装与依赖配置">3. TRELLIS 安装与依赖配置</h3>
<h4 id="拉取代码">拉取代码</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone --recurse-submodules https://github.com/microsoft/TRELLIS.git
</span></span><span style="display:flex;"><span>cd TRELLIS
</span></span></code></pre></div><h4 id="创建独立-python-环境并安装依赖">创建独立 Python 环境并安装依赖</h4>
<p>官方已提供自动化脚本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>./setup.sh --new-env --basic
</span></span></code></pre></div><p>如需支持训练、编辑或高级功能，可根据官方说明启用对应参数。</p>
<h3 id="4-基于-azure-vm-的推理运行方式">4. 基于 Azure VM 的推理运行方式</h3>
<h4 id="方式一命令行推理适合批量任务">方式一：命令行推理（适合批量任务）</h4>
<p>文本或图像驱动生成 3D 模型：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python example_text.py <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --prompt <span style="color:#e6db74">&#34;a futuristic electric motorcycle&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --output_dir outputs/
</span></span></code></pre></div><p>或多图像条件输入：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python example_multi_image.py <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --input_images images/ <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --output_dir outputs/
</span></span></code></pre></div><p>生成结果可直接导出为 Mesh / Radiance Field 等格式，用于后续渲染或工具链集成。</p>
<h4 id="方式二gradio-web-ui推荐用于演示与验证">方式二：Gradio Web UI（推荐用于演示与验证）</h4>
<p>TRELLIS 官方提供基于 Gradio 的 Web Demo，适合：</p>
<ul>
<li>团队内部体验</li>
<li>产品验证</li>
<li>算法效果对比</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python app.py
</span></span></code></pre></div><p>然后通过 Azure VM 公网 IP 或内网负载均衡访问 Web UI。</p>
<h3 id="5-azure-上的工程化部署建议">5. Azure 上的工程化部署建议</h3>
<p>为了更好地在 Azure 上长期运行 TRELLIS，建议采用以下架构实践：</p>
<h4 id="存储解耦">存储解耦</h4>
<ul>
<li>模型权重：Azure Blob Storage</li>
<li>生成结果：Blob / Azure Files</li>
<li>日志与元数据：Azure Monitor / Log Analytics</li>
</ul>
<h4 id="服务化封装">服务化封装</h4>
<ul>
<li>使用 <strong>FastAPI / Flask</strong> 将 TRELLIS 推理封装为 HTTP API</li>
<li>GPU VM 作为推理节点</li>
<li>前端或工作流通过 REST 调用</li>
</ul>
<h4 id="安全与网络">安全与网络</h4>
<ul>
<li>VM 置于 VNet 内</li>
<li>Web UI / API 通过 Application Gateway 或内部访问</li>
<li>支持私有部署，满足企业安全要求</li>
</ul>
<h2 id="总结与展望">总结与展望</h2>
<p>微软 TRELLIS 代表了当前 3D AI 生成技术的重要进步，它在生成质量、格式多样性与本地编辑能力方面具备显著优势。对于希望自动化 3D 内容创建的开发者与设计者而言，它是一个有实力的开源方案。部署方面，虽然目前官方尚未将其作为 Foundry 的 serverless 模型，但可通过 Azure 的常规模型部署管道与 GPU 计算能力在云端高效运行 TRELLIS 推理与自定义训练。</p>
]]></content:encoded></item><item><title>大语言模型推理并行策略全景解析：TP / DP / PP / EP 原理与 vLLM 实战性能验证</title><link>https://wilsonwu.me/zh/blog/2025/llm-inference-parallelism-in-vllm/</link><pubDate>Wed, 24 Dec 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/llm-inference-parallelism-in-vllm/</guid><description>&lt;p&gt;当大语言模型的参数规模跨过几十亿门槛之后，一个事实变得不可回避：&lt;strong&gt;单卡时代已经结束&lt;/strong&gt;。无论是训练还是推理，模型本身的参数规模、KV Cache 的增长速度，以及真实业务中并发请求带来的显存与算力压力，都决定了大模型必须运行在多 GPU、甚至多节点的环境中。&lt;/p&gt;</description><content:encoded><![CDATA[<p>当大语言模型的参数规模跨过几十亿门槛之后，一个事实变得不可回避：<strong>单卡时代已经结束</strong>。无论是训练还是推理，模型本身的参数规模、KV Cache 的增长速度，以及真实业务中并发请求带来的显存与算力压力，都决定了大模型必须运行在多 GPU、甚至多节点的环境中。</p>
<p><img alt="分布式推理趋势" loading="lazy" src="/zh/blog/2025/llm-inference-parallelism-in-vllm/1-overview.png"></p>
<p>然而，<strong>用多张卡跑模型</strong>并不是一个简单的资源堆叠问题。在实践中，工程团队很快会发现：<strong>不同的拆分方式，带来的性能表现差异可能是数量级的</strong>。有的配置 TTFT 极低，却在高并发下迅速失速；有的方案吞吐惊人，却难以满足在线服务对延迟的要求；还有一些组合在理论上看似合理，却在真实硬件拓扑下被通信成本完全吞噬。</p>
<p>在训练与推理阶段，业界逐步沉淀出四类核心并行策略：</p>
<ul>
<li><strong>TP（Tensor Parallelism，张量并行）</strong></li>
<li><strong>DP（Data Parallelism，数据并行）</strong></li>
<li><strong>PP（Pipeline Parallelism，流水线并行）</strong></li>
<li><strong>EP（Expert Parallelism，专家并行，主要用于 MoE）</strong></li>
</ul>
<p><img alt="alt text" loading="lazy" src="/zh/blog/2025/llm-inference-parallelism-in-vllm/2-parallelism.png"></p>
<h2 id="大语言模型四种核心并行策略基础">大语言模型四种核心并行策略基础</h2>
<h3 id="tensor-parallelismtp算子级拆分">Tensor Parallelism（TP）：算子级拆分</h3>
<p>核心思想：将单层内部的矩阵计算（如 Linear / Attention）沿维度切分到多张 GPU 上并行计算。</p>
<p>典型拆分方式：</p>
<ul>
<li>Linear 层按 <code>hidden_size</code> 或 <code>num_heads</code> 切分</li>
<li>Attention 的 Q / K / V 或 FFN 的中间层拆分</li>
</ul>
<p><img alt="alt text" loading="lazy" src="/zh/blog/2025/llm-inference-parallelism-in-vllm/3-tp.png"></p>
<p>优点：</p>
<ul>
<li>单层显存占用显著下降</li>
<li>单 token 延迟较低（适合推理）</li>
</ul>
<p>代价：</p>
<ul>
<li>每一层都需要 <strong>All-Reduce / All-Gather</strong></li>
<li>通信频繁，对 NVLink / IB 依赖高</li>
</ul>
<p>适用场景：</p>
<ul>
<li>单模型过大但层数不多</li>
<li>对 <strong>TTFT（Time To First Token）</strong>敏感的在线推理</li>
</ul>
<h3 id="data-parallelismdp请求级复制">Data Parallelism（DP）：请求级复制</h3>
<p>核心思想：模型完整复制到多张 GPU，每张 GPU 处理不同请求或 batch。</p>
<p><img alt="alt text" loading="lazy" src="/zh/blog/2025/llm-inference-parallelism-in-vllm/4-dp.png"></p>
<p>优点：</p>
<ul>
<li>实现简单</li>
<li>无模型内部通信</li>
<li>吞吐线性扩展</li>
</ul>
<p>代价：</p>
<ul>
<li>显存需求最高（每卡一整模型）</li>
<li>单请求延迟不变</li>
</ul>
<p>适用场景：</p>
<ul>
<li>模型能放进单卡</li>
<li>离线推理 / 高并发吞吐</li>
</ul>
<h3 id="pipeline-parallelismpp层级切分">Pipeline Parallelism（PP）：层级切分</h3>
<p>核心思想：将模型按层切分到多张 GPU，请求在 GPU 间<strong>流水线式</strong>流动。</p>
<p><img alt="alt text" loading="lazy" src="/zh/blog/2025/llm-inference-parallelism-in-vllm/5-pp.png"></p>
<p>优点：</p>
<ul>
<li>几乎无层内通信</li>
<li>显存效率高（比 DP 好）</li>
</ul>
<p>代价：</p>
<ul>
<li>pipeline bubble（气泡）带来空转</li>
<li>小 batch 下延迟显著升高</li>
</ul>
<p>适用场景：</p>
<ul>
<li>超深模型（如 70B+）</li>
<li>长序列推理或训练</li>
</ul>
<h3 id="expert-parallelismepmoe-专属并行">Expert Parallelism（EP）：MoE 专属并行</h3>
<p>核心思想：在 MoE（Mixture of Experts）模型中，不同 Expert 分布在不同 GPU 上，仅被激活的 Expert 参与计算。</p>
<p><img alt="alt text" loading="lazy" src="/zh/blog/2025/llm-inference-parallelism-in-vllm/6-ep.png"></p>
<p>优点：</p>
<ul>
<li>参数规模大但计算稀疏</li>
<li>性能/参数比极高</li>
</ul>
<p>代价：</p>
<ul>
<li>Token 路由与负载不均</li>
<li>通信模式复杂</li>
</ul>
<p>适用场景：</p>
<ul>
<li>MoE 模型（如 Mixtral、DeepSeek-MoE）</li>
<li>高参数规模、可接受一定调度复杂度</li>
</ul>
<h2 id="并行策略并非互斥而是可组合的">并行策略并非互斥，而是可组合的</h2>
<p>在真实系统中，并行策略通常<strong>组合使用</strong>：</p>
<table>
	<thead>
			<tr>
					<th>组合方式</th>
					<th>典型用途</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>TP × DP</td>
					<td>推理最常见组合</td>
			</tr>
			<tr>
					<td>TP × PP</td>
					<td>超大模型训练 / 推理</td>
			</tr>
			<tr>
					<td>TP × EP</td>
					<td>MoE 推理</td>
			</tr>
			<tr>
					<td>DP × EP</td>
					<td>高吞吐 MoE 服务</td>
			</tr>
			<tr>
					<td>TP × PP × DP</td>
					<td>大规模分布式集群</td>
			</tr>
	</tbody>
</table>
<p>问题不在于<strong>能不能用</strong>，而在于：<strong>不同组合，在真实推理负载下，性能表现差异极大。</strong></p>
<p>这正是 vLLM 非常适合做验证的原因。</p>
<h2 id="vllm-中的并行能力概览">vLLM 中的并行能力概览</h2>
<p>vLLM 是一个面向<strong>高性能 LLM 推理</strong>的引擎，其核心优势包括：</p>
<ul>
<li>高效 KV Cache 管理（PagedAttention）</li>
<li>原生支持 <strong>TP / PP</strong></li>
<li>对 DP 友好（多实例、多副本）</li>
<li>MoE 模型中支持 EP（依赖模型实现）</li>
</ul>
<p>在 vLLM 中，并行配置主要通过<strong>启动参数 + 资源拓扑</strong>控制。</p>
<h2 id="基于-vllm-的并行策略配置示例">基于 vLLM 的并行策略配置示例</h2>
<h3 id="tensor-parallelismtp">Tensor Parallelism（TP）</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>vllm serve meta-llama/Llama-2-70b-hf <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tensor-parallel-size <span style="color:#ae81ff">4</span>
</span></span></code></pre></div><p>含义：</p>
<ul>
<li>模型每一层被拆分到 4 张 GPU</li>
<li>适合单请求低延迟验证</li>
</ul>
<h3 id="pipeline-parallelismpp">Pipeline Parallelism（PP）</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>vllm serve meta-llama/Llama-2-70b-hf <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --pipeline-parallel-size <span style="color:#ae81ff">2</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tensor-parallel-size <span style="color:#ae81ff">2</span>
</span></span></code></pre></div><p>含义：</p>
<ul>
<li>2-stage pipeline</li>
<li>每 stage 内 2-way TP</li>
<li>总 GPU 数 = 4</li>
</ul>
<h3 id="data-parallelismdp">Data Parallelism（DP）</h3>
<p>vLLM 本身不显式声明 DP，而是通过<strong>多实例部署</strong>实现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># GPU 0-3</span>
</span></span><span style="display:flex;"><span>CUDA_VISIBLE_DEVICES<span style="color:#f92672">=</span>0,1,2,3 vllm serve ...
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># GPU 4-7</span>
</span></span><span style="display:flex;"><span>CUDA_VISIBLE_DEVICES<span style="color:#f92672">=</span>4,5,6,7 vllm serve ...
</span></span></code></pre></div><p>再通过上层负载均衡（如 Kubernetes / Gateway）实现 DP。</p>
<h3 id="expert-parallelismep">Expert Parallelism（EP）</h3>
<p>对于 MoE 模型（如 Mixtral）：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>vllm serve mistralai/Mixtral-8x7B-Instruct <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tensor-parallel-size <span style="color:#ae81ff">4</span>
</span></span></code></pre></div><p>vLLM 会自动将 Expert 分布到 TP 组内 GPU（模型内部实现 EP）。</p>
<h2 id="如何在-vllm-中验证并行组合的真实性能">如何在 vLLM 中“验证”并行组合的真实性能</h2>
<h3 id="核心评测指标">核心评测指标</h3>
<p>建议至少关注以下指标：</p>
<table>
	<thead>
			<tr>
					<th>指标</th>
					<th>含义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>TTFT</td>
					<td>首 token 延迟</td>
			</tr>
			<tr>
					<td>Tokens/s</td>
					<td>生成吞吐</td>
			</tr>
			<tr>
					<td>GPU 利用率</td>
					<td>并行效率</td>
			</tr>
			<tr>
					<td>KV Cache 命中率</td>
					<td>长上下文效率</td>
			</tr>
			<tr>
					<td>通信开销</td>
					<td>TP / EP 成本</td>
			</tr>
	</tbody>
</table>
<h3 id="推荐对比实验矩阵">推荐对比实验矩阵</h3>
<p>以 8 GPU 为例：</p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>TP</th>
					<th>PP</th>
					<th>DP</th>
					<th>适用目标</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>A</td>
					<td>8</td>
					<td>1</td>
					<td>1</td>
					<td>最低延迟</td>
			</tr>
			<tr>
					<td>B</td>
					<td>4</td>
					<td>2</td>
					<td>1</td>
					<td>显存均衡</td>
			</tr>
			<tr>
					<td>C</td>
					<td>2</td>
					<td>2</td>
					<td>2</td>
					<td>吞吐优先</td>
			</tr>
			<tr>
					<td>D</td>
					<td>1</td>
					<td>1</td>
					<td>8</td>
					<td>最大并发</td>
			</tr>
	</tbody>
</table>
<p>通过固定输入长度与请求并发，对比上述方案即可得到清晰结论。</p>
<h3 id="实际经验结论常见规律">实际经验结论（常见规律）</h3>
<ul>
<li><strong>TP 越大，TTFT 越低，但通信越重</strong></li>
<li><strong>PP 提升显存利用率，但对小 batch 极不友好</strong></li>
<li><strong>DP 是吞吐的唯一线性放大手段</strong></li>
<li><strong>MoE 的 EP 在低并发下可能反而变慢</strong></li>
</ul>
<h2 id="总结">总结</h2>
<p>并行策略没有<strong>最优解</strong>，只有<strong>最适解</strong>，TP / DP / PP / EP 并不是竞争关系，而是：</p>

<blockquote>
  <p><strong>在模型规模、请求模式、硬件拓扑三者约束下的工程权衡。</strong></p>

</blockquote>
<p>vLLM 提供了一个极其适合做<strong>并行策略验证与对比实验</strong>的平台。真正有价值的工作不是“支持了什么并行”，而是：</p>
<ul>
<li>在你的模型</li>
<li>在你的硬件</li>
<li>在你的真实请求负载下</li>
</ul>
<p><strong>验证哪种组合，才是最优解。</strong></p>
]]></content:encoded></item><item><title>大语言模型（LLM）推理加速：预填充（Prefill）和解码（Decode）解耦（PD 分离）</title><link>https://wilsonwu.me/zh/blog/2025/llm-pd-disaggregated/</link><pubDate>Mon, 22 Dec 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/llm-pd-disaggregated/</guid><description>&lt;p&gt;随着大语言模型（LLM）在对话系统、智能助手和 Agent 场景中的广泛落地，推理系统面临的核心挑战已从“能不能跑”转向“&lt;strong&gt;如何以更低延迟、更高吞吐、更稳定的方式运行&lt;/strong&gt;”。在这一背景下，&lt;strong&gt;PD Disaggregated（Prefill / Decode 解耦）&lt;/strong&gt; 逐渐成为大规模在线推理系统中的关键架构思想。&lt;/p&gt;</description><content:encoded><![CDATA[<p>随着大语言模型（LLM）在对话系统、智能助手和 Agent 场景中的广泛落地，推理系统面临的核心挑战已从“能不能跑”转向“<strong>如何以更低延迟、更高吞吐、更稳定的方式运行</strong>”。在这一背景下，<strong>PD Disaggregated（Prefill / Decode 解耦）</strong> 逐渐成为大规模在线推理系统中的关键架构思想。</p>
<p>本文将不依赖任何具体推理框架，从 <strong>模型推理执行流</strong> 的角度，系统阐述什么是 PD Disaggregated、为什么需要它，以及它为 LLM 推理系统带来的核心优势。</p>
<h2 id="llm-推理的两个本质阶段">LLM 推理的两个本质阶段</h2>
<p>所有自回归大语言模型的推理过程，本质上都可以拆解为两个阶段：</p>
<p><img alt="两个阶段" loading="lazy" src="/zh/blog/2025/llm-pd-disaggregated/1-inference-2-phase.png"></p>
<h3 id="prefill预填充阶段">Prefill（预填充阶段）</h3>
<p>Prefill 阶段负责处理用户输入的完整 Prompt，其主要任务包括：</p>
<ul>
<li>对输入序列进行一次完整前向计算；</li>
<li>构建上下文对应的 KV Cache；</li>
<li>生成第一个输出 Token。</li>
</ul>
<p>这一阶段具有如下特征：</p>
<ul>
<li><strong>计算密集型（Compute-bound）</strong></li>
<li>大规模矩阵乘法（GEMM）占主导</li>
<li>序列长度通常较长</li>
<li>对批处理（Batching）高度友好</li>
</ul>
<h3 id="decode解码阶段">Decode（解码阶段）</h3>
<p>Decode 阶段进入逐 Token 生成过程，每一步都会：</p>
<ul>
<li>输入上一个生成的 Token；</li>
<li>读取历史 KV Cache；</li>
<li>生成下一个 Token，直到结束条件满足。</li>
</ul>
<p>其特征与 Prefill 明显不同：</p>
<ul>
<li><strong>访存密集型（Memory-bound）</strong></li>
<li>计算规模小，但 KV Cache 访问频繁</li>
<li>强延迟敏感</li>
<li>执行步数随生成长度线性增长</li>
</ul>
<h2 id="问题根源prefill-与-decode-的异构性">问题根源：Prefill 与 Decode 的“异构性”</h2>
<p>Prefill 与 Decode 并非同一类工作负载，它们在多个维度上呈现出<strong>显著异构性</strong>：</p>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Prefill</th>
					<th>Decode</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>计算模式</td>
					<td>大算子、密集计算</td>
					<td>小算子、频繁访存</td>
			</tr>
			<tr>
					<td>批处理需求</td>
					<td>大 batch 效果最好</td>
					<td>batch 过大反而增加延迟</td>
			</tr>
			<tr>
					<td>延迟容忍度</td>
					<td>相对较高</td>
					<td>极低</td>
			</tr>
			<tr>
					<td>资源瓶颈</td>
					<td>算力</td>
					<td>显存带宽 / KV Cache</td>
			</tr>
	</tbody>
</table>
<p>如果将这两种阶段<strong>混合部署、统一调度</strong>，就会导致资源使用上的结构性低效。</p>
<h2 id="什么是-pd-disaggregated">什么是 PD Disaggregated</h2>
<p><strong>PD Disaggregated（Prefill / Decode 解耦）</strong>，指的是在推理系统架构层面：</p>

<blockquote>
  <p><strong>将 Prefill 与 Decode 视为两类不同的工作负载，并在调度、资源分配甚至部署拓扑上对它们进行解耦设计。</strong></p>

</blockquote>
<p>需要强调的是：</p>
<ul>
<li>这并不是模型结构的拆分；</li>
<li>也不是参数的拆分；</li>
<li>而是<strong>推理执行路径与资源使用方式的解耦</strong>。</li>
</ul>
<p>PD Disaggregated 的核心思想是：
<strong>让 Prefill 为吞吐服务，让 Decode 为延迟服务。</strong></p>
<h2 id="为什么必须进行-pd-解耦">为什么必须进行 PD 解耦</h2>
<h3 id="gpu-利用率的结构性浪费">GPU 利用率的结构性浪费</h3>
<p>混合执行时常见现象包括：</p>
<ul>
<li>Decode 阶段算子太小，GPU 算力无法被充分利用；</li>
<li>Prefill 阶段的大算子又会阻塞 Decode；</li>
<li>结果是整体 GPU 利用率“看起来不低，但效率很差”。</li>
</ul>
<p>PD 解耦后，可以针对不同阶段采用不同的调度和批策略，显著改善有效算力利用。</p>
<h3 id="延迟不可预测尾延迟恶化">延迟不可预测，尾延迟恶化</h3>
<p>在在线推理场景中，Decode 阶段直接决定用户体验：</p>
<ul>
<li>首 Token 延迟（TTFT）</li>
<li>单 Token 延迟（TPOT）</li>
<li>P99 / P999 延迟</li>
</ul>
<p>如果 Decode 被 Prefill 阻塞，哪怕吞吐提升，用户体验也会显著下降。
PD Disaggregated 能从架构层面<strong>隔离延迟敏感路径</strong>。</p>
<h3 id="扩展性受限">扩展性受限</h3>
<p>在混合架构下：</p>
<ul>
<li>Prefill 和 Decode 只能按同一比例扩容；</li>
<li>无法根据实际负载特征独立扩展某一阶段。</li>
</ul>
<p>而真实业务中：</p>
<ul>
<li>Chat、Agent 场景 Decode 占比极高；</li>
<li>离线生成、批处理 Prefill 占比更高。</li>
</ul>
<p>PD 解耦使得 <strong>按阶段独立扩展成为可能</strong>。</p>
<h2 id="pd-disaggregated-的典型架构形态">PD Disaggregated 的典型架构形态</h2>
<p>在工程实践中，PD Disaggregated 通常体现为以下形式之一：</p>
<h3 id="调度级解耦soft-disaggregation">调度级解耦（Soft Disaggregation）</h3>
<ul>
<li>Prefill 与 Decode 共享模型参数；</li>
<li>但在调度层面对二者区别对待；</li>
<li>Decode 拥有更高优先级。</li>
</ul>
<p>这是最轻量、最容易落地的形态。</p>
<h3 id="资源级解耦resource-disaggregation">资源级解耦（Resource Disaggregation）</h3>
<ul>
<li>Prefill 与 Decode 使用不同的 GPU 资源池；</li>
<li>Prefill GPU 偏向算力型；</li>
<li>Decode GPU 偏向显存带宽与 KV Cache 容量。</li>
</ul>
<p>该模式在大规模集群中尤为常见。</p>
<h3 id="服务级解耦service-disaggregation">服务级解耦（Service Disaggregation）</h3>
<ul>
<li>Prefill 与 Decode 成为独立服务；</li>
<li>中间通过 KV Cache 或状态句柄连接；</li>
<li>可独立伸缩、独立调度。</li>
</ul>
<p>这是 PD Disaggregated 的“完全形态”，也是复杂度最高的一种。</p>
<h2 id="pd-disaggregated-带来的核心优势">PD Disaggregated 带来的核心优势</h2>
<h3 id="更高吞吐与更低延迟并存">更高吞吐与更低延迟并存</h3>
<p>传统架构往往需要在吞吐与延迟之间取舍，而 PD 解耦允许：</p>
<ul>
<li>Prefill 极致批量化；</li>
<li>Decode 极致低延迟。</li>
</ul>
<h3 id="更稳定的尾延迟">更稳定的尾延迟</h3>
<p>Decode 不再被大规模 Prefill 阻塞，P99 / P999 延迟显著改善，这对生产系统至关重要。</p>
<h3 id="更合理的资源利用">更合理的资源利用</h3>
<p>不同阶段匹配不同资源类型，避免“用算力型 GPU 跑访存密集任务”这类结构性浪费。</p>
<h3 id="更强的系统演进能力">更强的系统演进能力</h3>
<p>PD Disaggregated 为后续优化打开空间，例如：</p>
<ul>
<li>KV Cache 分层存储；</li>
<li>Decode 专用硬件；</li>
<li>面向 Agent 的长时会话优化。</li>
</ul>
<h2 id="总结">总结</h2>
<p>PD Disaggregated 并不是某个框架的特性，而是<strong>对 LLM 推理本质的抽象与再设计</strong>：</p>
<ul>
<li>Prefill 和 Decode 在计算形态上天然异构；</li>
<li>强行混合只会掩盖效率问题；</li>
<li>解耦是走向规模化、高质量推理服务的必经之路。</li>
</ul>
<p>可以预见，随着模型规模继续增长、应用形态日益复杂，<strong>PD Disaggregated 将从“高级优化”演变为 LLM 推理系统的默认架构假设</strong>。</p>
]]></content:encoded></item><item><title>图像生成进入平台时代：GPT-Image-1.5 在 Microsoft Foundry 中的应用</title><link>https://wilsonwu.me/zh/blog/2025/azure-openai-gpt-image/</link><pubDate>Wed, 17 Dec 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/azure-openai-gpt-image/</guid><description>&lt;p&gt;&lt;img alt="GPT-Image-1.5" loading="lazy" src="https://wilsonwu.me/zh/blog/2025/azure-openai-gpt-image/0-gpt-image-1.5.png"&gt;&lt;/p&gt;
&lt;p&gt;近年来，生成式 AI 技术迅速演进。在自然语言处理之外，&lt;strong&gt;图像生成与编辑能力成为 AI 创新的重要前沿&lt;/strong&gt;。在这一趋势下，OpenAI 推出了 GPT Image 系列模型，在 Azure OpenAI 服务内同样可用。而其中最新发布的 &lt;strong&gt;GPT-Image-1.5&lt;/strong&gt; 可视为图像生成领域的新旗舰，在性能、效率和可控性方面均有显著提升。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="GPT-Image-1.5" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/0-gpt-image-1.5.png"></p>
<p>近年来，生成式 AI 技术迅速演进。在自然语言处理之外，<strong>图像生成与编辑能力成为 AI 创新的重要前沿</strong>。在这一趋势下，OpenAI 推出了 GPT Image 系列模型，在 Azure OpenAI 服务内同样可用。而其中最新发布的 <strong>GPT-Image-1.5</strong> 可视为图像生成领域的新旗舰，在性能、效率和可控性方面均有显著提升。</p>
<h2 id="什么是-gpt-image-15">什么是 GPT-Image-1.5</h2>
<p>GPT-Image-1.5 是 OpenAI 最新发布的<strong>多模态图像生成模型</strong>，属于 GPT Image 系列，目前是效果最佳的版本。与前代（如 GPT-Image-1）相比，它在指令遵循能力、图像质量、生成速度和成本效率方面都有明显提升。</p>
<p>官方定义上，GPT Image 模型是<strong>原生多模态语言模型（natively multimodal），能接收文本和图像输入，并生成图像输出</strong>。在功能上，它不仅支持从纯文本生成图像，还可以对已有图像进行编辑、修改、风格化转换等操作。</p>
<p>简而言之：</p>
<ul>
<li><strong>多模态</strong>：同时理解文本和图像输入。</li>
<li><strong>图像生成与编辑</strong>：支持从文本生成全新图像，也支持对已有图像进行局部修改和增强。</li>
<li><strong>高质量与高效</strong>：相比前代生成更精细、更快速，同时成本更低。</li>
</ul>
<h2 id="核心能力与特点">核心能力与特点</h2>
<h3 id="1-指令遵循及表达精准">1. 指令遵循及表达精准</h3>
<p>GPT-Image-1.5 在理解自然语言提示方面得到显著提升，尤其擅长：</p>
<ul>
<li><strong>对复杂描述的视觉表达</strong>，细节理解更准确</li>
<li><strong>在图像中生成清晰可读的文本内容</strong></li>
<li><strong>遵循用户指令进行定制化修改和组合</strong></li>
</ul>
<p>相比最初的图像生成模型（例如 DALL·E 系列），这类 GPT Image 系列模型对于提示语的响应更直观、可控性更高。</p>
<h3 id="2-图像质量与生成速度提升">2. 图像质量与生成速度提升</h3>
<p>GPT-Image-1.5 的生成速度比上一代快得多（据报道最高可达大约 4 倍提升），这对于需要快速迭代视觉内容的场景（如设计、产品原型等）尤为重要。</p>
<p>此外，图像生成结果在细节、现实感和一致性方面表现更好，尤其是在脸部、纹理、光影等关键元素上有显著进步。</p>
<h3 id="3-编辑与增强功能">3. 编辑与增强功能</h3>
<p>GPT-Image-1.5 同时支持图像编辑，包括：</p>
<ul>
<li><strong>局部修改</strong>：对选中区域进行变更</li>
<li><strong>添加或移除元素</strong></li>
<li><strong>风格化调整与概念转换</strong></li>
</ul>
<p>无需完全重新生成整幅图像，大幅提升工作流效率。</p>
<h3 id="4-成本与效率优化">4. 成本与效率优化</h3>
<p>GPT-Image-1.5 在 API 调用成本上较前代降低约 20%，使得高质量图像生成在更大范围内可持续使用，特别适合企业级批量生成需求。</p>
<h3 id="5-安全性与合规性">5. 安全性与合规性</h3>
<p>作为 Azure OpenAI 服务的一部分，GPT-Image-1.5 的使用受 Azure 的<strong>安全性、合规性和内容过滤体系</strong>保障，支持多种定制化审核与责任使用防护。</p>
<h2 id="在-microsoft-foundry-中部署与使用-gpt-image-15">在 Microsoft Foundry 中部署与使用 GPT-Image-1.5</h2>
<p>随着 <strong>Microsoft Foundry</strong> 的推出（当前为预览版），微软正在将 Azure OpenAI、模型目录、推理端点、Playground 与企业治理能力整合为<strong>统一的 AI 应用构建平台</strong>。在该预览版中，<strong>GPT-Image-1.5 已作为一等模型（first-class model）被直接纳入 Foundry 的模型体系</strong>，无需再通过传统的 “Azure AI Foundry / Azure AI Studio” 路径进行管理。</p>
<h3 id="1-在-microsoft-foundry-中部署-gpt-image-15">1. 在 Microsoft Foundry 中部署 GPT-Image-1.5</h3>
<p>在 Preview 版 Foundry 中，模型部署流程更接近“产品级模型托管”而非传统 Azure 资源配置：</p>
<p>首先进入 <strong>Microsoft Foundry（Preview）控制台</strong>。在左侧选择 <strong>Models</strong>（模型库）。在模型列表中找到 <strong>GPT-Image-1.5</strong>（Image / Multimodal 分类）。</p>
<p><img alt="GPT-Image-1.5" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/1-gpt-image-1.5.png"></p>
<p>打开模型详情页后，点击 <strong>Deploy</strong>，创建一个新的推理部署。</p>
<p><img alt="GPT-Image-1.5 详情" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/2-gpt-image-1.5-detail.png"></p>
<p>选择默认部署，简单选择 <strong>Region</strong> 后即可完成部署，整个过程非常简单：</p>
<p><img alt="选择区域" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/3-choose-region.png"></p>
<p>部署完成后会自动进入 GPT-Image-1.5 的 Playground：</p>
<p><img alt="进入 Playground" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/4-playground.png"></p>
<p>完成后，Foundry 会自动生成：</p>
<ul>
<li>一个可调用的 <strong>Inference Endpoint</strong></li>
<li>与之绑定的身份、配额与监控策略</li>
</ul>
<p>这一流程强调的是<strong>模型即服务（Model as a Service）</strong>，开发者不再需要直接关心底层 Azure OpenAI 资源的复杂配置。</p>
<h3 id="2-使用-foundry-playground-进行图像生成与-prompt-验证">2. 使用 Foundry Playground 进行图像生成与 Prompt 验证</h3>
<p>Microsoft Foundry（Preview）内置了统一的 <strong>Playground</strong>，用于对 GPT-Image-1.5 进行交互式试验。</p>
<p>在 Playground 中，你可以：</p>
<ul>
<li>直接输入文本 Prompt 生成图像</li>
<li>调整图像尺寸、质量等级等参数</li>
<li>快速验证复杂描述、风格化指令的效果</li>
<li>对比不同 Prompt 的生成结果</li>
</ul>
<p>这一能力特别适合以下角色协作使用：</p>
<ul>
<li>产品经理：验证视觉创意可行性</li>
<li>设计师：探索生成风格与构图</li>
<li>工程师：在写代码前固化 Prompt 模板</li>
</ul>
<p>Playground 在 Preview 版 Foundry 中的定位是 <strong>Prompt 即资产</strong>，验证通过的 Prompt 可以直接迁移到生产代码中使用。</p>
<p>生成图片后效果如下：</p>
<p><img alt="生成图片" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/5-gen.png"></p>
<h3 id="3-从-foundry-到应用代码的集成方式">3. 从 Foundry 到应用代码的集成方式</h3>
<p>在应用层面，<strong>Microsoft Foundry（Preview）部署的 GPT-Image-1.5 本质上仍然以标准推理 Endpoint 的形式对外提供服务</strong>：</p>
<ul>
<li>
<p>代码侧只需要关心：</p>
<ul>
<li>Endpoint URL</li>
<li>Deployment Name</li>
<li>身份认证方式</li>
</ul>
</li>
<li>
<p>不再直接依赖模型原始名称或版本号</p>
</li>
</ul>
<p>在企业实践中，常见模式是：</p>
<ul>
<li>开发 / 测试 / 生产环境使用不同 Deployment</li>
<li>模型版本升级（例如 GPT-Image-1.5 → 后续版本）在 Foundry 层完成</li>
<li>应用代码保持不变，仅消费稳定的 Endpoint</li>
</ul>
<p>这种模式显著降低了 <strong>模型演进对业务系统的侵入性</strong>。</p>
<h2 id="在-azure-openai-中如何使用-gpt-image-15">在 Azure OpenAI 中如何使用 GPT-Image-1.5</h2>
<p>你可以通过 Azure OpenAI 提供的 API、SDK 或 Azure AI Foundry 平台来调用 GPT-Image-1.5。基本流程如下：</p>
<h3 id="1-获取访问权限与部署模型">1. 获取访问权限与部署模型</h3>
<p>在模型的详情页可以查看通过代码嗲用 GPT-Image-1.5 的所有信息：</p>
<p><img alt="模型详情" loading="lazy" src="/zh/blog/2025/azure-openai-gpt-image/6-model-detail.png"></p>
<h3 id="2-使用-rest-api-生成图像">2. 使用 REST API 生成图像</h3>
<p>使用 <code>POST</code> 请求调用图像生成端点：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>POST https://&lt;your_resource&gt;.openai.azure.com/openai/deployments/&lt;your_deployment&gt;/images/generations?api-version<span style="color:#f92672">=</span>&lt;api_version&gt;
</span></span><span style="display:flex;"><span>Content-Type: application/json
</span></span><span style="display:flex;"><span>api-key: &lt;your_api_key&gt;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;prompt&#34;</span>: <span style="color:#e6db74">&#34;一位穿宇航服的猫在火星表面散步&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;size&#34;</span>: <span style="color:#e6db74">&#34;1024x1024&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;n&#34;</span>: 1,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;quality&#34;</span>: <span style="color:#e6db74">&#34;high&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span></code></pre></div><p>常见参数说明：</p>
<ul>
<li><code>prompt</code>: 文本描述</li>
<li><code>size</code>: 图像尺寸（如 1024x1024）</li>
<li><code>n</code>: 生成图像数量</li>
<li><code>quality</code>: 生成质量（low/medium/high）</li>
</ul>
<h3 id="3-图像编辑若支持">3. 图像编辑（若支持）</h3>
<p>上传已有图片并通过 prompt 指令结合修改：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>POST /images/edits
</span></span></code></pre></div><p>此类调用可以指定 mask、输入图片等字段来控制局部变化。</p>
<h3 id="4-sdk-与开发环境">4. SDK 与开发环境</h3>
<p>Azure 支持多种语言 SDK（例如 Python、C#），可借助 Azure Identity 认证库、安全地调用 API 并处理生成结果。</p>
<h2 id="总结">总结</h2>
<p>GPT-Image-1.5 在图像生成领域代表了下一阶段 AI 创意工具的能力边界。它在指令理解、生成速度、图像质量、成本效率与多模态处理方面均显著提升，为开发者和企业提供了一套强大且可扩展的视觉 AI 解决方案。</p>
<p>对于希望构建中长期 AI 能力平台的团队而言，Foundry + GPT-Image-1.5 是比直接裸用 API 更具工程化和可持续性的选择。</p>
<p>Azure OpenAI 服务结合了模型能力与企业级安全合规，让 GPT-Image-1.5 不仅适用于实验探索，也能支撑生产级应用。</p>
<p>如果你正在构建需要视觉理解或自动创意生成的应用，无论是批量生成还是交互式体验，GPT-Image-1.5 都值得深入实践。</p>
]]></content:encoded></item><item><title>基于 Azure OpenAI 构建智能地址解析 Chrome / Edge 浏览器扩展插件</title><link>https://wilsonwu.me/zh/blog/2025/edge-extension-with-azure-ai/</link><pubDate>Mon, 15 Dec 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/edge-extension-with-azure-ai/</guid><description>&lt;p&gt;在电商和物流领域，有一个不起眼但极其耗时的痛点：&lt;strong&gt;将复杂的地址格式正确的输入到物流系统固定的字段中&lt;/strong&gt;。客户发来的地址格式千奇百怪，有时是逗号分隔，有时连成一行，顺序也完全随机，经常还缺少省份或州等关键信息。&lt;/p&gt;</description><content:encoded><![CDATA[<p>在电商和物流领域，有一个不起眼但极其耗时的痛点：<strong>将复杂的地址格式正确的输入到物流系统固定的字段中</strong>。客户发来的地址格式千奇百怪，有时是逗号分隔，有时连成一行，顺序也完全随机，经常还缺少省份或州等关键信息。</p>
<p>本文我想分享我是如何构建 <strong>Auto Address</strong> 的，这是一个利用 <strong>Azure OpenAI</strong> 的强大能力来解决这个问题的 Chrome 及 Edge 浏览器扩展插件，其界面和使用方式及其简单。</p>
<p><img alt="主界面" loading="lazy" src="/zh/blog/2025/edge-extension-with-azure-ai/1-main-interface.png"></p>
<h2 id="问题非结构化数据">问题：非结构化数据</h2>
<p>想象一下收到这段文本：</p>

<blockquote>
  <p>&ldquo;张三 13800138000 广东省深圳市南山区科技园南区R2-B三楼&rdquo;</p>

</blockquote>
<p>为了发货，你需要将其拆解为：</p>
<ul>
<li><strong>姓名</strong>: 张三</li>
<li><strong>电话</strong>: 13800138000</li>
<li><strong>地址</strong>: 科技园南区R2-B三楼</li>
<li><strong>城市</strong>: 深圳市</li>
<li><strong>省份</strong>: 广东省</li>
<li><strong>国家</strong>: 中国</li>
</ul>
<p>手动处理成百上千个订单既繁琐又容易出错。正则表达式（Regex）可以处理某些情况，但当格式变化或用户有错别字时，它们往往无能为力。</p>
<h2 id="解决方案llm-作为解析器">解决方案：LLM 作为解析器</h2>
<p><img alt="Auto Address" loading="lazy" src="/zh/blog/2025/edge-extension-with-azure-ai/2-auto-address.png"></p>
<p>像 Azure OpenAI（如：GPT-4 或 GPT-5）这样的大语言模型在理解上下文方面表现出色。它们能区分街道名称和城市名称，不是因为死板的规则，而是因为它们理解地址的<strong>语义</strong>。</p>
<p>我选择 <strong>Azure OpenAI</strong> 进行此项目主要有两个原因：</p>
<ol>
<li><strong>企业级隐私</strong>：发送到 Azure OpenAI 的数据不会用于训练公共模型。</li>
<li><strong>可靠性</strong>：稳定的正常运行时间和性能。</li>
</ol>
<h2 id="技术揭秘它是如何工作的">技术揭秘：它是如何工作的</h2>
<p>插件的核心逻辑出奇地简单。它获取用户的输入，并将其连同一组非常具体的指令发送到 Azure OpenAI API。</p>
<h3 id="1-system-prompt-系统提示词">1. System Prompt (系统提示词)</h3>
<p>“魔法”在于系统提示词。我们需要确切地告诉模型要做什么，更重要的是，如何格式化输出。</p>
<p>这是代码中实际使用的提示词：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">role</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;system&#34;</span>, 
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">content</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;You are an address parser. Return ONLY a raw JSON object (no markdown code blocks) with keys: name, province, city, address, zip_code, country. If province/state is missing in the input, infer it from the city. Do not separate house number, include it in the address field.&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>这里使用的关键技巧：</strong></p>
<ul>
<li><strong>角色定义</strong>: &ldquo;You are an address parser.&rdquo; (你是一个地址解析器)</li>
<li><strong>输出约束</strong>: &ldquo;Return ONLY a raw JSON object&rdquo;. (仅返回原始 JSON 对象)。这防止模型添加像 &ldquo;这是您的 JSON：&rdquo; 这样的废话。</li>
<li><strong>Schema 定义</strong>: 我们明确列出了我们想要的键 (<code>name</code>, <code>province</code> 等)。</li>
<li><strong>逻辑注入</strong>: &ldquo;If province/state is missing&hellip; infer it.&rdquo; (如果省/州缺失&hellip;推断它)。这将逻辑处理卸载给了 AI。</li>
</ul>
<h3 id="2-api-调用">2. API 调用</h3>
<p>扩展使用标准的 <code>fetch</code> API 与您的 Azure 部署进行通信。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">url</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">baseUrl</span><span style="color:#e6db74">}</span><span style="color:#e6db74">openai/deployments/</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">azureDeployment</span><span style="color:#e6db74">}</span><span style="color:#e6db74">/chat/completions?api-version=2024-05-01-preview`</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">response</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">url</span>, {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;POST&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Type&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;application/json&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;api-key&#39;</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">azureApiKey</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">body</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">stringify</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">messages</span><span style="color:#f92672">:</span> [ <span style="color:#75715e">/* ... prompt ... */</span> ],
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">temperature</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0.1</span> <span style="color:#75715e">// 以获得一致、确定的结果
</span></span></span><span style="display:flex;"><span>  })
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>我们使用低 <code>temperature</code> (0.1)，因为我们不需要创造力；我们需要精确度。</p>
<h3 id="3-智能省份推断双重检查">3. 智能省份推断（“双重检查”）</h3>
<p>有时，即使是最好的模型也会遗漏细节，或者输入太模糊。一个常见的问题是缺少省/州信息。</p>
<p>我在 <code>popup.js</code> 中实现了一个“回退”机制。如果解析结果有城市但没有省份，扩展程序会发起<strong>第二次定向 API 调用</strong>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> ((<span style="color:#f92672">!</span><span style="color:#a6e22e">addr</span>.<span style="color:#a6e22e">province</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">addr</span>.<span style="color:#a6e22e">province</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;&#39;</span>) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">addr</span>.<span style="color:#a6e22e">city</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// ... 二次调用 ...
</span></span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">messages</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">role</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;system&#34;</span>, 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">content</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;You are a geography helper. Return ONLY the province/state name for the given city. Do not return JSON, just the name.&#34;</span>
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">role</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;user&#34;</span>, 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">content</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`Which province/state is the city &#34;</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">addr</span>.<span style="color:#a6e22e">city</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">countryContext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">?`</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>这确保了在用户看到数据之前，数据尽可能完整。</p>
<h2 id="chrome--edge-扩展架构">Chrome / Edge 扩展架构</h2>
<h3 id="manifest-v3-与-side-panel-侧边栏">Manifest V3 与 Side Panel (侧边栏)</h3>
<p>该扩展基于 <strong>Manifest V3</strong> 构建，这是 Chrome / Edge 扩展平台的最新迭代。</p>
<p>我没有使用点击后就会关闭的传统弹出窗口，而是使用了 <strong>Side Panel API</strong> (<code>&quot;permissions&quot;: [&quot;sidePanel&quot;]</code>)。这允许扩展程序停留在浏览器窗口的一侧。这对工作流程至关重要：用户可以在左侧查看发货单页面，同时在右侧查看解析出的地址数据。</p>
<h3 id="隐私优先">隐私优先</h3>
<p>首先使用前需要通过配置界面设置您要使用的 Azure OpenAI 信息：</p>
<p><img alt="配置界面" loading="lazy" src="/zh/blog/2025/edge-extension-with-azure-ai/3-options-interface.png"></p>
<p>主要的设计决策是<strong>零中间服务器</strong>。</p>
<ul>
<li>扩展程序使用 <code>chrome.storage.sync</code> 将您的 API 密钥本地存储在浏览器中。</li>
<li>请求直接从<strong>您的浏览器</strong> -&gt; <strong>Azure OpenAI</strong>。</li>
<li>扩展开发者不收集任何数据。</li>
</ul>
<h2 id="结语">结语</h2>
<p>通过结合 Chrome / Edge 扩展的灵活性和 Azure OpenAI 的智能，我们可以将繁琐的数据录入任务转化为一键操作。</p>
<p>代码已开源。欢迎查看、Fork 并构建您自己的 AI 驱动工具！</p>
<p><strong>GitHub Repo</strong>: <a href="https://github.com/wilsonwu/autoaddr">https://github.com/wilsonwu/autoaddr</a></p>
<p>如果您使用的是 Microsoft Edge 浏览器，可以通过扩展插件市场安装本插件的最新版本：<a href="https://microsoftedge.microsoft.com/addons/detail/auto-address-smart-ship/ieaphpecggbaiineilddheaghcolkbba">Edge 浏览器扩展插件</a></p>
]]></content:encoded></item><item><title>玩转大语言模型：为初学者扫清大语言模型（LLM）架构盲区</title><link>https://wilsonwu.me/zh/blog/2025/llm-architecture/</link><pubDate>Wed, 10 Dec 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/llm-architecture/</guid><description>&lt;p&gt;过去五年，大语言模型（Large Language Models, LLMs）的发展几乎完全重塑了人工智能的技术版图。从 GPT 到 LLaMA，从 Transformer 到 Mixture-of-Experts（MoE），从单体模型到大规模分布式参数服务器体系，架构演进直接推动了能力跃迁。&lt;/p&gt;</description><content:encoded><![CDATA[<p>过去五年，大语言模型（Large Language Models, LLMs）的发展几乎完全重塑了人工智能的技术版图。从 GPT 到 LLaMA，从 Transformer 到 Mixture-of-Experts（MoE），从单体模型到大规模分布式参数服务器体系，架构演进直接推动了能力跃迁。</p>
<p>本文将从架构层面系统梳理 LLM 的主流技术路径，并从应用视角分析其优劣及适配场景，为研发与业务团队提供技术选型参考。同时也希望可以为初学者们打开迈入大模型世界的一条门缝！</p>
<h2 id="大语言模型的主流架构体系">大语言模型的主流架构体系</h2>
<p><img alt="LLM" loading="lazy" src="/zh/blog/2025/llm-architecture/1-llm.png"></p>
<p>目前的 LLM 架构，大概可以分为这么几类：</p>
<ol>
<li><strong>传统 Transformer（Dense Transformer）</strong></li>
<li><strong>Mixture-of-Experts（MoE）架构</strong></li>
<li><strong>多模态扩展架构（Vision-Language / Audio-Language）</strong></li>
<li><strong>检索增强生成（RAG）与混合推理架构</strong></li>
<li><strong>基于代理（Agentic）架构的系统级 LLM</strong></li>
<li><strong>超大规模分布式训练架构（例如 Parameter Server / Fully Sharded）</strong></li>
</ol>
<p>下面逐一展开。</p>
<h2 id="dense-transformer">传统 Transformer：主流 LLM 的基础架构</h2>
<p>Dense Transformer 是绝大多数 LLM 的基座，包括 GPT 系列、LLaMA、Mistral、Qwen 等。</p>
<h3 id="dense-transformer-architecture">架构</h3>
<p><img alt="Dense Transformer" loading="lazy" src="/zh/blog/2025/llm-architecture/2-dense-transformer.png"></p>
<p>简单来说，就是“大力出奇迹”：</p>
<ul>
<li>所有 token 通过全连接注意力进行计算；</li>
<li>全部参数在每次前向推理中都会被激活；</li>
<li>结构相对规则，训练稳定；</li>
<li>模型规模靠堆叠层数与扩大 hidden size 线性提升。</li>
</ul>
<h3 id="dense-transformer-advantages">优势</h3>
<ul>
<li>推理路径稳定、可预测；</li>
<li>训练过程成熟，生态支持丰富；</li>
<li>对所有任务通用，不需要额外路由机制。</li>
</ul>
<h3 id="dense-transformer-limitations">局限</h3>
<ul>
<li>参数规模大时推理成本高（全部参数激活）；</li>
<li>扩展模型能力的成本几乎与参数规模线性相关。</li>
</ul>
<h3 id="dense-transformer-scenarios">应用场景</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>适用性</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>通用对话</td>
					<td>高</td>
			</tr>
			<tr>
					<td>编程、数学推理</td>
					<td>高</td>
			</tr>
			<tr>
					<td>对实时需求强的应用（低延迟）</td>
					<td>较低</td>
			</tr>
			<tr>
					<td>资源受限设备（边缘推理）</td>
					<td>一般，可用量化缓解</td>
			</tr>
	</tbody>
</table>
<p>Dense 模型依然是绝大多数企业首次采用 LLM 时的首选。</p>
<h2 id="moe">Mixture-of-Experts（MoE）：高效扩展能力的关键技术</h2>
<p>MoE 是当前最重要的新架构之一，代表模型包括：</p>
<ul>
<li>Google Switch Transformer</li>
<li>DeepSeek-V2/V3</li>
<li>Mixtral 系列</li>
<li>GPT-4 技术论文暗示其有 MoE 结构成分</li>
</ul>
<h3 id="moe-architecture">架构</h3>
<p>MoE 不再让所有参数参与每次计算，而是通过<strong>路由（Routing）</strong>机制让每个 token 只激活若干专家层（Expert）。</p>
<p><img alt="Token Choice Routing" loading="lazy" src="/zh/blog/2025/llm-architecture/3-moe-token-choice-routing.png"></p>
<p><img alt="Expert Choice Routing" loading="lazy" src="/zh/blog/2025/llm-architecture/4-moe-expert-choice-routing.png"></p>
<p>举个例子，一个 300B 参数的庞然大物，实际每次推理可能只动用了 30B 的参数。</p>
<h3 id="moe-advantages">优势</h3>
<ul>
<li><strong>训练扩展性极强</strong>：参数规模可以以更低成本提升到数千亿甚至上万亿。</li>
<li><strong>推理成本显著降低</strong>：激活专家减少推理 FLOPs。</li>
<li><strong>擅长复杂推理与多任务能力</strong>。</li>
</ul>
<h3 id="moe-limitations">局限</h3>
<ul>
<li>路由训练复杂，稳定性需求更高；</li>
<li>推理并行规模更大，对系统工程要求高；</li>
<li>不适合极端低延迟场景，因为专家调度带来额外开销。</li>
</ul>
<h3 id="moe-scenarios">应用场景</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>适用性</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>多领域任务（代码/数学/语言混合）</td>
					<td>极高</td>
			</tr>
			<tr>
					<td>超大规模模型（百亿+）</td>
					<td>极高</td>
			</tr>
			<tr>
					<td>边缘设备、小型模型</td>
					<td>低</td>
			</tr>
			<tr>
					<td>多租户大规模在线推理</td>
					<td>依实现而定</td>
			</tr>
	</tbody>
</table>
<p>MoE 已成为大型商业 LLM 的主流趋势。</p>
<h2 id="multimodal">多模态 LLM 架构：模型能力向感知延伸</h2>
<p>代表模型：GPT-4o、Gemini、Qwen-VL、LLaVA、Yi-VL</p>
<h3 id="multimodal-architecture">架构</h3>
<p>多模态 LLM 通过在 Transformer 基座上引入额外编码器，将视觉、音频、视频等信号映射为 token 表示。</p>
<p><img alt="多模态 LLM 架构" loading="lazy" src="/zh/blog/2025/llm-architecture/5-multimodal.png"></p>
<p>典型结构通常包含：</p>
<ul>
<li>视觉编码器（Vision Encoder，比如 ViT）</li>
<li>投影层（Projection Layer）</li>
<li>统一的 Transformer</li>
<li>多模态注意力机制</li>
</ul>
<h3 id="multimodal-advantages">优势</h3>
<ul>
<li>输入能力扩展显著：图像、音频、视频、文档统合处理；</li>
<li>极适合作为通用 AI Agent 的感知模块。</li>
</ul>
<h3 id="multimodal-limitations">局限</h3>
<ul>
<li>成本比纯文本 LLM 更高；</li>
<li>多模态数据构建成本大；</li>
<li>输出的稳定性与高保真图像/视频理解仍有上限。</li>
</ul>
<h3 id="multimodal-scenarios">应用场景</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>适用性</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>OCR / 文档理解</td>
					<td>极高</td>
			</tr>
			<tr>
					<td>多模态搜索</td>
					<td>高</td>
			</tr>
			<tr>
					<td>视频理解</td>
					<td>中（依赖模型）</td>
			</tr>
			<tr>
					<td>紧凑型设备部署</td>
					<td>低</td>
			</tr>
	</tbody>
</table>
<p>随着 GPT-4o 全模态输入的普及，多模态 LLM 已成为企业 AI 转型的必备能力层。</p>
<h2 id="hybridrag">RAG + LLM 混合架构：以知识增强为核心的系统形态</h2>
<p>RAG（Retrieval-Augmented Generation）通过将外部知识库融合到推理中解决 LLM 本身无法持久更新和存储大量事实的问题。</p>
<h3 id="hybridrag-architecture">架构</h3>
<p>由三个核心组件构成：</p>
<ul>
<li>向量数据库（Retriever）</li>
<li>文档预处理管线（Chunking, Indexing）</li>
<li>核心 LLM（Generator）</li>
</ul>
<p><img alt="混合架构" loading="lazy" src="/zh/blog/2025/llm-architecture/6-hybridrag.png"></p>
<h3 id="hybridrag-advantages">优势</h3>
<ul>
<li>模型无需重新训练即可持续更新知识；</li>
<li>显著增强长尾知识与事实性能力；</li>
<li>数据可控，便于企业级治理。</li>
</ul>
<h3 id="hybridrag-limitations">局限</h3>
<ul>
<li>依赖检索质量；</li>
<li>对 prompt 工程与 pipeline 工程化要求高；</li>
<li>多跳检索在 latency 上存在开销。</li>
</ul>
<h3 id="hybridrag-scenarios">应用场景</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>适用性</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>企业知识库问答</td>
					<td>极高</td>
			</tr>
			<tr>
					<td>法律、医疗等高准确性应用</td>
					<td>高</td>
			</tr>
			<tr>
					<td>实时 LLM 应用（对时延敏感）</td>
					<td>较低</td>
			</tr>
	</tbody>
</table>
<p>RAG 是企业级 LLM 落地中最常见的架构。</p>
<h2 id="agentic">基于代理的架构：基于智能体的系统级 LLM 架构</h2>
<p>代表：AutoGPT、GPT-o1、GPT-o3、DeepSeek-R1、自主推理 Agent Framework</p>
<p>随着模型具备规划（Planning）、执行（Acting）和反思（Reflection）能力，Agent 架构正在成为应用层主流。</p>
<h3 id="agentic-architecture">架构</h3>
<ul>
<li>多工具整合（Tool Use）</li>
<li>工作流管理</li>
<li>多轮子任务规划</li>
<li>Memory 与外部状态管理</li>
</ul>
<p><img alt="智能体架构" loading="lazy" src="/zh/blog/2025/llm-architecture/7-agentic.png"></p>
<h3 id="agentic-advantages">优势</h3>
<ul>
<li>可执行复杂任务链；</li>
<li>强化模型可控性；</li>
<li>工具能力扩展模型本身的局限。</li>
</ul>
<h3 id="agentic-limitations">局限</h3>
<ul>
<li>系统复杂度显著增加；</li>
<li>可靠性、可追踪性和可验证性要求高；</li>
<li>调度能力直接影响实际效果。</li>
</ul>
<h3 id="agentic-scenarios">应用场景</h3>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>适用性</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>自动化运营</td>
					<td>极高</td>
			</tr>
			<tr>
					<td>数据分析助手</td>
					<td>高</td>
			</tr>
			<tr>
					<td>多步骤计算任务</td>
					<td>高</td>
			</tr>
			<tr>
					<td>回答型问答</td>
					<td>一般</td>
			</tr>
			<tr>
					<td>超低延迟场景</td>
					<td>不适合</td>
			</tr>
	</tbody>
</table>
<p>Agent 架构代表了 LLM 应用的未来方向。</p>
<h2 id="distributed-training">分布式训练架构：支撑万亿参数模型的底层工程</h2>
<p><img alt="分布式训练" loading="lazy" src="/zh/blog/2025/llm-architecture/8-distributed-training.png"></p>
<p>这类方案通常没有特定的架构模型，依照不同的需求，技术厂商会演进出各类方案，比如：</p>
<ul>
<li>ZeRO / FSDP（Fully Sharded Data Parallel）</li>
<li>Megatron-LM（Tensor / Pipeline 并行）</li>
<li>DeepSpeed</li>
<li>Colossal-AI</li>
<li>Parameter Server（SageMaker、Ray、DeepSeek 自研）</li>
</ul>
<h3 id="distributed-training">优势</h3>
<ul>
<li>高效训练超大模型；</li>
<li>利用 GPU 集群最大化吞吐；</li>
<li>优化显存占用。</li>
</ul>
<h3 id="distributed-training">局限</h3>
<ul>
<li>工程复杂度极高；</li>
<li>部署成本昂贵；</li>
<li>对 GPU 集群架构高度依赖。</li>
</ul>
<h3 id="distributed-training">应用场景</h3>
<p>主要适用于自研大模型或千卡级 GPU 训练任务的企业或科研团队。</p>
<h2 id="各架构侧重点及优势对比">各架构侧重点及优势对比</h2>
<p>以下为一张综合对比表：</p>
<table>
	<thead>
			<tr>
					<th>架构类型</th>
					<th>优势</th>
					<th>劣势</th>
					<th>应用重点</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Dense Transformer</td>
					<td>稳定、成熟、推理链简单</td>
					<td>成本随规模线性提升</td>
					<td>通用模型、边缘部署</td>
			</tr>
			<tr>
					<td>MoE</td>
					<td>高效扩展、高性价比</td>
					<td>路由复杂、工程门槛高</td>
					<td>大规模模型、混合任务</td>
			</tr>
			<tr>
					<td>多模态 LLM</td>
					<td>输入能力强</td>
					<td>成本高、训练复杂</td>
					<td>文档/图像/视频/音频场景</td>
			</tr>
			<tr>
					<td>RAG</td>
					<td>强事实性、可更新</td>
					<td>检索依赖高</td>
					<td>企业知识库、法律/医疗</td>
			</tr>
			<tr>
					<td>Agent</td>
					<td>工具能力强、任务链执行</td>
					<td>系统复杂</td>
					<td>自动化任务、数据分析</td>
			</tr>
			<tr>
					<td>分布式架构</td>
					<td>支撑万亿模型</td>
					<td>成本与复杂度高</td>
					<td>训练基建</td>
			</tr>
	</tbody>
</table>
<h2 id="如何选择适合的-llm-架构">如何选择适合的 LLM 架构？</h2>
<p>基于上述介绍的 LLM 架构，我们初步了解了大语言模型本身的架构以及叠加一些应用场景的方案架构，而在 LLM 结合各个行业的真实应用场景中，架构远远不止这些，结合已有的计算机系统架构，LLM 的架构也在不断进化和丰富中，下面简单根据应用需求，推荐如下路径：</p>
<h3 id="目标是开发一个企业级问答系统">目标是开发一个企业级问答系统</h3>
<p>Dense Transformer + 强化 RAG：可靠、易控、低成本、可持续更新。</p>
<p><img alt="问答系统" loading="lazy" src="/zh/blog/2025/llm-architecture/9-qa-system.png"></p>
<h3 id="目标是打造一个全能基础大模型系统">目标是打造一个全能基础大模型系统</h3>
<p>MoE 是优选：性能与性价比兼得。</p>
<p><img alt="全能大模型系统" loading="lazy" src="/zh/blog/2025/llm-architecture/10-all-in-one-llm.png"></p>
<h3 id="目标是构建文档图像视频-ai-系统">目标是构建文档/图像/视频 AI 系统</h3>
<p>多模态 LLM：例如 GPT-4o、Qwen-VL、Gemini。</p>
<p><img alt="多模态架构" loading="lazy" src="/zh/blog/2025/llm-architecture/11-mm-arch.png"></p>
<h3 id="需要自动化任务执行">需要自动化任务执行</h3>
<p>基于代理的架构：模型本身不需要特别大，但需要良好的工具接口和规划能力。</p>
<p><img alt="自动化任务系统" loading="lazy" src="/zh/blog/2025/llm-architecture/12-llm-and-agent.png"></p>
<h3 id="需要训练和运行自研模型">需要训练和运行自研模型</h3>
<p>FSDP / Megatron / PS-based 分布式架构，这类需求其实可选范围非常广，由于是训推整体方案，只要沿用市面成型的一体方案即可，不需要花大量时间在训练和推理框架之间做兼容。</p>
<p><img alt="训推系统" loading="lazy" src="/zh/blog/2025/llm-architecture/13-training-inference.png"></p>
<h2 id="总结">总结</h2>
<p>大语言模型架构的演进已经进入百花齐放阶段。Dense 模型提供稳定基座，MoE 架构带来成本与能力的突破，多模态扩展了 AI 的感知边界，而 RAG 与 Agent 架构将 LLM 推向真正可控、可执行的智能体时代。</p>
<p>对企业而言，选对架构比盲目追求“更大模型”更重要。依据实际需求、工程能力和预算进行架构选择，既能优化效果，也能确保长期可维护性。</p>
]]></content:encoded></item><item><title>Fara-7B：微软推出的高效电脑操作代理模型</title><link>https://wilsonwu.me/zh/blog/2025/microsoft-fara-7b/</link><pubDate>Fri, 28 Nov 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/microsoft-fara-7b/</guid><description>介绍微软最新发布的 Fara-7B 模型，这是一个专为电脑操作设计的 7B 参数模型，以及如何通过 vLLM 进行部署和使用。</description><content:encoded><![CDATA[<p><img alt="Fara-7B" loading="lazy" src="/zh/blog/2025/microsoft-fara-7b/1-fara-7b.png"></p>
<p>微软研究院最近发布了 <strong>Fara-7B</strong>，这是一款专为电脑操作（Computer Use）设计的代理小语言模型（Agentic SLM）。与传统的聊天模型不同，Fara-7B 旨在通过像人类一样使用鼠标和键盘来完成任务。</p>
<h2 id="什么是-fara-7b">什么是 Fara-7B？</h2>
<p>Fara-7B 是一个拥有 70 亿参数的模型，基于 <a href="https://arxiv.org/abs/2502.13923">Qwen2.5-VL-7B</a> 构建。它的主要特点包括：</p>
<ul>
<li><strong>视觉感知</strong>：它通过视觉感知网页（截图）来操作，不需要依赖辅助功能树（Accessibility Trees）或额外的解析模型。</li>
<li><strong>高效紧凑</strong>：尽管只有 7B 参数，但在同类模型中实现了最先进的性能，甚至在某些基准测试中可以与更大的模型竞争。</li>
<li><strong>端侧运行</strong>：由于体积小，它可以在设备上本地运行，从而降低延迟并提高隐私安全性。</li>
</ul>
<p><img alt="使用" loading="lazy" src="/zh/blog/2025/microsoft-fara-7b/2-usage.png"></p>
<h2 id="核心技术">核心技术</h2>
<p>Fara-7B 的训练利用了微软开发的合成数据生成管道，该管道基于 <a href="https://www.microsoft.com/en-us/research/articles/magentic-one-a-generalist-multi-agent-system-for-solving-complex-tasks/">Magentic-One</a> 框架。它通过“观察-思考-行动”（observe-think-act）的循环来执行任务。</p>
<p>在每个步骤中，模型会接收：</p>
<ol>
<li>用户指令</li>
<li>完整的操作历史</li>
<li>最近的三张屏幕截图</li>
</ol>
<p>然后，它会输出一个推理信息（“思考”下一步行动），随后调用工具（如 <code>click(x,y)</code>、<code>type()</code> 等）来执行操作。</p>
<p><img alt="工作流程" loading="lazy" src="/zh/blog/2025/microsoft-fara-7b/3-workflow.png"></p>
<h2 id="性能表现">性能表现</h2>
<p>在 WebVoyager、Online-Mind2Web 等基准测试中，Fara-7B 展现了强大的性能。例如在 WebVoyager 上，它的任务成功率达到了 73.5%，超过了 UI-TARS-1.5-7B (66.4%) 和 GPT-4o (SoM Agent, 65.1%)。</p>
<h2 id="如何通过-vllm-使用-fara-7b">如何通过 vLLM 使用 Fara-7B</h2>
<p>Fara-7B 已经发布在 <a href="https://huggingface.co/microsoft/Fara-7B">Hugging Face</a> 上。由于它是基于 Qwen2.5-VL 的，我们可以使用 vLLM 来高效地部署和推理。</p>
<h3 id="安装-vllm">安装 vLLM</h3>
<p>首先确保你安装了最新版本的 <code>vllm</code>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pip install vllm
</span></span></code></pre></div><h3 id="python-代码示例">Python 代码示例</h3>
<p>以下是一个使用 vLLM 加载 Fara-7B 并进行推理的 Python 脚本示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> vllm <span style="color:#f92672">import</span> LLM, SamplingParams
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> PIL <span style="color:#f92672">import</span> Image
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> requests
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> io <span style="color:#f92672">import</span> BytesIO
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_fara_7b</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 模型名称</span>
</span></span><span style="display:flex;"><span>    model_name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;microsoft/Fara-7B&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 初始化 LLM</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Fara-7B 基于 Qwen2.5-VL，因此使用类似的配置</span>
</span></span><span style="display:flex;"><span>    llm <span style="color:#f92672">=</span> LLM(
</span></span><span style="display:flex;"><span>        model<span style="color:#f92672">=</span>model_name,
</span></span><span style="display:flex;"><span>        trust_remote_code<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        max_model_len<span style="color:#f92672">=</span><span style="color:#ae81ff">4096</span>,  <span style="color:#75715e"># 根据显存大小调整</span>
</span></span><span style="display:flex;"><span>        limit_mm_per_prompt<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;image&#34;</span>: <span style="color:#ae81ff">1</span>},
</span></span><span style="display:flex;"><span>        mm_processor_kwargs<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;min_pixels&#34;</span>: <span style="color:#ae81ff">28</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">28</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;max_pixels&#34;</span>: <span style="color:#ae81ff">1280</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">28</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">28</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 加载示例图片（这里仅作演示，实际使用中应为屏幕截图）</span>
</span></span><span style="display:flex;"><span>    image_url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg&#34;</span>
</span></span><span style="display:flex;"><span>    response <span style="color:#f92672">=</span> requests<span style="color:#f92672">.</span>get(image_url)
</span></span><span style="display:flex;"><span>    image <span style="color:#f92672">=</span> Image<span style="color:#f92672">.</span>open(BytesIO(response<span style="color:#f92672">.</span>content))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 构建 Prompt</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 注意：Fara-7B 使用特定的 Prompt 格式</span>
</span></span><span style="display:flex;"><span>    prompt <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&lt;|im_start|&gt;system</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">You are a helpful assistant.&lt;|im_end|&gt;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&lt;|im_start|&gt;user</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&lt;|vision_start|&gt;&lt;|image_pad|&gt;&lt;|vision_end|&gt;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;Describe this image in detail.&lt;|im_end|&gt;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&lt;|im_start|&gt;assistant</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 准备输入</span>
</span></span><span style="display:flex;"><span>    inputs <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;prompt&#34;</span>: prompt,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;multi_modal_data&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;image&#34;</span>: image
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 采样参数</span>
</span></span><span style="display:flex;"><span>    sampling_params <span style="color:#f92672">=</span> SamplingParams(temperature<span style="color:#f92672">=</span><span style="color:#ae81ff">0.2</span>, max_tokens<span style="color:#f92672">=</span><span style="color:#ae81ff">512</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># 生成结果</span>
</span></span><span style="display:flex;"><span>    outputs <span style="color:#f92672">=</span> llm<span style="color:#f92672">.</span>generate([inputs], sampling_params<span style="color:#f92672">=</span>sampling_params)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> o <span style="color:#f92672">in</span> outputs:
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;Generated text: </span><span style="color:#e6db74">{</span>o<span style="color:#f92672">.</span>outputs[<span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>text<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    run_fara_7b()
</span></span></code></pre></div><h3 id="命令行启动-api-服务">命令行启动 API 服务</h3>
<p>如果你想启动一个兼容 OpenAI API 的服务，可以使用以下命令：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>vllm serve microsoft/Fara-7B <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --trust-remote-code <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --limit-mm-per-prompt image<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --mm-processor-kwargs <span style="color:#e6db74">&#39;{&#34;min_pixels&#34;: 784, &#34;max_pixels&#34;: 1003520}&#39;</span>
</span></span></code></pre></div><p>注意：<code>min_pixels</code> 对应 28x28，<code>max_pixels</code> 对应 1280x28x28。</p>
<h2 id="总结">总结</h2>
<p>Fara-7B 是微软在代理小模型领域的一次重要尝试，它证明了小模型在复杂的电脑操作任务中也能有出色的表现。通过开源权重，开发者可以更轻松地探索和构建基于 GUI 的自动化代理。</p>
]]></content:encoded></item><item><title>快速为 Github Copilot 配置最新的 Gemini 3 Pro 模型加速开发体验</title><link>https://wilsonwu.me/zh/blog/2025/use-gemini-3-pro-in-github-copilot/</link><pubDate>Thu, 20 Nov 2025 00:00:00 -0500</pubDate><guid>https://wilsonwu.me/zh/blog/2025/use-gemini-3-pro-in-github-copilot/</guid><description>&lt;p&gt;随着 AI 辅助编程工具的不断进化，GitHub Copilot 也在持续引入更多强大的模型供开发者选择。最近，GitHub Copilot 宣布支持 Google 最新的 &lt;strong&gt;Gemini 3 Pro&lt;/strong&gt; 模型（预览版）。作为一个每天都在使用 Copilot 的开发者，我第一时间进行了体验，发现它在逻辑推理和长上下文理解方面有着令人惊喜的表现。&lt;/p&gt;</description><content:encoded><![CDATA[<p>随着 AI 辅助编程工具的不断进化，GitHub Copilot 也在持续引入更多强大的模型供开发者选择。最近，GitHub Copilot 宣布支持 Google 最新的 <strong>Gemini 3 Pro</strong> 模型（预览版）。作为一个每天都在使用 Copilot 的开发者，我第一时间进行了体验，发现它在逻辑推理和长上下文理解方面有着令人惊喜的表现。</p>
<p>在这篇文章中，我将手把手教你如何在 VS Code 中切换到 Gemini 3 Pro，并分享一些使用心得。</p>
<h2 id="为什么要关注-gemini-3-pro">为什么要关注 Gemini 3 Pro？</h2>
<p>Gemini 3 Pro 是 Google 推出的最新一代多模态大模型。在代码生成和理解领域，它展现出了极强的竞争力：</p>
<ol>
<li><strong>更强的推理能力</strong>：面对复杂的算法问题或架构设计，Gemini 3 Pro 往往能给出更深入的分析。</li>
<li><strong>超长上下文窗口</strong>：它能够理解更多的项目代码上下文，这对于大型项目的重构和 Bug 修复至关重要。</li>
<li><strong>响应速度</strong>：尽管模型参数巨大，但在 Copilot 中的响应速度依然非常流畅。</li>
</ol>
<h2 id="如何在-github-copilot-中启用-gemini-3-pro">如何在 GitHub Copilot 中启用 Gemini 3 Pro</h2>
<p>启用过程非常简单，只需要确保你的 VS Code 和 Copilot 插件是最新版本。</p>
<h3 id="步骤-1更新环境">步骤 1：更新环境</h3>
<p>确保你的 Visual Studio Code 和 GitHub Copilot Chat 扩展都已经更新到最新版本。通常 VS Code 会自动更新，但你也可以手动检查一下。</p>
<p><img alt="Github Copilot Chat" loading="lazy" src="/zh/blog/2025/use-gemini-3-pro-in-github-copilot/1-copilot-chat.png"></p>
<h3 id="步骤-2打开-copilot-chat">步骤 2：打开 Copilot Chat</h3>
<p>在 VS Code 侧边栏点击 GitHub Copilot 图标，打开聊天窗口。</p>
<p><img alt="聊天窗口" loading="lazy" src="/zh/blog/2025/use-gemini-3-pro-in-github-copilot/2-copilot-chat-window.png"></p>
<h3 id="步骤-3切换模型">步骤 3：切换模型</h3>
<p>在聊天输入框的下方，你会看到当前使用的模型名称（通常默认是 Auto 或 GPT-4o）。</p>
<p><img alt="默认模型" loading="lazy" src="/zh/blog/2025/use-gemini-3-pro-in-github-copilot/3-default-model.png"></p>
<p>点击模型名称，在弹出的下拉菜单中，选择 <strong>Gemini 3 Pro (Preview)</strong>。</p>
<p><img alt="切换模型" loading="lazy" src="/zh/blog/2025/use-gemini-3-pro-in-github-copilot/4-switch-model.png"></p>
<p>一旦选中，你就可以开始使用 Gemini 3 Pro 进行对话了！</p>
<p>看看他的效果吧！</p>
<p><img alt="展示效果" loading="lazy" src="/zh/blog/2025/use-gemini-3-pro-in-github-copilot/5-for-test.png"></p>
<h2 id="实战体验gemini-3-pro-强在哪里">实战体验：Gemini 3 Pro 强在哪里？</h2>
<h3 id="1-复杂逻辑的代码解释">1. 复杂逻辑的代码解释</h3>
<p>我尝试让它解释一段复杂的异步并发控制代码。Gemini 3 Pro 不仅解释了代码的表面含义，还指出了其中潜在的死锁风险，并给出了优化建议。这种“多想一步”的能力在 Code Review 时非常有帮助。</p>
<h3 id="2-跨文件上下文理解">2. 跨文件上下文理解</h3>
<p>在处理一个涉及多个模块的重构任务时，我将相关的几个文件都添加到了上下文中（使用 <code>@workspace</code> 或手动引用）。Gemini 3 Pro 能够很好地串联起这些文件之间的依赖关系，生成的修改建议很少出现“幻觉”或引用不存在的变量。</p>
<h2 id="总结">总结</h2>
<p>GitHub Copilot 引入 Gemini 3 Pro 为开发者提供了更多的选择。不同的模型有不同的“性格”和优势领域。我建议大家在处理不同类型的任务时可以尝试切换模型：</p>
<ul>
<li><strong>日常简单代码补全</strong>：默认模型通常够快够用。</li>
<li><strong>复杂架构设计或疑难 Bug 排查</strong>：试试 <strong>Gemini 3 Pro</strong>，它可能会给你带来新的思路。</li>
</ul>
<p>快去试试吧，感受一下 Google 最新 AI 模型的编程实力！</p>
]]></content:encoded></item></channel></rss>