<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://izualzhy.cn/feed.xml" rel="self" type="application/atom+xml" /><link href="https://izualzhy.cn/" rel="alternate" type="text/html" /><updated>2026-06-14T13:09:01+00:00</updated><id>https://izualzhy.cn/feed.xml</id><title type="html">Ying</title><subtitle>New</subtitle><author><name>ying</name></author><entry><title type="html">《GPT 图解》笔记：微调与RLHF、总结</title><link href="https://izualzhy.cn/llm-diagrammatize-ft-rlhf-summary" rel="alternate" type="text/html" title="《GPT 图解》笔记：微调与RLHF、总结" /><published>2026-06-14T06:08:17+00:00</published><updated>2026-06-14T06:08:17+00:00</updated><id>https://izualzhy.cn/llm-diagrammatize-ft-rlhf-summary</id><content type="html" xml:base="https://izualzhy.cn/llm-diagrammatize-ft-rlhf-summary"><![CDATA[<h2 id="1-微调">1. 微调</h2>

<p>微调的代码和预训练几乎一样，区别在于数据如何准备。</p>

<p>例如目标要生成一个问答模型，准备的数据如下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User: hi , how are you ?
AI: i am doing well , thank you . how about you ?
User: i am good , thanks for asking . what can you do ?
AI: i am an ai language model . i can help you answer questions .
User: what is the weather like today ?
AI: please check a weather website or application for the current conditions .
User: can you recommend a good book ?
AI: sure ! to kill a mockingbird by harper lee is a classic and highly recommended novel .
User: thank you ! i will check it out .
AI: you are welcome ! let me know if you need help with anything else .
</code></pre></div></div>

<p>那训练的数据则分别从 User AI 里提取，形如：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Input:
&lt;sos&gt; hi, how are you &lt;eos&gt;

Target:
&lt;sos&gt; I am doing well, thank you &lt;eos&gt;
</code></pre></div></div>

<p>之后也是根据 Input 训练出 Output，然后不断缩小与 Target 的差距。</p>

<p>不过从实际接触的其他资料看，书里的例子可能适合教学，更常见的做法是把对话作为一个完整序列，右移一位作为训练目标：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>source:
User: hi, how are you?
AI: i am doing

target:
hi, how are you?
AI: i am doing well
</code></pre></div></div>

<p>这样模型学习的是”给定前文，预测下一个 token”，跟预训练的目标保持一致。</p>

<h2 id="2-rlhf">2. RLHF</h2>

<p>Reinforcement Learning from Human Feedback 的几个步骤：</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/8-7.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<ol>
  <li>通过有标注的数据，在预训练模型上，训练出监督学习微调模型(Supervised Fine-Tune Model, SFT)(即第 1 节的产出)</li>
  <li>在 1 的基础上，基于标注数据(带回答得分)，训练一个奖励模型，该模型善于对于回答进行评价得分</li>
  <li>在 1 的基础上，用 2 产出的奖励模型评价得分，该得分会加权到 loss，以不断改善该模型</li>
</ol>

<p>书里的例子做了较大简化：数据里直接固定了 score，即<code class="language-plaintext highlighter-rouge">(User, AI Answer) → 一个标量奖励</code>，跳过了奖励模型的训练过程。真实的 RLHF 流程要复杂得多，但核心思想就是这三步：先用监督数据微调，教模型怎么说(例如 User:Question AI:Answer 的形式)，再用人类偏好教模型”说得好”。</p>

<h2 id="3-总结">3. 总结</h2>

<p>这本书从去年开始看，中间看到 Attention 那章因为不懂就放弃了，最近几个周末又狂啃了几顿，终于基本看懂。</p>

<p>新的过程主要还是借助了 ChatGPT，我把自己的理解，跟 AI 不断交流，直到至少理论上是自洽的，偶尔再拿 ds 做二次确认，以确保没有理解偏差。因为这类技术书籍，打不牢基础，看到后面也只能是沦为满嘴技术名词的附庸者而已。这个过程，也让我感触颇深。特别是这类自由度比较高的学习场景——无标准题目、无标准答案、理解程度全凭个人——AI 辅助学习的效率已经远超预期。以后教育的形式、人才的判断标准，大概都会改变。</p>

<p>回到书籍本身，从 N-Gram 的统计思想出发，一路走到 GPT，我对于这条脉络的理解也清晰了不少。</p>

<p>最早是概率的思想，<a href="http://izualzhy.cn/llm-diagrammatize-ngram-nplm-lstm">N-Gram 刻画词序概率，NPLM 引入神经网络，LSTM 解决长距离依赖</a>。然后基于翻译任务，<a href="http://izualzhy.cn/llm-diagrammatize-seq2seq-attention">Seq2Seq 架构将编码器-解码器带入序列任务，点积注意力让模型知道 token 间的关系</a>。注意力机制继续进化，<a href="http://izualzhy.cn/llm-diagrammatize-attention-qkv-multi-mask">QKV 的参数化设计、多头注意力的子空间拆分、掩码对生成过程的约束</a>。这些积累最终催生了 <a href="http://izualzhy.cn/llm-diagrammatize-transformer">Transformer 架构</a>，彻底抛弃 RNN，用位置编码和交叉注意力重新定义了序列建模。在此之上，编码器产生了 BERT，解码器则产生了 <a href="http://izualzhy.cn/llm-diagrammatize-gpt">GPT</a>。GPT 是纯 Decoder 结构，自回归生成：训练时右移一位学习”给定前文，预测下一个 token”，推理时逐 token 生成直到结束。最后，通过微调和 RLHF 让模型能更符合某个场景的偏好——先用监督数据教它对话的形式，再用人类偏好教它回答的质量。也就是本文所记录的内容，基于这些对话的数据训练出来的模型，就是平时我用的最多的 ChatXXX 的最初形态了。</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[1. 微调 微调的代码和预训练几乎一样，区别在于数据如何准备。 例如目标要生成一个问答模型，准备的数据如下： User: hi , how are you ? AI: i am doing well , thank you . how about you ? User: i am good , thanks for asking . what can you do ? AI: i am an ai language model . i can help you answer questions . User: what is the weather like today ? AI: please check a weather website or application for the current conditions . User: can you recommend a good book ? AI: sure ! to kill a mockingbird by harper lee is a classic and highly recommended novel . User: thank you ! i will check it out . AI: you are welcome ! let me know if you need help with anything else . 那训练的数据则分别从 User AI 里提取，形如： Input: &lt;sos&gt; hi, how are you &lt;eos&gt; Target: &lt;sos&gt; I am doing well, thank you &lt;eos&gt; 之后也是根据 Input 训练出 Output，然后不断缩小与 Target 的差距。 不过从实际接触的其他资料看，书里的例子可能适合教学，更常见的做法是把对话作为一个完整序列，右移一位作为训练目标： source: User: hi, how are you? AI: i am doing target: hi, how are you? AI: i am doing well 这样模型学习的是”给定前文，预测下一个 token”，跟预训练的目标保持一致。 2. RLHF Reinforcement Learning from Human Feedback 的几个步骤： 图源：《GPT图解-大模型是怎样构建的》 通过有标注的数据，在预训练模型上，训练出监督学习微调模型(Supervised Fine-Tune Model, SFT)(即第 1 节的产出) 在 1 的基础上，基于标注数据(带回答得分)，训练一个奖励模型，该模型善于对于回答进行评价得分 在 1 的基础上，用 2 产出的奖励模型评价得分，该得分会加权到 loss，以不断改善该模型 书里的例子做了较大简化：数据里直接固定了 score，即(User, AI Answer) → 一个标量奖励，跳过了奖励模型的训练过程。真实的 RLHF 流程要复杂得多，但核心思想就是这三步：先用监督数据微调，教模型怎么说(例如 User:Question AI:Answer 的形式)，再用人类偏好教模型”说得好”。 3. 总结 这本书从去年开始看，中间看到 Attention 那章因为不懂就放弃了，最近几个周末又狂啃了几顿，终于基本看懂。 新的过程主要还是借助了 ChatGPT，我把自己的理解，跟 AI 不断交流，直到至少理论上是自洽的，偶尔再拿 ds 做二次确认，以确保没有理解偏差。因为这类技术书籍，打不牢基础，看到后面也只能是沦为满嘴技术名词的附庸者而已。这个过程，也让我感触颇深。特别是这类自由度比较高的学习场景——无标准题目、无标准答案、理解程度全凭个人——AI 辅助学习的效率已经远超预期。以后教育的形式、人才的判断标准，大概都会改变。 回到书籍本身，从 N-Gram 的统计思想出发，一路走到 GPT，我对于这条脉络的理解也清晰了不少。 最早是概率的思想，N-Gram 刻画词序概率，NPLM 引入神经网络，LSTM 解决长距离依赖。然后基于翻译任务，Seq2Seq 架构将编码器-解码器带入序列任务，点积注意力让模型知道 token 间的关系。注意力机制继续进化，QKV 的参数化设计、多头注意力的子空间拆分、掩码对生成过程的约束。这些积累最终催生了 Transformer 架构，彻底抛弃 RNN，用位置编码和交叉注意力重新定义了序列建模。在此之上，编码器产生了 BERT，解码器则产生了 GPT。GPT 是纯 Decoder 结构，自回归生成：训练时右移一位学习”给定前文，预测下一个 token”，推理时逐 token 生成直到结束。最后，通过微调和 RLHF 让模型能更符合某个场景的偏好——先用监督数据教它对话的形式，再用人类偏好教它回答的质量。也就是本文所记录的内容，基于这些对话的数据训练出来的模型，就是平时我用的最多的 ChatXXX 的最初形态了。]]></summary></entry><entry><title type="html">《GPT 图解》笔记：GPT-从 Decoder 到自回归文本生成</title><link href="https://izualzhy.cn/llm-diagrammatize-gpt" rel="alternate" type="text/html" title="《GPT 图解》笔记：GPT-从 Decoder 到自回归文本生成" /><published>2026-06-13T06:18:45+00:00</published><updated>2026-06-13T06:18:45+00:00</updated><id>https://izualzhy.cn/llm-diagrammatize-gpt</id><content type="html" xml:base="https://izualzhy.cn/llm-diagrammatize-gpt"><![CDATA[<p>上一篇<a href="https://izualzhy.cn/llm-diagrammatize-transformer">《GPT 图解》笔记：Transformer</a>最后一节提到了：</p>
<ol>
  <li>Encoder 能同时“看到”句子中所有位置的信息（左边和右边），适合理解句子。</li>
  <li>Decoder 每个位置只能“看到”它之前的位置（左边），未来的词被掩码遮住了，适合推理。</li>
</ol>

<p>在演进过程中，基于 Encoder 发展出来 BERT，基于 Decoder 发展出了 GPT.</p>

<p>这篇笔记主要记录<strong>GPT</strong>，主要有：</p>

<ol>
  <li><strong>贪婪解码到集束搜索</strong>：贪心即选取局部最优解，集束则是在每一个保留多个高概率候选 token，实现累计最高概率</li>
  <li><strong>GPT 组件结构</strong>：在 Decoder 基础上做了新增(softmax&amp;linear)和删减(跟编码器的交叉注意力)</li>
  <li><strong>训练与推理过程中的自回归</strong>：前者利用右移输入和 Causal Mask，在并行计算中模拟自回归约束；后者则是基于预测值继续预测</li>
</ol>

<h2 id="1-贪婪解码器">1. 贪婪解码器</h2>

<p>上一篇笔记是这么预测的：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 创建一个大小为 1 的批次，目标语言序列 dec_inputs 在测试阶段，仅包含句子开始符号 &lt;sos&gt;
</span><span class="n">enc_inputs</span><span class="p">,</span> <span class="n">dec_inputs</span><span class="p">,</span> <span class="n">target_batch</span> <span class="o">=</span> <span class="n">corpus</span><span class="p">.</span><span class="n">make_batch</span><span class="p">(</span><span class="n">batch_size</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span><span class="n">test_batch</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span> 
<span class="k">print</span><span class="p">(</span><span class="s">"编码器输入 :"</span><span class="p">,</span> <span class="n">enc_inputs</span><span class="p">)</span> <span class="c1"># 打印编码器输入
</span><span class="k">print</span><span class="p">(</span><span class="s">"解码器输入 :"</span><span class="p">,</span> <span class="n">dec_inputs</span><span class="p">)</span> <span class="c1"># 打印解码器输入
</span><span class="k">print</span><span class="p">(</span><span class="s">"目标数据 :"</span><span class="p">,</span> <span class="n">target_batch</span><span class="p">)</span> <span class="c1"># 打印目标数据
</span><span class="n">predict</span><span class="p">,</span> <span class="n">enc_self_attns</span><span class="p">,</span> <span class="n">dec_self_attns</span><span class="p">,</span> <span class="n">dec_enc_attns</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="n">enc_inputs</span><span class="p">,</span> <span class="n">dec_inputs</span><span class="p">)</span> <span class="c1"># 用模型进行翻译
</span><span class="n">predict</span> <span class="o">=</span> <span class="n">predict</span><span class="p">.</span><span class="n">view</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">corpus</span><span class="p">.</span><span class="n">tgt_vocab</span><span class="p">))</span> <span class="c1"># 将预测结果维度重塑
</span><span class="n">predict</span> <span class="o">=</span> <span class="n">predict</span><span class="p">.</span><span class="n">data</span><span class="p">.</span><span class="nb">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">keepdim</span><span class="o">=</span><span class="bp">True</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span> <span class="c1"># 找到每个位置概率最大的词汇的索引
# 解码预测的输出，将所预测的目标句子中的索引转换为单词
</span><span class="n">translated_sentence</span> <span class="o">=</span> <span class="p">[</span><span class="n">corpus</span><span class="p">.</span><span class="n">tgt_idx2word</span><span class="p">[</span><span class="n">idx</span><span class="p">.</span><span class="n">item</span><span class="p">()]</span> <span class="k">for</span> <span class="n">idx</span> <span class="ow">in</span> <span class="n">predict</span><span class="p">.</span><span class="n">squeeze</span><span class="p">()]</span>
<span class="c1"># 将输入的源语言句子中的索引转换为单词
</span><span class="n">input_sentence</span> <span class="o">=</span> <span class="s">' '</span><span class="p">.</span><span class="n">join</span><span class="p">([</span><span class="n">corpus</span><span class="p">.</span><span class="n">src_idx2word</span><span class="p">[</span><span class="n">idx</span><span class="p">.</span><span class="n">item</span><span class="p">()]</span> <span class="k">for</span> <span class="n">idx</span> <span class="ow">in</span> <span class="n">enc_inputs</span><span class="p">[</span><span class="mi">0</span><span class="p">]])</span>
<span class="k">print</span><span class="p">(</span><span class="n">input_sentence</span><span class="p">,</span> <span class="s">'-&gt;'</span><span class="p">,</span> <span class="n">translated_sentence</span><span class="p">)</span> <span class="c1"># 打印原始句子和翻译后的句子
</span></code></pre></div></div>

<p>初始时，<code class="language-plaintext highlighter-rouge">dec_inputs = [&lt;sos&gt; &lt;pad&gt; &lt;pad&gt; &lt;pad&gt; &lt;pad&gt;]</code>。</p>

<p>调用 <code class="language-plaintext highlighter-rouge">model(enc_inputs, dec_inputs)</code>执行 1 次 forward 预测。forward 的内部流程：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Transformer.forward()
├── encoder(enc_inputs)                       1次
├── decoder(dec_inputs, enc_inputs, enc_outputs) 1次
└── projection(dec_outputs)                   1次
</code></pre></div></div>

<p>预测过程中形状变化：</p>
<pre><code class="language-mermaid">graph LR
    A["(1, T, V)"] --&gt;|"predict.view(-1, V)"| B["(T, V)"]
    B --&gt;|"predict.data.max(1, keepdim=True)[1]"| C["(T, 1)"]
    C --&gt;|"predict.squeeze()"| D["(T)"]
</code></pre>

<p>因为提前构造了完整长度的 dec_inputs，这里一次得到所有位置的预测：token0 token1 token2 … tokenT , 即每个位置最大概率的 token.</p>

<p>贪婪解码器则是一步步预测的，代码：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 定义贪婪解码器函数
</span><span class="k">def</span> <span class="nf">greedy_decoder</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">enc_input</span><span class="p">,</span> <span class="n">start_symbol</span><span class="p">):</span>
    <span class="c1"># 对输入数据进行编码，并获得编码器输出及自注意力权重
</span>    <span class="n">enc_outputs</span><span class="p">,</span> <span class="n">enc_self_attns</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">encoder</span><span class="p">(</span><span class="n">enc_input</span><span class="p">)</span>    
    <span class="c1"># 初始化解码器输入为全零张量，大小为 (1, 5)，数据类型与 enc_input 一致
</span>    <span class="n">dec_input</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">5</span><span class="p">).</span><span class="n">type_as</span><span class="p">(</span><span class="n">enc_input</span><span class="p">.</span><span class="n">data</span><span class="p">)</span>    
    <span class="c1"># 设置下一个要解码的符号为开始符号
</span>    <span class="n">next_symbol</span> <span class="o">=</span> <span class="n">start_symbol</span>    
    <span class="c1"># 循环5次，为解码器输入中的每一个位置填充一个符号
</span>    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">5</span><span class="p">):</span>
        <span class="c1"># 将下一个符号放入解码器输入的当前位置
</span>        <span class="n">dec_input</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">next_symbol</span>        
        <span class="c1"># 运行解码器，获得解码器输出、解码器自注意力权重和编码器-解码器注意力权重
</span>        <span class="n">dec_output</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">decoder</span><span class="p">(</span><span class="n">dec_input</span><span class="p">,</span> <span class="n">enc_input</span><span class="p">,</span> <span class="n">enc_outputs</span><span class="p">)</span>
        <span class="c1"># 将解码器输出投影到目标词汇空间
</span>        <span class="n">projected</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">projection</span><span class="p">(</span><span class="n">dec_output</span><span class="p">)</span>        
        <span class="c1"># 找到具有最高概率的下一个单词
</span>        <span class="n">prob</span> <span class="o">=</span> <span class="n">projected</span><span class="p">.</span><span class="n">squeeze</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="nb">max</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">,</span> <span class="n">keepdim</span><span class="o">=</span><span class="bp">False</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span>
        <span class="n">next_word</span> <span class="o">=</span> <span class="n">prob</span><span class="p">.</span><span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>        
        <span class="c1"># 将找到的下一个单词作为新的符号
</span>        <span class="n">next_symbol</span> <span class="o">=</span> <span class="n">next_word</span><span class="p">.</span><span class="n">item</span><span class="p">()</span>        
    <span class="c1"># 返回解码器输入，它包含了生成的符号序列
</span>    <span class="n">dec_outputs</span> <span class="o">=</span> <span class="n">dec_input</span>
    <span class="k">return</span> <span class="n">dec_outputs</span>
</code></pre></div></div>

<p>预测过程中的内部流程:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├── encoder(enc_input)      1次
└── for i in range(T):
        dec_input[0][i] = ...
        decoder(dec_input, enc_input, enc_outputs)  T次
        projection(dec_output)                      T次
</code></pre></div></div>

<p>Encoder 只计算一次，Decoder + Projection 重复计算 T 次。</p>

<p>每次循环，取第 i 个位置概率最大的 token，写回 dec_input[0][i]，更新该位置，然后基于新的预测继续预测。</p>

<p>这种基于当前时刻生成的 token，作为下一时刻的输入，继续参与后续 token 的预测的方式，就是典型的<strong>自回归（Autoregressive）推理</strong>。</p>

<p>注：自回归除了贪婪模式，还有其他例如集束搜索，文章末尾会提到。</p>

<p>这种“预测未来，再利用预测结果继续预测未来”的模式，让我想起之前看的一篇科幻小说 😁，主人公拥有一种能力，可以预测 5 分钟之后要发生的事情，突然有一天他意识到，基于这个能力，他可以预测 5 分钟后他会预测什么，那么他就可以预测 10 分钟后的事情了，以此类推，他就可以预测未来。</p>

<h2 id="2-搭建简化-gpt-模型">2. 搭建简化 GPT 模型</h2>

<h3 id="21-组件">2.1. 组件</h3>

<figure>
  <img src="/assets/images/gpt-diagrammatize/7-5.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>图里各组件的作用:</p>
<ol>
<li>多头自注意力：QKV 矩阵、记录 token 之间的关注信息</li>
<li>逐位置前馈网络：两个卷积层(标准似乎是两个 Linear)，总之就是两个矩阵</li>
<li>正弦位置编码：生成跟位置信息相关的向量</li>
<li value="45">填充掩码、后续掩码：通过预定义一个矩阵，避免关注到无用信息、未来信息</li>
<li value="67">将上述组件封装为一层的 DecoderLayer，大致流程：<code>Masked Self-Attention → Add&amp;Norm → FFN → Add&amp;Norm</code>，然后叠加多层封装为 Decoder</li>
<li value="8">投影层将 Decoder 输出的特征向量，转为具体的预测词</li>
</ol>

<p>只有解码器，因此也就去掉了 Decoder 里的 编码器-解码器注意力 层。</p>

<p>书中这一节提到:</p>

<blockquote>
  <p>GPT模型仅包含解码器部分，没有编码器部分。因此，它更适用于无条件文本生成任务，而不是类似机器翻译或问答等需要编码器-解码器结构的任务。</p>
</blockquote>

<p>这是早期的观点，我们现在使用 ChatGPT 时，实际上也能翻译的。我理解是原始 Transformer 用 Encoder 理解源序列，再用 Decoder 生成目标序列；GPT 则把“源序列 + 任务指令 + 目标序列”统一看作一个长 Token 序列，通过 Decoder 的自回归生成完成翻译。</p>

<p>也就是后续的 GPT 不再区分翻译、问答、摘要等不同任务，而就看做 token 序列的条件生成问题。</p>

<p>有种一切皆 token 序列，一切皆生成的感觉。</p>

<h3 id="22-构造训练数据">2.2. 构造训练数据</h3>

<p>我在很多时候不理解算法，都是卡在了没有理解最开始的数据结构和样例。书里使用了 WikiText2 来构建数据（目前源码在高版本 torchtext 已经跑不通了，可以使用 AI 修复下代码）。</p>

<p>相比之前的 demo，主要的区别有两个：</p>
<ol>
  <li>数据集不再是代码中手动定义的几条句子，而是通过 torchtext 下载 WikiText2 数据集</li>
  <li>手动 <code class="language-plaintext highlighter-rouge">sentence.split(' ')</code> 改为使用 tokenizer 分词</li>
</ol>

<p>但是目的是相同的，vocab 含义相同，本质仍然是 token → id 的映射。</p>

<p>接下来具体说明下构造数据的代码流程(<em>注：代码参见原书</em>)。</p>

<p>首先，将每个句子转为 token id 序列：<code class="language-plaintext highlighter-rouge">tokens = [ vocab[&lt;sos&gt;], vocab(tokens).., vocab[&lt;eos&gt;] ]</code>。</p>

<p>然后，<code class="language-plaintext highlighter-rouge">__getitem__</code> 返回两个 Tensor：<code class="language-plaintext highlighter-rouge">source = tokens[:-1]</code>，<code class="language-plaintext highlighter-rouge">target = tokens[1:]</code>。即目标序列相对于输入<strong>右移一位</strong>，用于训练下一个 token 预测（右移的具体原因说明见2.3）。</p>

<p>由于一个 batch 中句子长度不一致，还需要两步补齐：</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">pad_sequence</code>：创建二维张量，第一维是句子个数，第二维是最大序列长度，用 <code class="language-plaintext highlighter-rouge">padding_value</code> 补齐</li>
  <li><code class="language-plaintext highlighter-rouge">collate_fn</code>：计算 sources 和 targets 中最大长度，分别补齐，确保两者长度一致</li>
</ul>

<p>这样大小整齐的数据，就可以用来训练了。</p>

<h3 id="23-训练过程中的自回归">2.3. 训练过程中的自回归</h3>

<p>训练中的自回归离不开<strong>右移</strong>操作。</p>

<p>假设有一个句子序列为<code class="language-plaintext highlighter-rouge">&lt;sos&gt; I love apple &lt;eos&gt;</code></p>
<ul>
  <li>输入序列 source: <code class="language-plaintext highlighter-rouge">&lt;sos&gt; I love apple</code></li>
  <li>目标序列 target: <code class="language-plaintext highlighter-rouge">I love apple &lt;eos&gt;</code></li>
</ul>

<p>source 是 Decoder 输入，target 是 Decoder 预测的目标值。</p>

<p>仍以<code class="language-plaintext highlighter-rouge">source = &lt;sos&gt; I love apple</code>为例，在 Decoder ，每个位置都会产生一个 Query：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Q0       Q1      Q2       Q3
 |        |       |        |
&lt;sos&gt;     I     love    apple
</code></pre></div></div>

<p>Causal Mask 作用在每个 Query 上，同时遮挡住了后续的 token，效果上即：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Q0: 只能看 &lt;sos&gt;
Q1: 只能看 &lt;sos&gt;, I
Q2: 只能看 &lt;sos&gt;, I, love
Q3: 只能看 &lt;sos&gt;, I, love, apple
</code></pre></div></div>

<p>这就是<strong>训练过程中的自回归</strong></p>

<p>Decoder 在计算时，可以<strong>并行计算</strong>这个四个任务。输出：<code class="language-plaintext highlighter-rouge">outputs.shape = (B, T, V)</code>，例如这里就是<code class="language-plaintext highlighter-rouge">(1, 4, vocab_size)</code></p>

<p>outputs 表示每个位置上的词的概率，例如:</p>
<ol>
  <li>outputs[0,0,:] -&gt; 预测 <code class="language-plaintext highlighter-rouge">I</code> 的概率分布</li>
  <li>outputs[0,1,:] -&gt; <code class="language-plaintext highlighter-rouge">love</code> 的</li>
  <li>outputs[0,2,:] -&gt; <code class="language-plaintext highlighter-rouge">apple</code> 的</li>
  <li>outputs[0,3,:] -&gt; <code class="language-plaintext highlighter-rouge">&lt;eos&gt;</code> 的</li>
</ol>

<p>再跟<code class="language-plaintext highlighter-rouge">target = ['I', 'love', 'apple', '&lt;eos&gt;']</code>，逐位置计算<code class="language-plaintext highlighter-rouge">CrossEntropyLoss</code></p>

<h2 id="3-预测">3. 预测</h2>

<h3 id="31-贪婪解码">3.1. 贪婪解码</h3>

<p>如果还是使用贪婪的方式，每次选取概率最大的 token，代码如下：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 测试文本生成
</span><span class="k">def</span> <span class="nf">generate_text</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">input_str</span><span class="p">,</span> <span class="n">max_len</span><span class="o">=</span><span class="mi">50</span><span class="p">):</span>
    <span class="n">model</span><span class="p">.</span><span class="nb">eval</span><span class="p">()</span>  <span class="c1"># 将模型设置为评估（测试）模式，关闭dropout和batch normalization等训练相关的层
</span>    <span class="c1"># 将输入字符串中的每个token 转换为其在词汇表中的索引
</span>    <span class="n">input_tokens</span> <span class="o">=</span> <span class="p">[</span><span class="n">corpus</span><span class="p">.</span><span class="n">vocab</span><span class="p">[</span><span class="n">token</span><span class="p">]</span> <span class="k">for</span> <span class="n">token</span> <span class="ow">in</span> <span class="n">input_str</span><span class="p">]</span>
    <span class="c1"># 创建一个新列表，将输入的tokens复制到输出tokens中，目前只有输入的词
</span>    <span class="n">output_tokens</span> <span class="o">=</span> <span class="n">input_tokens</span><span class="p">.</span><span class="n">copy</span><span class="p">()</span>
    <span class="k">with</span> <span class="n">torch</span><span class="p">.</span><span class="n">no_grad</span><span class="p">():</span>  <span class="c1"># 禁用梯度计算，以节省内存并加速测试过程
</span>        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">max_len</span><span class="p">):</span>  <span class="c1"># 生成最多max_len个tokens
</span>            <span class="c1"># 将输出的token转换为 PyTorch张量，并增加一个代表批次的维度[1, len(output_tokens)]
</span>            <span class="n">inputs</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">LongTensor</span><span class="p">(</span><span class="n">output_tokens</span><span class="p">).</span><span class="n">unsqueeze</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
            <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="n">inputs</span><span class="p">)</span> <span class="c1">#输出 logits形状为[1, len(output_tokens), vocab_size]
</span>            <span class="c1"># 在最后一个维度上获取logits中的最大值，并返回其索引（即下一个token）
</span>             <span class="n">_</span><span class="p">,</span> <span class="n">next_token</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">max</span><span class="p">(</span><span class="n">outputs</span><span class="p">[:,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="p">:],</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>            
            <span class="n">next_token</span> <span class="o">=</span> <span class="n">next_token</span><span class="p">.</span><span class="n">item</span><span class="p">()</span> <span class="c1"># 将张量转换为Python整数            
</span>            <span class="k">if</span> <span class="n">next_token</span> <span class="o">==</span> <span class="n">corpus</span><span class="p">.</span><span class="n">vocab</span><span class="p">[</span><span class="s">"&lt;eos&gt;"</span><span class="p">]:</span>
                <span class="k">break</span> <span class="c1"># 如果生成的token是 EOS（结束符），则停止生成过程           
</span>            <span class="n">output_tokens</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">next_token</span><span class="p">)</span> <span class="c1"># 将生成的tokens添加到output_tokens列表
</span>    <span class="c1"># 将输出tokens转换回文本字符串
</span>    <span class="n">output_str</span> <span class="o">=</span> <span class="s">" "</span><span class="p">.</span><span class="n">join</span><span class="p">([</span><span class="n">corpus</span><span class="p">.</span><span class="n">idx2word</span><span class="p">[</span><span class="n">token</span><span class="p">]</span> <span class="k">for</span> <span class="n">token</span> <span class="ow">in</span> <span class="n">output_tokens</span><span class="p">])</span>
    <span class="k">return</span> <span class="n">output_str</span>
<span class="err">　</span>
<span class="n">input_str</span> <span class="o">=</span> <span class="p">[</span><span class="s">"Python"</span><span class="p">]</span> <span class="c1"># 输入一个词：Python
</span><span class="n">generated_text</span> <span class="o">=</span> <span class="n">generate_text</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">input_str</span><span class="p">)</span> <span class="c1"># 模型根据这个词生成后续文本
</span><span class="k">print</span><span class="p">(</span><span class="s">"生成的文本："</span><span class="p">,</span> <span class="n">generated_text</span><span class="p">)</span> <span class="c1"># 打印预测文本
</span></code></pre></div></div>

<p>对应的逻辑图：</p>

<pre><code class="language-mermaid">flowchart LR

A["inputs&lt;br&gt;[1, T]"]
A --&gt;|"model(inputs)"| B["outputs&lt;br&gt;[1, T, V]"]
B --&gt;|"outputs[:, -1, :]"| C["last_logits&lt;br&gt;[1, V]"]
C --&gt;|"torch.max"| D["next_token_id&lt;br&gt;scalar"]
D --&gt;|"append"| E["inputs&lt;br&gt;[1, T+1]"]

E -. repeat .-&gt; A
</code></pre>

<p>其中<strong>repeat</strong>即为自回归的过程。</p>

<h3 id="32-集束搜索">3.2. 集束搜索</h3>

<figure>
  <img src="/assets/images/gpt-diagrammatize/7-8.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>集束搜索是一种启发式搜索策略。</p>

<p>实际并不复杂，我理解贪心是贪心 1 步，集束就是贪心 N 步，保留多个高概率候选 token，然后取 N 步里最优的路径。</p>

<p>以书中代码为例：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 定义集束搜索的函数
</span><span class="k">def</span> <span class="nf">generate_text_beam_search</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">input_str</span><span class="p">,</span> <span class="n">max_len</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">beam_width</span><span class="o">=</span><span class="mi">5</span><span class="p">):</span>
    <span class="n">model</span><span class="p">.</span><span class="nb">eval</span><span class="p">()</span>  <span class="c1"># 将模型设置为评估模式，关闭dropout和batch normalization等与训练相关的层
</span>    <span class="c1"># 将输入字符串中的每个token 转换为其在词汇表中的索引
</span>    <span class="n">input_tokens</span> <span class="o">=</span> <span class="p">[</span><span class="n">vocab</span><span class="p">[</span><span class="n">token</span><span class="p">]</span> <span class="k">for</span> <span class="n">token</span> <span class="ow">in</span> <span class="n">input_str</span><span class="p">.</span><span class="n">split</span><span class="p">()]</span>
    <span class="c1"># 创建一个列表，用于存储候选序列
</span>    <span class="n">candidates</span> <span class="o">=</span> <span class="p">[(</span><span class="n">input_tokens</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">)]</span>
    <span class="k">with</span> <span class="n">torch</span><span class="p">.</span><span class="n">no_grad</span><span class="p">():</span>  <span class="c1"># 禁用梯度计算，以节省内存并加速测试过程
</span>        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">max_len</span><span class="p">):</span>  <span class="c1"># 生成最多max_len个token
</span>            <span class="n">new_candidates</span> <span class="o">=</span> <span class="p">[]</span>
            <span class="k">for</span> <span class="n">candidate</span><span class="p">,</span> <span class="n">candidate_score</span> <span class="ow">in</span> <span class="n">candidates</span><span class="p">:</span>
                <span class="n">inputs</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">LongTensor</span><span class="p">(</span><span class="n">candidate</span><span class="p">).</span><span class="n">unsqueeze</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
                <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="n">inputs</span><span class="p">)</span> <span class="c1"># 输出 logits形状为[1, len(output_tokens), vocab_size]
</span>                <span class="n">logits</span> <span class="o">=</span> <span class="n">outputs</span><span class="p">[:,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="p">:]</span> <span class="c1"># 只关心最后一个时间步（即最新生成的token）的logits
</span>                <span class="c1"># 找到具有最高分数的前beam_width个token
</span>                <span class="n">scores</span><span class="p">,</span> <span class="n">next_tokens</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">topk</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">beam_width</span><span class="p">,</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
                <span class="n">final_results</span> <span class="o">=</span> <span class="p">[]</span> <span class="c1"># 初始化输出序列
</span>                <span class="k">for</span> <span class="n">score</span><span class="p">,</span> <span class="n">next_token</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">scores</span><span class="p">.</span><span class="n">squeeze</span><span class="p">(),</span> <span class="n">next_tokens</span><span class="p">.</span><span class="n">squeeze</span><span class="p">()):</span>
                    <span class="n">new_candidate</span> <span class="o">=</span> <span class="n">candidate</span> <span class="o">+</span> <span class="p">[</span><span class="n">next_token</span><span class="p">.</span><span class="n">item</span><span class="p">()]</span>
                    <span class="n">new_score</span> <span class="o">=</span> <span class="n">candidate_score</span> <span class="o">-</span> <span class="n">score</span><span class="p">.</span><span class="n">item</span><span class="p">()</span>  <span class="c1"># 使用负数，因为我们需要降序排列
</span>                    <span class="k">if</span> <span class="n">next_token</span><span class="p">.</span><span class="n">item</span><span class="p">()</span> <span class="o">==</span> <span class="n">vocab</span><span class="p">[</span><span class="s">"&lt;eos&gt;"</span><span class="p">]:</span>
                        <span class="c1"># 如果生成的token是EOS（结束符），将其添加到最终结果中
</span>                        <span class="n">final_results</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">new_candidate</span><span class="p">,</span> <span class="n">new_score</span><span class="p">))</span>
                    <span class="k">else</span><span class="p">:</span>
                        <span class="c1"># 将新生成的候选序列添加到新候选列表中
</span>                        <span class="n">new_candidates</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">new_candidate</span><span class="p">,</span> <span class="n">new_score</span><span class="p">))</span>
            <span class="c1"># 从新候选列表中选择得分最高的beam_width个序列
</span>            <span class="n">candidates</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">new_candidates</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">])[:</span><span class="n">beam_width</span><span class="p">]</span>
    <span class="c1"># 选择得分最高的候选序列
</span>    <span class="n">best_candidate</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">candidates</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
    <span class="c1"># 将输出的 token 转换回文本字符串
</span>    <span class="n">output_str</span> <span class="o">=</span> <span class="s">" "</span><span class="p">.</span><span class="n">join</span><span class="p">([</span><span class="n">vocab</span><span class="p">.</span><span class="n">get_itos</span><span class="p">()[</span><span class="n">token</span><span class="p">]</span> <span class="k">for</span> <span class="n">token</span> <span class="ow">in</span> <span class="n">best_candidate</span> <span class="k">if</span> <span class="n">vocab</span><span class="p">.</span><span class="n">get_itos</span><span class="p">()[</span><span class="n">token</span><span class="p">]</span> <span class="o">!=</span> <span class="s">"&lt;pad&gt;"</span><span class="p">])</span>
    <span class="k">return</span> <span class="n">output_str</span>
<span class="err">　</span>
<span class="n">model</span><span class="p">.</span><span class="n">load_state_dict</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="s">'best_model.pth'</span><span class="p">))</span> <span class="c1"># 加载模型
</span><span class="n">input_str</span> <span class="o">=</span> <span class="s">"my name"</span>  <span class="c1"># 输入几个词
</span><span class="n">generated_text</span> <span class="o">=</span> <span class="n">generate_text_beam_search</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">input_str</span><span class="p">)</span>  <span class="c1"># 模型根据这些词生成后续文本
</span><span class="k">print</span><span class="p">(</span><span class="s">"生成的文本："</span><span class="p">,</span> <span class="n">generated_text</span><span class="p">)</span>  <span class="c1"># 打印生成的文本
</span></code></pre></div></div>

<p>代码说明：</p>
<ol>
  <li>对当前的每个候选序列（candidates），计算下一个 token 的概率，取概率最高的 beam_width 个 token，分别追加到该候选序列后面，形成新的候选序列（候选数量最多扩展为 当前候选数 × beam_width）。</li>
  <li>对所有新生成的候选序列，计算累计分数（通常是所有 token 的 log 概率之和），保留分数最高的 beam_width 个，作为下一轮的 candidates，继续生成。</li>
</ol>

<p>注意 2 实现跟图里的小差别，如果按照图里的方式，就是 beam_width^n 的候选数量变化。通过 2，确保了每一个位置预测后，总数量仍然是 beam_width.</p>

<p>整体来看，GPT 的核心思想并不复杂：保留 Transformer Decoder，通过 Causal Mask 实现训练阶段的并行自回归，再通过逐 token 生成完成推理。之前一直有个疑问：只有 Decoder 没有 Encoder，是怎么处理翻译、问答这些需要”理解输入”的任务？看到训练数据构造这一节才明白——GPT 把输入和输出拼成一个序列，”理解输入”本身就变成了预测下一个 token 的过程。一切皆生成。</p>

<p>在书里这一章也算是终于 get 到了答案。</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[上一篇《GPT 图解》笔记：Transformer最后一节提到了： Encoder 能同时“看到”句子中所有位置的信息（左边和右边），适合理解句子。 Decoder 每个位置只能“看到”它之前的位置（左边），未来的词被掩码遮住了，适合推理。 在演进过程中，基于 Encoder 发展出来 BERT，基于 Decoder 发展出了 GPT. 这篇笔记主要记录GPT，主要有： 贪婪解码到集束搜索：贪心即选取局部最优解，集束则是在每一个保留多个高概率候选 token，实现累计最高概率 GPT 组件结构：在 Decoder 基础上做了新增(softmax&amp;linear)和删减(跟编码器的交叉注意力) 训练与推理过程中的自回归：前者利用右移输入和 Causal Mask，在并行计算中模拟自回归约束；后者则是基于预测值继续预测 1. 贪婪解码器 上一篇笔记是这么预测的： # 创建一个大小为 1 的批次，目标语言序列 dec_inputs 在测试阶段，仅包含句子开始符号 &lt;sos&gt; enc_inputs, dec_inputs, target_batch = corpus.make_batch(batch_size=1,test_batch=True) print("编码器输入 :", enc_inputs) # 打印编码器输入 print("解码器输入 :", dec_inputs) # 打印解码器输入 print("目标数据 :", target_batch) # 打印目标数据 predict, enc_self_attns, dec_self_attns, dec_enc_attns = model(enc_inputs, dec_inputs) # 用模型进行翻译 predict = predict.view(-1, len(corpus.tgt_vocab)) # 将预测结果维度重塑 predict = predict.data.max(1, keepdim=True)[1] # 找到每个位置概率最大的词汇的索引 # 解码预测的输出，将所预测的目标句子中的索引转换为单词 translated_sentence = [corpus.tgt_idx2word[idx.item()] for idx in predict.squeeze()] # 将输入的源语言句子中的索引转换为单词 input_sentence = ' '.join([corpus.src_idx2word[idx.item()] for idx in enc_inputs[0]]) print(input_sentence, '-&gt;', translated_sentence) # 打印原始句子和翻译后的句子 初始时，dec_inputs = [&lt;sos&gt; &lt;pad&gt; &lt;pad&gt; &lt;pad&gt; &lt;pad&gt;]。 调用 model(enc_inputs, dec_inputs)执行 1 次 forward 预测。forward 的内部流程： Transformer.forward() ├── encoder(enc_inputs) 1次 ├── decoder(dec_inputs, enc_inputs, enc_outputs) 1次 └── projection(dec_outputs) 1次 预测过程中形状变化： graph LR A["(1, T, V)"] --&gt;|"predict.view(-1, V)"| B["(T, V)"] B --&gt;|"predict.data.max(1, keepdim=True)[1]"| C["(T, 1)"] C --&gt;|"predict.squeeze()"| D["(T)"] 因为提前构造了完整长度的 dec_inputs，这里一次得到所有位置的预测：token0 token1 token2 … tokenT , 即每个位置最大概率的 token. 贪婪解码器则是一步步预测的，代码： # 定义贪婪解码器函数 def greedy_decoder(model, enc_input, start_symbol): # 对输入数据进行编码，并获得编码器输出及自注意力权重 enc_outputs, enc_self_attns = model.encoder(enc_input) # 初始化解码器输入为全零张量，大小为 (1, 5)，数据类型与 enc_input 一致 dec_input = torch.zeros(1, 5).type_as(enc_input.data) # 设置下一个要解码的符号为开始符号 next_symbol = start_symbol # 循环5次，为解码器输入中的每一个位置填充一个符号 for i in range(0, 5): # 将下一个符号放入解码器输入的当前位置 dec_input[0][i] = next_symbol # 运行解码器，获得解码器输出、解码器自注意力权重和编码器-解码器注意力权重 dec_output, _, _ = model.decoder(dec_input, enc_input, enc_outputs) # 将解码器输出投影到目标词汇空间 projected = model.projection(dec_output) # 找到具有最高概率的下一个单词 prob = projected.squeeze(0).max(dim=-1, keepdim=False)[1] next_word = prob.data[i] # 将找到的下一个单词作为新的符号 next_symbol = next_word.item() # 返回解码器输入，它包含了生成的符号序列 dec_outputs = dec_input return dec_outputs 预测过程中的内部流程: ├── encoder(enc_input) 1次 └── for i in range(T): dec_input[0][i] = ... decoder(dec_input, enc_input, enc_outputs) T次 projection(dec_output) T次 Encoder 只计算一次，Decoder + Projection 重复计算 T 次。 每次循环，取第 i 个位置概率最大的 token，写回 dec_input[0][i]，更新该位置，然后基于新的预测继续预测。 这种基于当前时刻生成的 token，作为下一时刻的输入，继续参与后续 token 的预测的方式，就是典型的自回归（Autoregressive）推理。 注：自回归除了贪婪模式，还有其他例如集束搜索，文章末尾会提到。 这种“预测未来，再利用预测结果继续预测未来”的模式，让我想起之前看的一篇科幻小说 😁，主人公拥有一种能力，可以预测 5 分钟之后要发生的事情，突然有一天他意识到，基于这个能力，他可以预测 5 分钟后他会预测什么，那么他就可以预测 10 分钟后的事情了，以此类推，他就可以预测未来。 2. 搭建简化 GPT 模型 2.1. 组件 图源：《GPT图解-大模型是怎样构建的》 图里各组件的作用: 多头自注意力：QKV 矩阵、记录 token 之间的关注信息 逐位置前馈网络：两个卷积层(标准似乎是两个 Linear)，总之就是两个矩阵 正弦位置编码：生成跟位置信息相关的向量 填充掩码、后续掩码：通过预定义一个矩阵，避免关注到无用信息、未来信息 将上述组件封装为一层的 DecoderLayer，大致流程：Masked Self-Attention → Add&amp;Norm → FFN → Add&amp;Norm，然后叠加多层封装为 Decoder 投影层将 Decoder 输出的特征向量，转为具体的预测词 只有解码器，因此也就去掉了 Decoder 里的 编码器-解码器注意力 层。 书中这一节提到: GPT模型仅包含解码器部分，没有编码器部分。因此，它更适用于无条件文本生成任务，而不是类似机器翻译或问答等需要编码器-解码器结构的任务。 这是早期的观点，我们现在使用 ChatGPT 时，实际上也能翻译的。我理解是原始 Transformer 用 Encoder 理解源序列，再用 Decoder 生成目标序列；GPT 则把“源序列 + 任务指令 + 目标序列”统一看作一个长 Token 序列，通过 Decoder 的自回归生成完成翻译。 也就是后续的 GPT 不再区分翻译、问答、摘要等不同任务，而就看做 token 序列的条件生成问题。 有种一切皆 token 序列，一切皆生成的感觉。 2.2. 构造训练数据 我在很多时候不理解算法，都是卡在了没有理解最开始的数据结构和样例。书里使用了 WikiText2 来构建数据（目前源码在高版本 torchtext 已经跑不通了，可以使用 AI 修复下代码）。 相比之前的 demo，主要的区别有两个： 数据集不再是代码中手动定义的几条句子，而是通过 torchtext 下载 WikiText2 数据集 手动 sentence.split(' ') 改为使用 tokenizer 分词 但是目的是相同的，vocab 含义相同，本质仍然是 token → id 的映射。 接下来具体说明下构造数据的代码流程(注：代码参见原书)。 首先，将每个句子转为 token id 序列：tokens = [ vocab[&lt;sos&gt;], vocab(tokens).., vocab[&lt;eos&gt;] ]。 然后，__getitem__ 返回两个 Tensor：source = tokens[:-1]，target = tokens[1:]。即目标序列相对于输入右移一位，用于训练下一个 token 预测（右移的具体原因说明见2.3）。 由于一个 batch 中句子长度不一致，还需要两步补齐： pad_sequence：创建二维张量，第一维是句子个数，第二维是最大序列长度，用 padding_value 补齐 collate_fn：计算 sources 和 targets 中最大长度，分别补齐，确保两者长度一致 这样大小整齐的数据，就可以用来训练了。 2.3. 训练过程中的自回归 训练中的自回归离不开右移操作。 假设有一个句子序列为&lt;sos&gt; I love apple &lt;eos&gt; 输入序列 source: &lt;sos&gt; I love apple 目标序列 target: I love apple &lt;eos&gt; source 是 Decoder 输入，target 是 Decoder 预测的目标值。 仍以source = &lt;sos&gt; I love apple为例，在 Decoder ，每个位置都会产生一个 Query： Q0 Q1 Q2 Q3 | | | | &lt;sos&gt; I love apple Causal Mask 作用在每个 Query 上，同时遮挡住了后续的 token，效果上即： Q0: 只能看 &lt;sos&gt; Q1: 只能看 &lt;sos&gt;, I Q2: 只能看 &lt;sos&gt;, I, love Q3: 只能看 &lt;sos&gt;, I, love, apple 这就是训练过程中的自回归 Decoder 在计算时，可以并行计算这个四个任务。输出：outputs.shape = (B, T, V)，例如这里就是(1, 4, vocab_size) outputs 表示每个位置上的词的概率，例如: outputs[0,0,:] -&gt; 预测 I 的概率分布 outputs[0,1,:] -&gt; love 的 outputs[0,2,:] -&gt; apple 的 outputs[0,3,:] -&gt; &lt;eos&gt; 的 再跟target = ['I', 'love', 'apple', '&lt;eos&gt;']，逐位置计算CrossEntropyLoss 3. 预测 3.1. 贪婪解码 如果还是使用贪婪的方式，每次选取概率最大的 token，代码如下： # 测试文本生成 def generate_text(model, input_str, max_len=50): model.eval() # 将模型设置为评估（测试）模式，关闭dropout和batch normalization等训练相关的层 # 将输入字符串中的每个token 转换为其在词汇表中的索引 input_tokens = [corpus.vocab[token] for token in input_str] # 创建一个新列表，将输入的tokens复制到输出tokens中，目前只有输入的词 output_tokens = input_tokens.copy() with torch.no_grad(): # 禁用梯度计算，以节省内存并加速测试过程 for _ in range(max_len): # 生成最多max_len个tokens # 将输出的token转换为 PyTorch张量，并增加一个代表批次的维度[1, len(output_tokens)] inputs = torch.LongTensor(output_tokens).unsqueeze(0).to(device) outputs = model(inputs) #输出 logits形状为[1, len(output_tokens), vocab_size] # 在最后一个维度上获取logits中的最大值，并返回其索引（即下一个token） _, next_token = torch.max(outputs[:, -1, :], dim=-1) next_token = next_token.item() # 将张量转换为Python整数 if next_token == corpus.vocab["&lt;eos&gt;"]: break # 如果生成的token是 EOS（结束符），则停止生成过程 output_tokens.append(next_token) # 将生成的tokens添加到output_tokens列表 # 将输出tokens转换回文本字符串 output_str = " ".join([corpus.idx2word[token] for token in output_tokens]) return output_str 　 input_str = ["Python"] # 输入一个词：Python generated_text = generate_text(model, input_str) # 模型根据这个词生成后续文本 print("生成的文本：", generated_text) # 打印预测文本 对应的逻辑图： flowchart LR A["inputs&lt;br&gt;[1, T]"] A --&gt;|"model(inputs)"| B["outputs&lt;br&gt;[1, T, V]"] B --&gt;|"outputs[:, -1, :]"| C["last_logits&lt;br&gt;[1, V]"] C --&gt;|"torch.max"| D["next_token_id&lt;br&gt;scalar"] D --&gt;|"append"| E["inputs&lt;br&gt;[1, T+1]"] E -. repeat .-&gt; A 其中repeat即为自回归的过程。 3.2. 集束搜索 图源：《GPT图解-大模型是怎样构建的》 集束搜索是一种启发式搜索策略。 实际并不复杂，我理解贪心是贪心 1 步，集束就是贪心 N 步，保留多个高概率候选 token，然后取 N 步里最优的路径。 以书中代码为例： # 定义集束搜索的函数 def generate_text_beam_search(model, input_str, max_len=50, beam_width=5): model.eval() # 将模型设置为评估模式，关闭dropout和batch normalization等与训练相关的层 # 将输入字符串中的每个token 转换为其在词汇表中的索引 input_tokens = [vocab[token] for token in input_str.split()] # 创建一个列表，用于存储候选序列 candidates = [(input_tokens, 0.0)] with torch.no_grad(): # 禁用梯度计算，以节省内存并加速测试过程 for _ in range(max_len): # 生成最多max_len个token new_candidates = [] for candidate, candidate_score in candidates: inputs = torch.LongTensor(candidate).unsqueeze(0).to(device) outputs = model(inputs) # 输出 logits形状为[1, len(output_tokens), vocab_size] logits = outputs[:, -1, :] # 只关心最后一个时间步（即最新生成的token）的logits # 找到具有最高分数的前beam_width个token scores, next_tokens = torch.topk(logits, beam_width, dim=-1) final_results = [] # 初始化输出序列 for score, next_token in zip(scores.squeeze(), next_tokens.squeeze()): new_candidate = candidate + [next_token.item()] new_score = candidate_score - score.item() # 使用负数，因为我们需要降序排列 if next_token.item() == vocab["&lt;eos&gt;"]: # 如果生成的token是EOS（结束符），将其添加到最终结果中 final_results.append((new_candidate, new_score)) else: # 将新生成的候选序列添加到新候选列表中 new_candidates.append((new_candidate, new_score)) # 从新候选列表中选择得分最高的beam_width个序列 candidates = sorted(new_candidates, key=lambda x: x[1])[:beam_width] # 选择得分最高的候选序列 best_candidate, _ = sorted(candidates, key=lambda x: x[1])[0] # 将输出的 token 转换回文本字符串 output_str = " ".join([vocab.get_itos()[token] for token in best_candidate if vocab.get_itos()[token] != "&lt;pad&gt;"]) return output_str 　 model.load_state_dict(torch.load('best_model.pth')) # 加载模型 input_str = "my name" # 输入几个词 generated_text = generate_text_beam_search(model, input_str) # 模型根据这些词生成后续文本 print("生成的文本：", generated_text) # 打印生成的文本 代码说明： 对当前的每个候选序列（candidates），计算下一个 token 的概率，取概率最高的 beam_width 个 token，分别追加到该候选序列后面，形成新的候选序列（候选数量最多扩展为 当前候选数 × beam_width）。 对所有新生成的候选序列，计算累计分数（通常是所有 token 的 log 概率之和），保留分数最高的 beam_width 个，作为下一轮的 candidates，继续生成。 注意 2 实现跟图里的小差别，如果按照图里的方式，就是 beam_width^n 的候选数量变化。通过 2，确保了每一个位置预测后，总数量仍然是 beam_width. 整体来看，GPT 的核心思想并不复杂：保留 Transformer Decoder，通过 Causal Mask 实现训练阶段的并行自回归，再通过逐 token 生成完成推理。之前一直有个疑问：只有 Decoder 没有 Encoder，是怎么处理翻译、问答这些需要”理解输入”的任务？看到训练数据构造这一节才明白——GPT 把输入和输出拼成一个序列，”理解输入”本身就变成了预测下一个 token 的过程。一切皆生成。 在书里这一章也算是终于 get 到了答案。]]></summary></entry><entry><title type="html">《GPT 图解》笔记：Transformer</title><link href="https://izualzhy.cn/llm-diagrammatize-transformer" rel="alternate" type="text/html" title="《GPT 图解》笔记：Transformer" /><published>2026-06-07T07:18:45+00:00</published><updated>2026-06-07T07:18:45+00:00</updated><id>https://izualzhy.cn/llm-diagrammatize-transformer</id><content type="html" xml:base="https://izualzhy.cn/llm-diagrammatize-transformer"><![CDATA[<p>这篇笔记主要记录<strong>Transformer</strong></p>

<p>我理解主要有几个变化：</p>
<ol>
  <li>使用注意力机制代替 RNN 的递归状态传递（hidden state），从而摆脱时间步之间的串行依赖，实现并行计算。(Transformer 里完全不使用 RNN)</li>
  <li>引入位置编码，使模型能够感知 token 的位置信息；正弦位置编码还具有便于学习相对位置关系的性质</li>
  <li>注意力包含了自注意力和 Cross-Attention（交叉注意力），两者都是多头注意力</li>
  <li>引入掩码机制，根据用途不同又分为填充掩码（Padding Mask）和因果掩码（Causal Mask）</li>
</ol>

<h2 id="1-attention-is-all-you-need">1. Attention is All You Need</h2>

<p>相比之前笔记<a href="https://izualzhy.cn/llm-diagrammatize-seq2seq-attention">《GPT 图解》笔记：Seq2Seq及点积注意力</a>里的 Seq2Seq，Transformer 最大的变化就是不再需要任何循环神经网络结构，也就是代码里没有<code class="language-plaintext highlighter-rouge">self.rnn = nn.RNN</code>这一层了。</p>

<p>从功能角度，RNN 和 Attention 都是在解决<strong>建模序列中的上下文依赖关系的问题</strong>：如何让单个 token 不只知道自己，还知道上下文。</p>

<p>RNN 的思路是 把历史压缩进 hidden ，而 Attention 则是需要谁的信息, 就直接去读取谁。具体的：</p>
<ol>
  <li>RNN 是通过隐藏状态的递归计算: The → animal → didn’t → cross → … → it ，对应: h1 → h2 → h3 → … → ht ，即一步步把信息传递过来。</li>
  <li>Transformer 则通过注意力机制解决: \( \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V \)</li>
</ol>

<p>核心变化是：不再依赖 hidden state 逐步传递信息，而是利用 Attention 允许每个 token 根据需要动态聚合其它 token 的信息。从而更好地处理长距离依赖，并实现大规模<strong>并行计算</strong>。</p>

<h2 id="2-transformer-架构">2. Transformer 架构</h2>

<figure>
  <img src="/assets/images/gpt-diagrammatize/6-13.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>接下来介绍下其中新出现的一些名词，然后解析该架构。</p>

<h2 id="3-transformer-位置编码">3. Transformer-位置编码</h2>

<p>位置编码在编码器、解码器都存在，位于生成输入向量之后。位置向量与输入向量相加，生成新的表示向量。</p>

<p>RNN 和 Transformer 都有 Embedding 这一层： <code class="language-plaintext highlighter-rouge">embedding = nn.Embedding(vocab_size, hidden_size)</code></p>

<p>流程上也都是： token id → Embedding → token vector</p>

<p>RNN 是顺序计算（h1 → h2 → … → hn），因此计算过程天然包含顺序信息，不需要额外的位置编码。</p>

<p>Transformer 则做不到。</p>

<p>Self-Attention 可以建立 token 之间的两两关联关系（attention），但本身无法区分 token 的先后顺序。例如：
“I love you” 和 “you love I”.</p>

<p>如果只看词向量集合，Self-Attention 本身并不知道谁在前谁在后。</p>

<p>Position Encoding 的作用是向模型显式提供位置信息，从而弥补 Self-Attention 本身<strong>缺乏顺序感知能力的问题</strong>。</p>

<p>具体的，位置编码本身并不会直接决定“谁关注谁”，它只是为每个 token 提供位置信息。真正的关注模式会在训练过程中由 Attention 的参数学习得到的。</p>

<p>例如，在大量语料中，模型可能经常看到类似 “The cat sat on the mat” 这样的结构。由于位置编码告诉模型 “cat” 和 “sat” 之间存在稳定的位置关系，而 Attention 又可以自由学习 token 之间的依赖，训练后模型往往会学到：在理解 “sat” 时，需要关注前面的主语 “cat”。</p>

<p>也就是说：位置编码提供“位置信息”，Attention + 参数学习决定“关注关系”。</p>

<p>那位置编码<strong>是什么？</strong>论文中采用的是正弦位置编码：</p>
<ol>
  <li>偶数维：\( PE(pos,2i)=\sin\left(\frac{pos}{10000^{2i/d_{model}}}\right) \)</li>
  <li>奇数维：\( PE(pos,2i+1)=\cos\left(\frac{pos}{10000^{2i/d_{model}}}\right) \)</li>
</ol>

<p>比如位置 100 的编码是：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">PE</span><span class="p">(</span><span class="mi">100</span><span class="p">)</span> <span class="o">=</span> <span class="p">[</span>
 <span class="n">sin</span><span class="p">(...),</span>   <span class="c1"># 第0维
</span> <span class="n">cos</span><span class="p">(...),</span>   <span class="c1"># 第1维
</span>
 <span class="n">sin</span><span class="p">(...),</span>   <span class="c1"># 第2维
</span> <span class="n">cos</span><span class="p">(...),</span>   <span class="c1"># 第3维
</span>
 <span class="n">sin</span><span class="p">(...),</span>   <span class="c1"># 第4维
</span> <span class="n">cos</span><span class="p">(...),</span>   <span class="c1"># 第5维
</span><span class="p">]</span>
</code></pre></div></div>

<p>即每个位置都会同时拥有 sin 和 cos 两部分信息，放在 embedding 的不同维度里。</p>

<p><strong>为什么</strong>选择这个函数，我的理解是： <br />
Transformer 需要学习的是 Q、K、V 等参数矩阵。 这些参数不应该依赖于某个具体位置，否则模型只能处理训练时见过的位置。例如训练时见过 pos=1000，如果参数与绝对位置绑定，那么推理时遇到 pos=1001 就无法很好泛化。</p>

<p>因此可以推导出对<strong>位置编码函数的要求：除了表示绝对位置外，还需要让模型容易学习“相对位置关系”</strong>。</p>

<p>正弦位置编码恰好具有这样一个性质：对于每个频率对 (sin, cos)，PE(pos + k) 可以表示为 PE(pos) 的线性组合。</p>

<p>即存在一个仅依赖于 k 的变换矩阵，使得：
PE(pos + k) = W(k) · PE(pos)</p>

<p>其中 W(k) 只依赖于相对偏移量 k，而不依赖于具体位置 pos。</p>

<p>因此对于 Attention 来说，相同的相对偏移量总能对应到相同的变换模式。训练过程中，模型就更容易学习到：</p>

<ul>
  <li>距离当前 token 2 个位置的词比较重要</li>
  <li>距离当前 token 10 个位置的词不太重要</li>
</ul>

<p>等相对位置规律。</p>

<p>也正因为学习的是相对位置规律，而不是某个固定位置，所以模型能够泛化到训练时未出现过的序列长度。</p>

<p>可能更简洁的理解：正弦编码既能表示绝对位置，又能通过线性变换表达相对位置关系。</p>

<h2 id="4-transformer-注意力">4. Transformer-注意力</h2>

<p>架构图中有两种注意力:</p>

<ol>
  <li>多头自注意力</li>
  <li>Cross-Attention（交叉注意力）</li>
</ol>

<p>两种注意力最直接的区别：</p>
<ul>
  <li>Self-Attention：Q、K、V都来自同一个地方(编码器中则来自编码器输入、解码器中则来自解码器输入)</li>
  <li>Cross-Attention：Q来自一个地方（解码器），K、V来自另一个地方（编码器）</li>
</ul>

<p>自注意力到多头注意力，主要是引入了多个子空间：</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/6-14.jpg" style="max-width:300px" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<h2 id="5-transformer-encoder">5. Transformer-Encoder</h2>

<p>编码器由多个相同结构的层堆叠而成，每个层包含两个主要步骤：</p>

<ol>
  <li>（多头自注意力 → 残差连接 &amp; 层归一化）
    <ul>
      <li>掩码：使用 填充掩码（Padding Mask），忽略句子中的 [PAD] 占位符</li>
      <li>输入：输入序列的词向量 + 位置编码的结合信息。</li>
    </ul>
  </li>
  <li>（前馈神经网络(FFN) → 残差连接 &amp; 层归一化）
    <ul>
      <li>作用：对自注意力层收集到的上下文信息进行非线性变换和特征提取，让模型学习更复杂的模式。</li>
      <li>输入：经过第一步处理后的特征向量（已融合全局信息）。</li>
      <li>输出：当前编码器层的最终表示，传递给下一编码器层。</li>
    </ul>
  </li>
</ol>

<p>源码可以在作者的 github 找到，这里就不照抄了。梳理 Encoder 流程图及形状变化：</p>

<pre><code class="language-mermaid">flowchart TD

A["enc_inputs&lt;br/&gt;(B, S)&lt;br/&gt;Token ID"]

A
-- "nn.Embedding(vocab_size, d_model)" --&gt;
B["token_embedding&lt;br/&gt;(B, S, D)"]

A
-- "Position Index&lt;br/&gt;1...S" --&gt;
C["pos_indices&lt;br/&gt;(1, S)"]

C
-- "Sin/Cos Position Embedding" --&gt;
D["position_embedding&lt;br/&gt;(1, S, D)"]

B
-- "+" --&gt; E["Embedding Fusion&lt;br/&gt;(B, S, D)&lt;br/&gt;Token + Position"]

D
-- "+" --&gt; E

A
-- "get_attn_pad_mask()" --&gt;
F["enc_self_attn_mask&lt;br/&gt;(B, S, S)"]

subgraph Encoder Layers [Encoder Layer × N]

    G["Layer Input&lt;br/&gt;(B, S, D)"]

    G
    --&gt; H["Multi-Head Self Attention&lt;br/&gt;Q = K = V = Input"]

    F
    --&gt; H

    H
    --&gt; I["Add &amp; Norm&lt;br/&gt;Residual Connection"]

    I
    --&gt; J["Position-wise FFN&lt;br/&gt;D → d_ff → D"]

    J
    --&gt; K["Add &amp; Norm&lt;br/&gt;Residual Connection"]

    K
    --&gt; L["Layer Output&lt;br/&gt;(B, S, D)"]

end

E
--&gt; G

L
--&gt; N["enc_outputs&lt;br/&gt;(B, S, D)&lt;br/&gt;Final Encoder Output"]

H
-. "attn_weights&lt;br/&gt;(B, n_heads, S, S)" .-&gt;
M["Attention Weights"]
</code></pre>

<p>流程：</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">nn.Embedding</code> + 位置编码生成新的向量表示</li>
  <li>增加位置编码，遮挡 enc_inputs 中的 padding 位置</li>
  <li>输入首先经过线性投影得到 Q、K、V，然后通过多头自注意力计算上下文表示(context)和注意力权重(weights)。</li>
  <li>FFN 迭代: \( FFN(x) = W_2(ReLU(W_1x + b_1)) + b_2 \)。注意这一层跟 Attention 层的区别，Attention 层主要更新：W_Q、W_K、W_V、W_O（输入到 Q/K/V 空间的投影矩阵），FFN 层主要更新：W1、W2（以及对应 bias）。Attention 负责决定看谁，FFN 负责决定看到之后如何加工这些信息</li>
</ol>

<p>再深入到 Multi-Head Self Attention ，这里由两部分组成：<code class="language-plaintext highlighter-rouge">ScaledDotProductAttention MultiHeadAttention</code></p>

<p><code class="language-plaintext highlighter-rouge">ScaledDotProductAttention</code>实现缩放点积注意力 :</p>
<ul>
  <li>输入:
    <ul>
      <li>Q K V [batch_size, n_heads, len_q/k/v, dim_q=k/v] (为了实现点积，需约束 dim_q=dim_k)</li>
      <li>attn_mask [batch_size, n_heads, len_q, len_k]（跟 scores/weights 相同，用于忽略注意力分数）  attn_mask</li>
    </ul>
  </li>
  <li>输出:
    <ul>
      <li>上下文向量(context): [batch_size, n_heads, len_q, dim_v]</li>
      <li>注意力分数(weights): [batch_size, n_heads, len_q, len_k]</li>
    </ul>
  </li>
</ul>

<p><code class="language-plaintext highlighter-rouge">MultiHeadAttention</code>则定义了 QKV，同时实现了多个子空间调用<code class="language-plaintext highlighter-rouge">ScaledDotProductAttention</code>的效果。</p>

<p>每个子层都遵循：Sublayer(x) → 层归一化(x + Sublayer(x))</p>

<h2 id="6-transformer-decoder">6. Transformer-Decoder</h2>

<p>解码器也是由 N 个相同结构的层堆叠而成（原论文 N=6），每个层包含三个主要步骤：</p>

<ol>
  <li>（掩码多头自注意力 → 残差连接 &amp; 层归一化）
    <ul>
      <li>掩码：同时使用填充掩码（Padding Mask）和因果掩码（Causal Mask）</li>
      <li>输入：解码器输入序列的词向量与位置编码的融合表示。</li>
      <li>输出：融合历史上下文信息的隐藏表示。</li>
    </ul>
  </li>
  <li>（多头 Cross-Attention（交叉注意力） → 残差连接 &amp; 层归一化）
    <ul>
      <li>原则：利用编码器输出的信息辅助当前 token 的生成。</li>
      <li>掩码：仅使用编码器填充掩码（Encoder Padding Mask）</li>
      <li>输入：</li>
    </ul>
    <ul>
      <li>Query 来自步骤1的输出；</li>
      <li>Key、Value 来自编码器最终输出。<br />
    - 输出：融合源序列信息后的隐藏表示。</li>
    </ul>
  </li>
  <li>（前馈神经网络 FFN → 残差连接 &amp; 层归一化）
    <ul>
      <li>作用：通 Encoder</li>
      <li>输入：经过交叉注意力后的隐藏表示</li>
      <li>输出：当前解码器层的最终表示，传递给下一解码器层</li>
    </ul>
  </li>
</ol>

<p>在自注意里层，相比 Transformer-Encoder，这里多了 Causal Mask，主要是因为解码器采用自回归生成方式，每个位置只能看到当前位置及之前的位置，不能看到未来 token。</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/6-21.jpg" style="max-width:300px" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>书里的图片比较复杂，我理解其实就是一个矩阵，比如原始 dec_inputs 大小是<code class="language-plaintext highlighter-rouge">(T)</code>，这里就是生成了一个<code class="language-plaintext highlighter-rouge">(T, T)</code>的矩阵(流程图里的<code class="language-plaintext highlighter-rouge">dec_self_attn_mask</code>)，对 dec_inputs 的第 i 个 token，按照矩阵第 i 行的数据遮挡，例如：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        K:
        &lt;sos&gt; I love you &lt;eos&gt;

Q &lt;sos&gt;  ✓    ×   ×   ×    ×
Q I      ✓    ✓   ×   ×    ×
Q love   ✓    ✓   ✓   ×    ×
Q you    ✓    ✓   ✓   ✓    ×
Q &lt;eos&gt;  ✓    ✓   ✓   ✓    ✓
</code></pre></div></div>

<p>梳理 Decoder 流程图及形状变化：</p>

<pre><code class="language-mermaid">flowchart TD

%% ===================== ENCODER OUTPUT =====================
N["enc_outputs&lt;br/&gt;(B, S, D)&lt;br/&gt;Final Encoder Output"]

%% ===================== DECODER INPUT =====================
A["dec_inputs&lt;br/&gt;(B, T)&lt;br/&gt;Token ID"]

A
-- "nn.Embedding" --&gt;
B["target_embedding&lt;br/&gt;(B, T, D)"]

A
-- "Position Index 1...T" --&gt;
C["pos_indices&lt;br/&gt;(1, T)"]

C
-- "Sin/Cos Position Embedding" --&gt;
D["position_embedding&lt;br/&gt;(1, T, D)"]

B --&gt; E["Embedding Fusion&lt;br/&gt;(B, T, D)"]
D --&gt; E

%% ===================== MASK =====================
A
-- "get_subsequent_mask()" --&gt;
F["causal_mask&lt;br/&gt;(T, T)"]

A
-- "get_attn_pad_mask()" --&gt;
G["dec_self_attn_mask&lt;br/&gt;(B, T, T)"]

%% ===================== DECODER STACK =====================
subgraph Decoder Layers [Decoder Layer × N]

    H["Layer Input&lt;br/&gt;(B, T, D)"]

    %% -------- masked self attention --------
    H
    --&gt; I["Masked Multi-Head Self-Attention&lt;br/&gt;Q=K=V=Decoder Input"]

    G --&gt; I
    F --&gt; I

    I
    --&gt; J["Add &amp; Norm"]

    %% -------- cross attention --------
    J
    --&gt; K["Cross-Attention&lt;br/&gt;Q=Decoder&lt;br/&gt;K=V=Encoder"]

    N --&gt; K

    K
    --&gt; L["Add &amp; Norm"]

    %% -------- FFN --------
    L
    --&gt; M["Position-wise FFN&lt;br/&gt;D → d_ff → D"]

    M
    --&gt; O["Add &amp; Norm"]

    O
    --&gt; P["Layer Output&lt;br/&gt;(B, T, D)"]

end

E --&gt; H

P --&gt; Q["dec_outputs&lt;br/&gt;(B, T, D)&lt;br/&gt;Final Decoder Output"]

%% ===================== ATTENTION OUTPUTS =====================
I -. "self_attn_weights" .-&gt; S1["Decoder Self-Attn Weights"]

K -. "cross_attn_weights" .-&gt; S2["Cross-Attn Weights"]
</code></pre>

<h2 id="7-transformer">7. Transformer</h2>

<p>Transformer 就是将 Encoder Decoder 结合起来：</p>

<pre><code class="language-mermaid">flowchart TD

A["enc_inputs (B, S)"]
B["dec_inputs (B, T)"]

A --&gt; C["Encoder Layers (N × EncoderLayer)"]
C --&gt; D["enc_outputs (B, S, D)"]

B --&gt; E["Decoder Layers (N × DecoderLayer)"]
D --&gt; E

E --&gt; F["dec_outputs (B, T, D)"]
F --&gt; G["Linear + Softmax"]
G --&gt; H["Output (B, T, V)"]
</code></pre>

<p>Encoder Decoder 有很多相似之处，例如都是分层、都有掩码、注意力等，对比下区别：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">对比维度</th>
      <th style="text-align: left"><strong>编码器</strong></th>
      <th style="text-align: left"><strong>解码器</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>核心步骤</strong></td>
      <td style="text-align: left">2步（自注意力 + FFN）</td>
      <td style="text-align: left">3步（自注意力 + 交叉注意力 + FFN）</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>掩码类型</strong></td>
      <td style="text-align: left">仅填充掩码（Padding Mask）</td>
      <td style="text-align: left">填充掩码 + 因果掩码（Causal Mask）</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>交互对象</strong></td>
      <td style="text-align: left">只和自身输入交互</td>
      <td style="text-align: left">自身输入 + 编码器输出（交叉注意力读编码器）</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>核心目标</strong></td>
      <td style="text-align: left">深度<strong>理解</strong>整个源序列</td>
      <td style="text-align: left"><strong>生成</strong>目标序列，同时对齐源序列</td>
    </tr>
  </tbody>
</table>

<p>注意编码器和解码器都有自注意力（Self-Attention），解码器额外包含交叉注意力（Cross-Attention）。这些注意力层通常都采用<strong>多头注意力（Multi-Head Attention）</strong>实现。</p>

<p>其中各个注意力层用到的掩码有：</p>

<ol>
  <li>编码器-多头自注意力（Encoder Self-Attention）
    <ul>
      <li>Padding Mask: 用于遮挡编码器输入中的无意义 padding 位, 因为 Encoder 可以看到整个输入序列，所以不需要 Causal Mask。形状：<code class="language-plaintext highlighter-rouge">[B,S,S]</code></li>
    </ul>
  </li>
  <li>解码器-多头自注意力（Decoder Self-Attention）
    <ul>
      <li>Padding Mask: 用于遮挡解码器输入中的 padding 位。形状：<code class="language-plaintext highlighter-rouge">[B,T,T]</code></li>
      <li>Causal Mask: 用于遮挡当前位置之后的 token，防止解码器看到未来信息。形状：<code class="language-plaintext highlighter-rouge">[B,T,T]</code></li>
      <li>实际参与计算的是两者合并后的 Mask：<code class="language-plaintext highlighter-rouge">M = M_padding | M_causal</code></li>
    </ul>
  </li>
  <li>解码器-编码器交叉注意力（Cross-Attention）
    <ul>
      <li>Encoder Padding Mask: Query(Q) 来自解码器，Key(K)、Value(V) 来自编码器输出。用于遮挡编码器侧的 padding 位。不涉及 Causal Mask，因为编码器序列是完整可见的。Mask 的作用，对于每个 Decoder Query，决定哪些 Encoder Key 可以被访问，所以形状：<code class="language-plaintext highlighter-rouge">[B,T,S]</code>（dim-2：T 表示 Decoder Query 数量, dim-3: S 表示 Encoder Key 数量，因此可以支持每个 Query，读取需要屏蔽哪些 Encoder Key）</li>
    </ul>
  </li>
</ol>

<p>因此粗略可以得出：Decoder = Encoder + Cross-Attention + Causal Mask</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[这篇笔记主要记录Transformer 我理解主要有几个变化： 使用注意力机制代替 RNN 的递归状态传递（hidden state），从而摆脱时间步之间的串行依赖，实现并行计算。(Transformer 里完全不使用 RNN) 引入位置编码，使模型能够感知 token 的位置信息；正弦位置编码还具有便于学习相对位置关系的性质 注意力包含了自注意力和 Cross-Attention（交叉注意力），两者都是多头注意力 引入掩码机制，根据用途不同又分为填充掩码（Padding Mask）和因果掩码（Causal Mask） 1. Attention is All You Need 相比之前笔记《GPT 图解》笔记：Seq2Seq及点积注意力里的 Seq2Seq，Transformer 最大的变化就是不再需要任何循环神经网络结构，也就是代码里没有self.rnn = nn.RNN这一层了。 从功能角度，RNN 和 Attention 都是在解决建模序列中的上下文依赖关系的问题：如何让单个 token 不只知道自己，还知道上下文。 RNN 的思路是 把历史压缩进 hidden ，而 Attention 则是需要谁的信息, 就直接去读取谁。具体的： RNN 是通过隐藏状态的递归计算: The → animal → didn’t → cross → … → it ，对应: h1 → h2 → h3 → … → ht ，即一步步把信息传递过来。 Transformer 则通过注意力机制解决: \( \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V \) 核心变化是：不再依赖 hidden state 逐步传递信息，而是利用 Attention 允许每个 token 根据需要动态聚合其它 token 的信息。从而更好地处理长距离依赖，并实现大规模并行计算。 2. Transformer 架构 图源：《GPT图解-大模型是怎样构建的》 接下来介绍下其中新出现的一些名词，然后解析该架构。 3. Transformer-位置编码 位置编码在编码器、解码器都存在，位于生成输入向量之后。位置向量与输入向量相加，生成新的表示向量。 RNN 和 Transformer 都有 Embedding 这一层： embedding = nn.Embedding(vocab_size, hidden_size) 流程上也都是： token id → Embedding → token vector RNN 是顺序计算（h1 → h2 → … → hn），因此计算过程天然包含顺序信息，不需要额外的位置编码。 Transformer 则做不到。 Self-Attention 可以建立 token 之间的两两关联关系（attention），但本身无法区分 token 的先后顺序。例如： “I love you” 和 “you love I”. 如果只看词向量集合，Self-Attention 本身并不知道谁在前谁在后。 Position Encoding 的作用是向模型显式提供位置信息，从而弥补 Self-Attention 本身缺乏顺序感知能力的问题。 具体的，位置编码本身并不会直接决定“谁关注谁”，它只是为每个 token 提供位置信息。真正的关注模式会在训练过程中由 Attention 的参数学习得到的。 例如，在大量语料中，模型可能经常看到类似 “The cat sat on the mat” 这样的结构。由于位置编码告诉模型 “cat” 和 “sat” 之间存在稳定的位置关系，而 Attention 又可以自由学习 token 之间的依赖，训练后模型往往会学到：在理解 “sat” 时，需要关注前面的主语 “cat”。 也就是说：位置编码提供“位置信息”，Attention + 参数学习决定“关注关系”。 那位置编码是什么？论文中采用的是正弦位置编码： 偶数维：\( PE(pos,2i)=\sin\left(\frac{pos}{10000^{2i/d_{model}}}\right) \) 奇数维：\( PE(pos,2i+1)=\cos\left(\frac{pos}{10000^{2i/d_{model}}}\right) \) 比如位置 100 的编码是： PE(100) = [ sin(...), # 第0维 cos(...), # 第1维 sin(...), # 第2维 cos(...), # 第3维 sin(...), # 第4维 cos(...), # 第5维 ] 即每个位置都会同时拥有 sin 和 cos 两部分信息，放在 embedding 的不同维度里。 为什么选择这个函数，我的理解是： Transformer 需要学习的是 Q、K、V 等参数矩阵。 这些参数不应该依赖于某个具体位置，否则模型只能处理训练时见过的位置。例如训练时见过 pos=1000，如果参数与绝对位置绑定，那么推理时遇到 pos=1001 就无法很好泛化。 因此可以推导出对位置编码函数的要求：除了表示绝对位置外，还需要让模型容易学习“相对位置关系”。 正弦位置编码恰好具有这样一个性质：对于每个频率对 (sin, cos)，PE(pos + k) 可以表示为 PE(pos) 的线性组合。 即存在一个仅依赖于 k 的变换矩阵，使得： PE(pos + k) = W(k) · PE(pos) 其中 W(k) 只依赖于相对偏移量 k，而不依赖于具体位置 pos。 因此对于 Attention 来说，相同的相对偏移量总能对应到相同的变换模式。训练过程中，模型就更容易学习到： 距离当前 token 2 个位置的词比较重要 距离当前 token 10 个位置的词不太重要 等相对位置规律。 也正因为学习的是相对位置规律，而不是某个固定位置，所以模型能够泛化到训练时未出现过的序列长度。 可能更简洁的理解：正弦编码既能表示绝对位置，又能通过线性变换表达相对位置关系。 4. Transformer-注意力 架构图中有两种注意力: 多头自注意力 Cross-Attention（交叉注意力） 两种注意力最直接的区别： Self-Attention：Q、K、V都来自同一个地方(编码器中则来自编码器输入、解码器中则来自解码器输入) Cross-Attention：Q来自一个地方（解码器），K、V来自另一个地方（编码器） 自注意力到多头注意力，主要是引入了多个子空间： 图源：《GPT图解-大模型是怎样构建的》 5. Transformer-Encoder 编码器由多个相同结构的层堆叠而成，每个层包含两个主要步骤： （多头自注意力 → 残差连接 &amp; 层归一化） 掩码：使用 填充掩码（Padding Mask），忽略句子中的 [PAD] 占位符 输入：输入序列的词向量 + 位置编码的结合信息。 （前馈神经网络(FFN) → 残差连接 &amp; 层归一化） 作用：对自注意力层收集到的上下文信息进行非线性变换和特征提取，让模型学习更复杂的模式。 输入：经过第一步处理后的特征向量（已融合全局信息）。 输出：当前编码器层的最终表示，传递给下一编码器层。 源码可以在作者的 github 找到，这里就不照抄了。梳理 Encoder 流程图及形状变化： flowchart TD A["enc_inputs&lt;br/&gt;(B, S)&lt;br/&gt;Token ID"] A -- "nn.Embedding(vocab_size, d_model)" --&gt; B["token_embedding&lt;br/&gt;(B, S, D)"] A -- "Position Index&lt;br/&gt;1...S" --&gt; C["pos_indices&lt;br/&gt;(1, S)"] C -- "Sin/Cos Position Embedding" --&gt; D["position_embedding&lt;br/&gt;(1, S, D)"] B -- "+" --&gt; E["Embedding Fusion&lt;br/&gt;(B, S, D)&lt;br/&gt;Token + Position"] D -- "+" --&gt; E A -- "get_attn_pad_mask()" --&gt; F["enc_self_attn_mask&lt;br/&gt;(B, S, S)"] subgraph Encoder Layers [Encoder Layer × N] G["Layer Input&lt;br/&gt;(B, S, D)"] G --&gt; H["Multi-Head Self Attention&lt;br/&gt;Q = K = V = Input"] F --&gt; H H --&gt; I["Add &amp; Norm&lt;br/&gt;Residual Connection"] I --&gt; J["Position-wise FFN&lt;br/&gt;D → d_ff → D"] J --&gt; K["Add &amp; Norm&lt;br/&gt;Residual Connection"] K --&gt; L["Layer Output&lt;br/&gt;(B, S, D)"] end E --&gt; G L --&gt; N["enc_outputs&lt;br/&gt;(B, S, D)&lt;br/&gt;Final Encoder Output"] H -. "attn_weights&lt;br/&gt;(B, n_heads, S, S)" .-&gt; M["Attention Weights"] 流程： nn.Embedding + 位置编码生成新的向量表示 增加位置编码，遮挡 enc_inputs 中的 padding 位置 输入首先经过线性投影得到 Q、K、V，然后通过多头自注意力计算上下文表示(context)和注意力权重(weights)。 FFN 迭代: \( FFN(x) = W_2(ReLU(W_1x + b_1)) + b_2 \)。注意这一层跟 Attention 层的区别，Attention 层主要更新：W_Q、W_K、W_V、W_O（输入到 Q/K/V 空间的投影矩阵），FFN 层主要更新：W1、W2（以及对应 bias）。Attention 负责决定看谁，FFN 负责决定看到之后如何加工这些信息 再深入到 Multi-Head Self Attention ，这里由两部分组成：ScaledDotProductAttention MultiHeadAttention ScaledDotProductAttention实现缩放点积注意力 : 输入: Q K V [batch_size, n_heads, len_q/k/v, dim_q=k/v] (为了实现点积，需约束 dim_q=dim_k) attn_mask [batch_size, n_heads, len_q, len_k]（跟 scores/weights 相同，用于忽略注意力分数） attn_mask 输出: 上下文向量(context): [batch_size, n_heads, len_q, dim_v] 注意力分数(weights): [batch_size, n_heads, len_q, len_k] MultiHeadAttention则定义了 QKV，同时实现了多个子空间调用ScaledDotProductAttention的效果。 每个子层都遵循：Sublayer(x) → 层归一化(x + Sublayer(x)) 6. Transformer-Decoder 解码器也是由 N 个相同结构的层堆叠而成（原论文 N=6），每个层包含三个主要步骤： （掩码多头自注意力 → 残差连接 &amp; 层归一化） 掩码：同时使用填充掩码（Padding Mask）和因果掩码（Causal Mask） 输入：解码器输入序列的词向量与位置编码的融合表示。 输出：融合历史上下文信息的隐藏表示。 （多头 Cross-Attention（交叉注意力） → 残差连接 &amp; 层归一化） 原则：利用编码器输出的信息辅助当前 token 的生成。 掩码：仅使用编码器填充掩码（Encoder Padding Mask） 输入： Query 来自步骤1的输出； Key、Value 来自编码器最终输出。 - 输出：融合源序列信息后的隐藏表示。 （前馈神经网络 FFN → 残差连接 &amp; 层归一化） 作用：通 Encoder 输入：经过交叉注意力后的隐藏表示 输出：当前解码器层的最终表示，传递给下一解码器层 在自注意里层，相比 Transformer-Encoder，这里多了 Causal Mask，主要是因为解码器采用自回归生成方式，每个位置只能看到当前位置及之前的位置，不能看到未来 token。 图源：《GPT图解-大模型是怎样构建的》 书里的图片比较复杂，我理解其实就是一个矩阵，比如原始 dec_inputs 大小是(T)，这里就是生成了一个(T, T)的矩阵(流程图里的dec_self_attn_mask)，对 dec_inputs 的第 i 个 token，按照矩阵第 i 行的数据遮挡，例如： K: &lt;sos&gt; I love you &lt;eos&gt; Q &lt;sos&gt; ✓ × × × × Q I ✓ ✓ × × × Q love ✓ ✓ ✓ × × Q you ✓ ✓ ✓ ✓ × Q &lt;eos&gt; ✓ ✓ ✓ ✓ ✓ 梳理 Decoder 流程图及形状变化： flowchart TD %% ===================== ENCODER OUTPUT ===================== N["enc_outputs&lt;br/&gt;(B, S, D)&lt;br/&gt;Final Encoder Output"] %% ===================== DECODER INPUT ===================== A["dec_inputs&lt;br/&gt;(B, T)&lt;br/&gt;Token ID"] A -- "nn.Embedding" --&gt; B["target_embedding&lt;br/&gt;(B, T, D)"] A -- "Position Index 1...T" --&gt; C["pos_indices&lt;br/&gt;(1, T)"] C -- "Sin/Cos Position Embedding" --&gt; D["position_embedding&lt;br/&gt;(1, T, D)"] B --&gt; E["Embedding Fusion&lt;br/&gt;(B, T, D)"] D --&gt; E %% ===================== MASK ===================== A -- "get_subsequent_mask()" --&gt; F["causal_mask&lt;br/&gt;(T, T)"] A -- "get_attn_pad_mask()" --&gt; G["dec_self_attn_mask&lt;br/&gt;(B, T, T)"] %% ===================== DECODER STACK ===================== subgraph Decoder Layers [Decoder Layer × N] H["Layer Input&lt;br/&gt;(B, T, D)"] %% -------- masked self attention -------- H --&gt; I["Masked Multi-Head Self-Attention&lt;br/&gt;Q=K=V=Decoder Input"] G --&gt; I F --&gt; I I --&gt; J["Add &amp; Norm"] %% -------- cross attention -------- J --&gt; K["Cross-Attention&lt;br/&gt;Q=Decoder&lt;br/&gt;K=V=Encoder"] N --&gt; K K --&gt; L["Add &amp; Norm"] %% -------- FFN -------- L --&gt; M["Position-wise FFN&lt;br/&gt;D → d_ff → D"] M --&gt; O["Add &amp; Norm"] O --&gt; P["Layer Output&lt;br/&gt;(B, T, D)"] end E --&gt; H P --&gt; Q["dec_outputs&lt;br/&gt;(B, T, D)&lt;br/&gt;Final Decoder Output"] %% ===================== ATTENTION OUTPUTS ===================== I -. "self_attn_weights" .-&gt; S1["Decoder Self-Attn Weights"] K -. "cross_attn_weights" .-&gt; S2["Cross-Attn Weights"] 7. Transformer Transformer 就是将 Encoder Decoder 结合起来： flowchart TD A["enc_inputs (B, S)"] B["dec_inputs (B, T)"] A --&gt; C["Encoder Layers (N × EncoderLayer)"] C --&gt; D["enc_outputs (B, S, D)"] B --&gt; E["Decoder Layers (N × DecoderLayer)"] D --&gt; E E --&gt; F["dec_outputs (B, T, D)"] F --&gt; G["Linear + Softmax"] G --&gt; H["Output (B, T, V)"] Encoder Decoder 有很多相似之处，例如都是分层、都有掩码、注意力等，对比下区别： 对比维度 编码器 解码器 核心步骤 2步（自注意力 + FFN） 3步（自注意力 + 交叉注意力 + FFN） 掩码类型 仅填充掩码（Padding Mask） 填充掩码 + 因果掩码（Causal Mask） 交互对象 只和自身输入交互 自身输入 + 编码器输出（交叉注意力读编码器） 核心目标 深度理解整个源序列 生成目标序列，同时对齐源序列 注意编码器和解码器都有自注意力（Self-Attention），解码器额外包含交叉注意力（Cross-Attention）。这些注意力层通常都采用多头注意力（Multi-Head Attention）实现。 其中各个注意力层用到的掩码有： 编码器-多头自注意力（Encoder Self-Attention） Padding Mask: 用于遮挡编码器输入中的无意义 padding 位, 因为 Encoder 可以看到整个输入序列，所以不需要 Causal Mask。形状：[B,S,S] 解码器-多头自注意力（Decoder Self-Attention） Padding Mask: 用于遮挡解码器输入中的 padding 位。形状：[B,T,T] Causal Mask: 用于遮挡当前位置之后的 token，防止解码器看到未来信息。形状：[B,T,T] 实际参与计算的是两者合并后的 Mask：M = M_padding | M_causal 解码器-编码器交叉注意力（Cross-Attention） Encoder Padding Mask: Query(Q) 来自解码器，Key(K)、Value(V) 来自编码器输出。用于遮挡编码器侧的 padding 位。不涉及 Causal Mask，因为编码器序列是完整可见的。Mask 的作用，对于每个 Decoder Query，决定哪些 Encoder Key 可以被访问，所以形状：[B,T,S]（dim-2：T 表示 Decoder Query 数量, dim-3: S 表示 Encoder Key 数量，因此可以支持每个 Query，读取需要屏蔽哪些 Encoder Key） 因此粗略可以得出：Decoder = Encoder + Cross-Attention + Causal Mask]]></summary></entry><entry><title type="html">《GPT 图解》笔记：QKV、多头注意力及掩码</title><link href="https://izualzhy.cn/llm-diagrammatize-attention-qkv-multi-mask" rel="alternate" type="text/html" title="《GPT 图解》笔记：QKV、多头注意力及掩码" /><published>2026-05-30T01:09:06+00:00</published><updated>2026-05-30T01:09:06+00:00</updated><id>https://izualzhy.cn/llm-diagrammatize-attention-qkv-multi-mask</id><content type="html" xml:base="https://izualzhy.cn/llm-diagrammatize-attention-qkv-multi-mask"><![CDATA[<p>这篇笔记主要记录：</p>

<ol>
  <li><strong>注意力机制中的 Q K V</strong>: 注意力机制中的 QKV 代表了什么以及为什么.</li>
  <li><strong>多头注意力、掩码</strong>: 多头注意力使用多个子空间捕捉不同的特征；掩码则手动把不需要关注的信息权重设为近似“0”</li>
</ol>

<h2 id="1-注意力机制中的-q-k-v">1. 注意力机制中的 Q K V</h2>

<p>以<a href="https://izualzhy.cn/llm-diagrammatize-seq2seq-attention">Seq2Seq及点积注意力</a>第 3 节的流程图为例: <strong>rnn_output → Query</strong>，<strong>enc_output → Key</strong>，<strong>enc_output → Value</strong></p>

<p>逻辑上这么理解：</p>
<ol class="info">
  <li>Query: 找什么，作为搜索条件</li>
  <li>Key: token 用于被匹配的特征</li>
  <li>Value: 需要读取的信息，即内容</li>
</ol>

<p>我读到这里时，最大的困惑是 Key Value 既然含义不同，怎么还都能用 enc_output 表示？这里还是例子为了入门简化带来的误解。实际情况 QKV 是由经过不同的矩阵变换得到的，所以才能够表述上述不同的特征。</p>

<p>QKV 变换的过程：</p>
<ol>
  <li>\( QK^T \) 得到相似度分数（raw scores / raw weights）</li>
  <li>softmax(raw_weights) 得到注意力权重（attention weights）: \( \text{attn} = \text{softmax}(QK^T) \)</li>
  <li>attn_weights @ V 得到新的上下文表示（context vector / attention output）: \( \text{output} = \text{attn} \cdot V \)</li>
</ol>

<p>整体公式（省略缩放因子 \( \sqrt{d_k} \) 以简化表达）：</p>

\[\text{Attention}(Q, K, V) = \text{softmax}(QK^T) \cdot V\]

<p>假定初时形状:
\( Q.\text{shape} = (\text{batch_size}, Q_y, Q_z) \)
\( K.\text{shape} = (\text{batch_size}, K_y, K_z) \)
\( V.\text{shape} = (\text{batch_size}, V_y, V_z) \)</p>

<p>那么根据如上过程就存在如下约束：</p>
<ol>
  <li>\( \text{attn} = \text{softmax}(QK^T) \): attn.shape = (batch_size, Qy, Ky), 要求 \( Q_z = K_z \)，即 Q K 的特征维度相同</li>
  <li>\( \text{output} = \text{attn} \cdot V \): 要求 \( K_y = V_y \)，即 K V 的序列长度维度相同</li>
</ol>

<p>如果 QKV 来自于同一个输入序列，就是自注意力(self-attention)机制，则天然满足 \( Q_y = K_y = V_y \)。</p>

<p><strong>那为什么不直接使用 x，而是要引入 3 个线性层？</strong></p>

<p>比如对应句子： The animal didn’t cross the street because it was tired. 我们需要知道：it -&gt; animal 而不是 street ，但是显然 Embedding 后：x_it x_animal x_street 只是普通向量，没有产生这个联系。</p>

<p>那就会希望 Q K V 能够学出来：</p>
<ol>
  <li>Q(it) ≈ [正在寻找“可指代对象”, 单数名词, 有生命]</li>
  <li>K(animal) ≈ [单数名词, 有生命, 可被代词指代]</li>
  <li>K(street) ≈ [地点, 无生命]
就可以满足：Q(it) · K(animal) &gt;&gt; Q(it) · K(street)<br />
同时希望 V 能学到：V(animal) ≈ [动物语义, 生物属性, 主语信息, 上下文信息]</li>
</ol>

<p><strong>Q K V 不同，所以即使是相同来源，也要经过不同的变换，使得矩阵能够学到不同的特征</strong>。</p>

<h2 id="2-多头注意力掩码">2. 多头注意力、掩码</h2>

<figure>
  <img src="/assets/images/gpt-diagrammatize/5-12.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>多头注意力，就是先分成多个子空间再合并。其中每个子空间有单独的 QKV，本质上还是刚才的注意力代码。</p>

<p>我的疑问<strong>是为什么不是一个空间更高维度，而是多个子空间？</strong></p>

<p>关于这点没有实战经验，chatgpt 的解释我觉得还是可以说通的：</p>
<ol>
  <li>不同子空间，容易衍生出不同的 attention matrix，直白的理解就是能从不同角度学习</li>
  <li>过高维度不容易梯度优化 <br />
当然也可能出现多个子空间最后都学成一样的情况，叫做 attention head collapse，但通常不会，因为初始参数完全是随机的。</li>
</ol>

<figure>
  &lt;img src=”/assets/images/gpt-diagrammatize/5-14.jpg”/&gt;
  &lt;figcaption class=”img-source”&gt;图源：《GPT图解-大模型是怎样构建的》&lt;/figcaption&gt;
</figure>

<p>掩码，则是把不需要关注的信息权重设为近似”0”</p>

<p><strong>为什么这么做？</strong> 有些位置不重要，比如 &lt;pad&gt; ，可能也还有其他 mask     <br />
<strong>怎么做的：</strong>：在 softmax 前人为降低某些位置分数（接近负无穷），softmax 后接近 0  <br />
我理解使用矩阵有两个原因：多种 mask 相加方便、矩阵计算适合张量并行优化</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[这篇笔记主要记录： 注意力机制中的 Q K V: 注意力机制中的 QKV 代表了什么以及为什么. 多头注意力、掩码: 多头注意力使用多个子空间捕捉不同的特征；掩码则手动把不需要关注的信息权重设为近似“0” 1. 注意力机制中的 Q K V 以Seq2Seq及点积注意力第 3 节的流程图为例: rnn_output → Query，enc_output → Key，enc_output → Value 逻辑上这么理解： Query: 找什么，作为搜索条件 Key: token 用于被匹配的特征 Value: 需要读取的信息，即内容 我读到这里时，最大的困惑是 Key Value 既然含义不同，怎么还都能用 enc_output 表示？这里还是例子为了入门简化带来的误解。实际情况 QKV 是由经过不同的矩阵变换得到的，所以才能够表述上述不同的特征。 QKV 变换的过程： \( QK^T \) 得到相似度分数（raw scores / raw weights） softmax(raw_weights) 得到注意力权重（attention weights）: \( \text{attn} = \text{softmax}(QK^T) \) attn_weights @ V 得到新的上下文表示（context vector / attention output）: \( \text{output} = \text{attn} \cdot V \) 整体公式（省略缩放因子 \( \sqrt{d_k} \) 以简化表达）： \[\text{Attention}(Q, K, V) = \text{softmax}(QK^T) \cdot V\] 假定初时形状: \( Q.\text{shape} = (\text{batch_size}, Q_y, Q_z) \) \( K.\text{shape} = (\text{batch_size}, K_y, K_z) \) \( V.\text{shape} = (\text{batch_size}, V_y, V_z) \) 那么根据如上过程就存在如下约束： \( \text{attn} = \text{softmax}(QK^T) \): attn.shape = (batch_size, Qy, Ky), 要求 \( Q_z = K_z \)，即 Q K 的特征维度相同 \( \text{output} = \text{attn} \cdot V \): 要求 \( K_y = V_y \)，即 K V 的序列长度维度相同 如果 QKV 来自于同一个输入序列，就是自注意力(self-attention)机制，则天然满足 \( Q_y = K_y = V_y \)。 那为什么不直接使用 x，而是要引入 3 个线性层？ 比如对应句子： The animal didn’t cross the street because it was tired. 我们需要知道：it -&gt; animal 而不是 street ，但是显然 Embedding 后：x_it x_animal x_street 只是普通向量，没有产生这个联系。 那就会希望 Q K V 能够学出来： Q(it) ≈ [正在寻找“可指代对象”, 单数名词, 有生命] K(animal) ≈ [单数名词, 有生命, 可被代词指代] K(street) ≈ [地点, 无生命] 就可以满足：Q(it) · K(animal) &gt;&gt; Q(it) · K(street) 同时希望 V 能学到：V(animal) ≈ [动物语义, 生物属性, 主语信息, 上下文信息] Q K V 不同，所以即使是相同来源，也要经过不同的变换，使得矩阵能够学到不同的特征。 2. 多头注意力、掩码 图源：《GPT图解-大模型是怎样构建的》 多头注意力，就是先分成多个子空间再合并。其中每个子空间有单独的 QKV，本质上还是刚才的注意力代码。 我的疑问是为什么不是一个空间更高维度，而是多个子空间？ 关于这点没有实战经验，chatgpt 的解释我觉得还是可以说通的： 不同子空间，容易衍生出不同的 attention matrix，直白的理解就是能从不同角度学习 过高维度不容易梯度优化 当然也可能出现多个子空间最后都学成一样的情况，叫做 attention head collapse，但通常不会，因为初始参数完全是随机的。 &lt;img src=”/assets/images/gpt-diagrammatize/5-14.jpg”/&gt; &lt;figcaption class=”img-source”&gt;图源：《GPT图解-大模型是怎样构建的》&lt;/figcaption&gt; 掩码，则是把不需要关注的信息权重设为近似”0” 为什么这么做？ 有些位置不重要，比如 &lt;pad&gt; ，可能也还有其他 mask 怎么做的：：在 softmax 前人为降低某些位置分数（接近负无穷），softmax 后接近 0 我理解使用矩阵有两个原因：多种 mask 相加方便、矩阵计算适合张量并行优化]]></summary></entry><entry><title type="html">《GPT 图解》笔记：Seq2Seq及点积注意力</title><link href="https://izualzhy.cn/llm-diagrammatize-seq2seq-attention" rel="alternate" type="text/html" title="《GPT 图解》笔记：Seq2Seq及点积注意力" /><published>2026-05-24T11:10:44+00:00</published><updated>2026-05-24T11:10:44+00:00</updated><id>https://izualzhy.cn/llm-diagrammatize-seq2seq-attention</id><content type="html" xml:base="https://izualzhy.cn/llm-diagrammatize-seq2seq-attention"><![CDATA[<p>这篇笔记主要记录：</p>

<ol>
  <li><strong>Seq2Seq</strong>: 机器翻译这类 序列到序列 的任务，需要先理解完整输入再生成输出, 而非 RNN 那样仅预测下一个 token.</li>
  <li><strong>点积注意力</strong>: 计算两个矩阵 X1 X2 的相似度，然后进一步计算 X1 融合了 X2 信息后的矩阵</li>
  <li><strong>带点积注意力的Seq2Seq</strong>: 增加点积注意力，将编码器、解码器的隐藏状态联系起来，也就是能关注到输入序列中的重要部分，从而更好地捕捉上下文相关性</li>
</ol>

<h2 id="1-seq2seq">1. Seq2Seq</h2>

<p>上一篇笔记讲到了 RNN, RNNLM 可以输入前面的词预测下一个词，例如 I love deep → learning ，即 sequence → next token</p>

<p>机器翻译则是另外一个形式，要求输入:I love you 输出: 我爱你，即<strong>一个输入序列 → 一个输出序列（sequence-to-sequence）</strong>，因此后来发展出了 Encoder-Decoder（Seq2Seq）结构。</p>

<p>Seq2Seq 会先通过 Encoder 将输入序列编码为上下文表示（hidden state），再将其作为 Decoder 的初始 hidden state，逐步生成输出序列：</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/4-4.jpg" style="max-width:500px" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>为了简化说明，编码器、解码器使用最基础的 nn.RNN 实现:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span> <span class="c1"># 导入 torch.nn 库
# 定义编码器类，继承自 nn.Module
</span><span class="k">class</span> <span class="nc">Encoder</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">input_size</span><span class="p">,</span> <span class="n">hidden_size</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">Encoder</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span>       
        <span class="bp">self</span><span class="p">.</span><span class="n">hidden_size</span> <span class="o">=</span> <span class="n">hidden_size</span> <span class="c1"># 设置隐藏层大小       
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">embedding</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">input_size</span><span class="p">,</span> <span class="n">hidden_size</span><span class="p">)</span> <span class="c1"># 创建词嵌入层       
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">rnn</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">RNN</span><span class="p">(</span><span class="n">hidden_size</span><span class="p">,</span> <span class="n">hidden_size</span><span class="p">,</span> <span class="n">batch_first</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span> <span class="c1"># 创建 RNN 层    
</span>    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">inputs</span><span class="p">,</span> <span class="n">hidden</span><span class="p">):</span> <span class="c1"># 前向传播函数
</span>        <span class="n">embedded</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">embedding</span><span class="p">(</span><span class="n">inputs</span><span class="p">)</span> <span class="c1"># 将输入转换为嵌入向量       
</span>        <span class="n">output</span><span class="p">,</span> <span class="n">hidden</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">rnn</span><span class="p">(</span><span class="n">embedded</span><span class="p">,</span> <span class="n">hidden</span><span class="p">)</span> <span class="c1"># 将嵌入向量输入 RNN 层并获取输出
</span>        <span class="k">return</span> <span class="n">output</span><span class="p">,</span> <span class="n">hidden</span>

<span class="c1"># 定义解码器类，继承自 nn.Module
</span><span class="k">class</span> <span class="nc">Decoder</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">hidden_size</span><span class="p">,</span> <span class="n">output_size</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">Decoder</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span>       
        <span class="bp">self</span><span class="p">.</span><span class="n">hidden_size</span> <span class="o">=</span> <span class="n">hidden_size</span> <span class="c1"># 设置隐藏层大小       
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">embedding</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">output_size</span><span class="p">,</span> <span class="n">hidden_size</span><span class="p">)</span> <span class="c1"># 创建词嵌入层
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">rnn</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">RNN</span><span class="p">(</span><span class="n">hidden_size</span><span class="p">,</span> <span class="n">hidden_size</span><span class="p">,</span> <span class="n">batch_first</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  <span class="c1"># 创建 RNN 层       
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">out</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">hidden_size</span><span class="p">,</span> <span class="n">output_size</span><span class="p">)</span> <span class="c1"># 创建线性输出层    
</span>    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">inputs</span><span class="p">,</span> <span class="n">hidden</span><span class="p">):</span>  <span class="c1"># 前向传播函数     
</span>        <span class="n">embedded</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">embedding</span><span class="p">(</span><span class="n">inputs</span><span class="p">)</span> <span class="c1"># 将输入转换为嵌入向量       
</span>        <span class="n">output</span><span class="p">,</span> <span class="n">hidden</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">rnn</span><span class="p">(</span><span class="n">embedded</span><span class="p">,</span> <span class="n">hidden</span><span class="p">)</span> <span class="c1"># 将嵌入向量输入 RNN 层并获取输出       
</span>        <span class="n">output</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">out</span><span class="p">(</span><span class="n">output</span><span class="p">)</span> <span class="c1"># 使用线性层生成最终输出
</span>        <span class="k">return</span> <span class="n">output</span><span class="p">,</span> <span class="n">hidden</span>
<span class="n">n_hidden</span> <span class="o">=</span> <span class="mi">128</span> <span class="c1"># 设置隐藏层数量
# 创建编码器和解码器
</span><span class="n">encoder</span> <span class="o">=</span> <span class="n">Encoder</span><span class="p">(</span><span class="n">voc_size_cn</span><span class="p">,</span> <span class="n">n_hidden</span><span class="p">)</span>
<span class="n">decoder</span> <span class="o">=</span> <span class="n">Decoder</span><span class="p">(</span><span class="n">n_hidden</span><span class="p">,</span> <span class="n">voc_size_en</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">' 编码器结构：'</span><span class="p">,</span> <span class="n">encoder</span><span class="p">)</span>  <span class="c1"># 打印编码器的结构
</span><span class="k">print</span><span class="p">(</span><span class="s">' 解码器结构：'</span><span class="p">,</span> <span class="n">decoder</span><span class="p">)</span>  <span class="c1"># 打印解码器的结构
</span></code></pre></div></div>

<p>定义 Seq2Seq 类，串联起来编码、解码过程：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Seq2Seq</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">encoder</span><span class="p">,</span> <span class="n">decoder</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">Seq2Seq</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span>
        <span class="c1"># 初始化编码器和解码器
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">encoder</span> <span class="o">=</span> <span class="n">encoder</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">decoder</span> <span class="o">=</span> <span class="n">decoder</span>
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">enc_input</span><span class="p">,</span> <span class="n">hidden</span><span class="p">,</span> <span class="n">dec_input</span><span class="p">):</span>    <span class="c1"># 定义前向传播函数
</span>        <span class="c1"># 使输入序列通过编码器并获取输出和隐藏状态
</span>        <span class="n">encoder_output</span><span class="p">,</span> <span class="n">encoder_hidden</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">encoder</span><span class="p">(</span><span class="n">enc_input</span><span class="p">,</span> <span class="n">hidden</span><span class="p">)</span>
        <span class="c1"># 将编码器的隐藏状态传递给解码器作为初始隐藏状态
</span>        <span class="n">decoder_hidden</span> <span class="o">=</span> <span class="n">encoder_hidden</span>
        <span class="c1"># 使解码器输入（目标序列）通过解码器并获取输出
</span>        <span class="n">decoder_output</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">decoder</span><span class="p">(</span><span class="n">dec_input</span><span class="p">,</span> <span class="n">decoder_hidden</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">decoder_output</span>

<span class="c1"># 创建 Seq2Seq 架构
</span><span class="n">model</span> <span class="o">=</span> <span class="n">Seq2Seq</span><span class="p">(</span><span class="n">encoder</span><span class="p">,</span> <span class="n">decoder</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">'S2S 模型结构：'</span><span class="p">,</span> <span class="n">model</span><span class="p">)</span>  <span class="c1"># 打印模型的结构
</span></code></pre></div></div>

<p>定义训练过程，完成后的 model 即可以用来预测了：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">train_seq2seq</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">criterion</span><span class="p">,</span> <span class="n">optimizer</span><span class="p">,</span> <span class="n">epochs</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">epoch</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">epochs</span><span class="p">):</span>
       <span class="n">encoder_input</span><span class="p">,</span> <span class="n">decoder_input</span><span class="p">,</span> <span class="n">target</span> <span class="o">=</span> <span class="n">make_data</span><span class="p">(</span><span class="n">sentences</span><span class="p">)</span> <span class="c1"># 训练数据的创建
</span>       <span class="n">hidden</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">encoder_input</span><span class="p">.</span><span class="n">size</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="n">n_hidden</span><span class="p">)</span> <span class="c1"># 初始化隐藏状态      
</span>       <span class="n">optimizer</span><span class="p">.</span><span class="n">zero_grad</span><span class="p">()</span><span class="c1"># 梯度清零        
</span>       <span class="n">output</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="n">encoder_input</span><span class="p">,</span> <span class="n">hidden</span><span class="p">,</span> <span class="n">decoder_input</span><span class="p">)</span> <span class="c1"># 获取模型输出        
</span>       <span class="n">loss</span> <span class="o">=</span> <span class="n">criterion</span><span class="p">(</span><span class="n">output</span><span class="p">.</span><span class="n">view</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">voc_size_en</span><span class="p">),</span> <span class="n">target</span><span class="p">.</span><span class="n">view</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">))</span> <span class="c1"># 计算损失        
</span>       <span class="k">if</span> <span class="p">(</span><span class="n">epoch</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">%</span> <span class="mi">40</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span> <span class="c1"># 打印损失
</span>          <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Epoch: </span><span class="si">{</span><span class="n">epoch</span> <span class="o">+</span> <span class="mi">1</span><span class="si">:</span><span class="mi">04</span><span class="n">d</span><span class="si">}</span><span class="s"> cost = </span><span class="si">{</span><span class="n">loss</span><span class="si">:</span><span class="p">.</span><span class="mi">6</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>         
       <span class="n">loss</span><span class="p">.</span><span class="n">backward</span><span class="p">()</span><span class="c1"># 反向传播        
</span>       <span class="n">optimizer</span><span class="p">.</span><span class="n">step</span><span class="p">()</span><span class="c1"># 更新参数
</span>
<span class="c1"># 训练模型
</span><span class="n">epochs</span> <span class="o">=</span> <span class="mi">400</span> <span class="c1"># 训练轮次
</span><span class="n">criterion</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">CrossEntropyLoss</span><span class="p">()</span> <span class="c1"># 损失函数
</span><span class="n">optimizer</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">optim</span><span class="p">.</span><span class="n">Adam</span><span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">parameters</span><span class="p">(),</span> <span class="n">lr</span><span class="o">=</span><span class="mf">0.001</span><span class="p">)</span> <span class="c1"># 优化器
</span><span class="n">train_seq2seq</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">criterion</span><span class="p">,</span> <span class="n">optimizer</span><span class="p">,</span> <span class="n">epochs</span><span class="p">)</span> <span class="c1"># 调用函数训练模型
</span></code></pre></div></div>

<p>解释前面的代码：</p>

<pre><code class="language-mermaid">flowchart TD

subgraph Encoder
    A["enc_input&lt;br/&gt;(B, S)"]

    A
    -- "nn.Embedding(input_size, H)" --&gt;
    B["embedded&lt;br/&gt;(B, S, H)"]

    B
    -- "nn.RNN(H, H)" --&gt;
    C["encoder_output&lt;br/&gt;(B, S, H)"]

    C
    --&gt; D["encoder_hidden&lt;br/&gt;(L, B, H)&lt;br/&gt;Encoder 对整个输入序列的压缩上下文表示"]
end

D
-- "作为 Decoder 初始 hidden state" --&gt;
E

subgraph Decoder
    F["dec_input&lt;br/&gt;(B, T)&lt;br/&gt;Decoder 输入序列"]

    F
    -- "nn.Embedding(output_size, H)" --&gt;
    G["embedded&lt;br/&gt;(B, T, H)"]

    G
    -- "nn.RNN(H, H)" --&gt;
    E["decoder_rnn_output&lt;br/&gt;(B, T, H)"]

    E
    --&gt; H["decoder_output&lt;br/&gt;(B, T, H)"]

    H
    -- "nn.Linear(H, output_size)" --&gt;
    I["output logits&lt;br/&gt;(B, T, V)&lt;br/&gt;Decoder 对词表中每个 token 的预测分数"]
end
</code></pre>

<p>说明：</p>

<table>
  <thead>
    <tr>
      <th>名称</th>
      <th>含义</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>B: batch_size</td>
      <td>一次并行处理多少个样本（通常是一批句子）</td>
    </tr>
    <tr>
      <td>S: seq_len</td>
      <td>每个序列包含多少个 token（同一个 batch 里，所有句子会被 padding 成相同长度）</td>
    </tr>
    <tr>
      <td>T: target_seq_len</td>
      <td>decoder 输入/输出序列长度（训练时=label长度，推理时=生成长度）</td>
    </tr>
    <tr>
      <td>H: hidden_size</td>
      <td>RNN hidden state 维度（本例中 embedding 维度与其相同）</td>
    </tr>
    <tr>
      <td>V: vocab_size</td>
      <td>输出词汇表大小</td>
    </tr>
    <tr>
      <td>L: num_layers</td>
      <td>RNN 堆叠层数</td>
    </tr>
  </tbody>
</table>

<p>注：</p>
<ol>
  <li>这个 Seq2Seq 结构简单，没有用到 encoder_output，后续注意力会用到。</li>
  <li>这里引入了 Teacher Forcing，之前看到的训练过程是 输入→模型→预测→对比真实值算
  loss。但在自回归模型中多了一步选择：各时间步的输入用预测值还是真实值？Teacher Forcing 即训练时使用真实 token 作为下一步输入。</li>
  <li>书里的代码用来简要说明模型，仅教学作用，比如输出序列长度受限于
  seq_len，若目标句子更长则会被截断。</li>
</ol>

<h2 id="2-点积注意力">2. 点积注意力</h2>

<p>在干什么：</p>
<ul>
  <li>输入：X1 X2</li>
  <li>输出：X1 形状相同的矩阵，对 X1 的单个 token，融合了 X2 的每个 token 的向量，得到的结果。</li>
</ul>

<p>我的理解就是<strong>先计算 X1 X2 的相似度，然后进一步计算注意力(增加了softmax)，作为 X1 新的表示</strong>。</p>

<p>首先<strong>点积可以表示两个向量的相似程度</strong>: t1 t2 形状均为 <code class="language-plaintext highlighter-rouge">(1, feature_dim)</code>, \( \text{dot}(t_1, t_2) = t_1 \cdot t_2^T \)，结果是一个 标量（scalar），</p>

<p>代码在原书里有，这里记录下我理解的形状变化过程:</p>

<p>第 1 步： 初始时，x1.shape = (2, 3, 4) 、 x2.shape = (2, 5, 4)，即：x1: 3 个 token x2: 5 个 token , feature_dim=4 <br />
第 2 步： \( x_1 \cdot x_2^T = (\text{batch_size}, \text{seq_len}_1, \text{seq_len}_2) \), 其中 (b, i, j) 表示第 b 个样本中, x1 第 i 个元素与 x2 第 j 个元素的相似度</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/5-4.jpg" style="max-width:500px" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>第 3 步： attn_weights.shape = (2, 3, 5), 矩阵表示 x1 x2 之间两两 token 的关注程度，其中一行表示 x1 的 1 个 token 对 x2 所有 token 的关注程度
例如：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">attn_weights</span> <span class="o">=</span>
<span class="p">[</span>
  <span class="p">[</span>
    <span class="p">[</span><span class="mf">0.5</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mf">0.4</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">]</span>
  <span class="p">],</span>
  <span class="p">[</span>
    <span class="p">[</span><span class="mf">0.6</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">]</span>
  <span class="p">]</span>
<span class="p">]</span>
</code></pre></div></div>

<p>x1 第 1 个 token 对 x2 的第 1 个 token权重 0.5，第2 个 token 权重，0.2，…，也就是最关注第 1 个</p>

<p>第 4 步： attn_weights @ x2: 即 \( (\text{batch_size}, \text{seq_len}_1, \text{seq_len}_2) \times (\text{batch_size}, \text{seq_len}_2, \text{feature_dim}) = (\text{batch_size}, \text{seq_len}_1, \text{feature_dim}) \)</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/5-7.jpg" style="max-width:500px" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>逻辑上理解这个过程：</p>
<ol>
  <li>基于每个 token 的向量表示，计算 x1 x2 之间两两每个 token 的相似度</li>
  <li>对相似度矩阵每一行做 softmax， 得到 attention 权重， 表示： x1 的每个 token 应该关注 x2 各 token 的程度</li>
  <li>使用 attention 权重 对 x2 的 token 向量加权求和， 得到新的上下文表示（context vector）</li>
  <li>最终输出 shape： (batch_size, seq_len1, feature_dim) , 对应的是 x1 的每个 token “从 x2 中读取到的信息”</li>
</ol>

<p>从 点积注意力(Dot-Product Attention) 到 缩放点积注意力(Scaled Dot-Product Attention) ，变化主要是引入了<strong>缩放因子</strong>。</p>

<div class="info">
  <ol>
    <li>
      <p>是什么：
缩放点积注意力在计算注意力权重之前，会将点积结果也就是原始权重除以一个缩放因子，得到缩放后的原始权重。
通常缩放因子取值=输入特征维度的平方根。</p>
    </li>
    <li>
      <p>为什么：使得 softmax 函数在一个较为平缓的区域内工作，从而减轻梯度消失问题。因此是在计算权重前引入这一步骤。</p>
    </li>
  </ol>
</div>

<h2 id="3-带点积注意力的seq2seq">3. 带点积注意力的Seq2Seq</h2>

<p>相比 1. Seq2Seq 的变化：</p>
<ol>
  <li>decoder 增加输入：encoder_output(encoder 各个时间步的 hidden state)</li>
  <li>decoder RNN 层计算出 output 后，继续计算综合 encoder_output 的 attention contex 及 attention weight</li>
  <li>decoder 最终预测时，同时利用：</li>
</ol>

<ul>
  <li>Decoder 当前时刻状态（rnn_output）</li>
  <li>Encoder 提供的上下文信息（context）</li>
</ul>

<p>因此相比原始 Seq2Seq，
Decoder 不再只能依赖单个 encoder_hidden，
而是<strong>通过引入注意力，Seq2Seq 能够动态关注输入序列不同位置的信息(encoder_output)</strong>。</p>

<pre><code class="language-mermaid">flowchart TD

subgraph Encoder
    A["enc_input&lt;br/&gt;(B, S)"]

    A
    -- "nn.Embedding(input_size, H)" --&gt;
    B["embedded&lt;br/&gt;(B, S, H)"]

    B
    -- "nn.RNN(H, H)" --&gt;
    C["encoder_output&lt;br/&gt;(B, S, H)"]

    C
    --&gt; D["encoder_hidden&lt;br/&gt;(L, B, H)&lt;br/&gt;Encoder 对整个输入序列的压缩上下文表示"]
end

D
-- "作为 Decoder 初始 hidden state" --&gt;
E

subgraph Decoder
    F["dec_input&lt;br/&gt;(B, T)&lt;br/&gt;Decoder 输入序列"]

    F
    -- "nn.Embedding(output_size, H)" --&gt;
    G["embedded&lt;br/&gt;(B, T, H)"]

    G
    --&gt; E["nn.RNN(H, H)"]

    E
    --&gt; H["rnn_output&lt;br/&gt;(B, T, H)"]

    %% Attention 新增部分
    C
    -- "新增：encoder_output" --&gt;
    J["Attention"]

    H
    -- "新增：decoder query" --&gt;
    J

    J
    --&gt; K["新增：context&lt;br/&gt;(B, T, H)"]

    J
    --&gt; L["新增：attn_weights&lt;br/&gt;(B, T, S)"]

    %% concat 新增
    H
    -- "新增：torch.cat" --&gt;
    M["concat"]

    K
    -- "新增：torch.cat" --&gt;
    M

    M
    --&gt; N["新增：dec_output&lt;br/&gt;(B, T, 2 * H)"]

    %% Linear 输入变化
    N
    -- "修改：Linear 输入变为 2 * H" --&gt;
    O["output logits&lt;br/&gt;(B, T, V)&lt;br/&gt;Decoder 对词表中每个 token 的预测分数"]
end

%% 红色高亮
style J fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style K fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style L fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style M fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style N fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000

linkStyle 6 stroke:#ff0000,stroke-width:3px
linkStyle 7 stroke:#ff0000,stroke-width:3px
linkStyle 8 stroke:#ff0000,stroke-width:3px
linkStyle 9 stroke:#ff0000,stroke-width:3px
linkStyle 10 stroke:#ff0000,stroke-width:3px
linkStyle 11 stroke:#ff0000,stroke-width:3px
</code></pre>

<p>其中红色部分是相比简化 Seq2Seq 增加的技术点。</p>

<p><strong>总结Attention 的核心思想是</strong>：</p>
<ul>
  <li>输入: X1 X2 矩阵，对于 X1 中的每个 token，根据它与 X2 各 token 的相似度，计算 attention 权重；再使用这些权重对 X2 做加权求和，得到新的上下文表示（context vector）。</li>
  <li>输出：shape 与 X1 相同的新矩阵，其中每个 token 的表示，已经融合了来自 X2 的相关信息。</li>
</ul>

<p>在 Seq2Seq Attention 中：</p>
<ul>
  <li>Decoder 输出（decoder hidden states）作为 Query</li>
  <li>Encoder 输出（encoder outputs）作为 Key 和 Value</li>
</ul>

<p>因此：<br />
Decoder 在生成每个 token 时，可以动态关注 Encoder 不同位置的信息，而不再只依赖 Encoder 最后一个 hidden state。</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[这篇笔记主要记录： Seq2Seq: 机器翻译这类 序列到序列 的任务，需要先理解完整输入再生成输出, 而非 RNN 那样仅预测下一个 token. 点积注意力: 计算两个矩阵 X1 X2 的相似度，然后进一步计算 X1 融合了 X2 信息后的矩阵 带点积注意力的Seq2Seq: 增加点积注意力，将编码器、解码器的隐藏状态联系起来，也就是能关注到输入序列中的重要部分，从而更好地捕捉上下文相关性 1. Seq2Seq 上一篇笔记讲到了 RNN, RNNLM 可以输入前面的词预测下一个词，例如 I love deep → learning ，即 sequence → next token 机器翻译则是另外一个形式，要求输入:I love you 输出: 我爱你，即一个输入序列 → 一个输出序列（sequence-to-sequence），因此后来发展出了 Encoder-Decoder（Seq2Seq）结构。 Seq2Seq 会先通过 Encoder 将输入序列编码为上下文表示（hidden state），再将其作为 Decoder 的初始 hidden state，逐步生成输出序列： 图源：《GPT图解-大模型是怎样构建的》 为了简化说明，编码器、解码器使用最基础的 nn.RNN 实现: import torch.nn as nn # 导入 torch.nn 库 # 定义编码器类，继承自 nn.Module class Encoder(nn.Module): def __init__(self, input_size, hidden_size): super(Encoder, self).__init__() self.hidden_size = hidden_size # 设置隐藏层大小 self.embedding = nn.Embedding(input_size, hidden_size) # 创建词嵌入层 self.rnn = nn.RNN(hidden_size, hidden_size, batch_first=True) # 创建 RNN 层 def forward(self, inputs, hidden): # 前向传播函数 embedded = self.embedding(inputs) # 将输入转换为嵌入向量 output, hidden = self.rnn(embedded, hidden) # 将嵌入向量输入 RNN 层并获取输出 return output, hidden # 定义解码器类，继承自 nn.Module class Decoder(nn.Module): def __init__(self, hidden_size, output_size): super(Decoder, self).__init__() self.hidden_size = hidden_size # 设置隐藏层大小 self.embedding = nn.Embedding(output_size, hidden_size) # 创建词嵌入层 self.rnn = nn.RNN(hidden_size, hidden_size, batch_first=True) # 创建 RNN 层 self.out = nn.Linear(hidden_size, output_size) # 创建线性输出层 def forward(self, inputs, hidden): # 前向传播函数 embedded = self.embedding(inputs) # 将输入转换为嵌入向量 output, hidden = self.rnn(embedded, hidden) # 将嵌入向量输入 RNN 层并获取输出 output = self.out(output) # 使用线性层生成最终输出 return output, hidden n_hidden = 128 # 设置隐藏层数量 # 创建编码器和解码器 encoder = Encoder(voc_size_cn, n_hidden) decoder = Decoder(n_hidden, voc_size_en) print(' 编码器结构：', encoder) # 打印编码器的结构 print(' 解码器结构：', decoder) # 打印解码器的结构 定义 Seq2Seq 类，串联起来编码、解码过程： class Seq2Seq(nn.Module): def __init__(self, encoder, decoder): super(Seq2Seq, self).__init__() # 初始化编码器和解码器 self.encoder = encoder self.decoder = decoder def forward(self, enc_input, hidden, dec_input): # 定义前向传播函数 # 使输入序列通过编码器并获取输出和隐藏状态 encoder_output, encoder_hidden = self.encoder(enc_input, hidden) # 将编码器的隐藏状态传递给解码器作为初始隐藏状态 decoder_hidden = encoder_hidden # 使解码器输入（目标序列）通过解码器并获取输出 decoder_output, _ = self.decoder(dec_input, decoder_hidden) return decoder_output # 创建 Seq2Seq 架构 model = Seq2Seq(encoder, decoder) print('S2S 模型结构：', model) # 打印模型的结构 定义训练过程，完成后的 model 即可以用来预测了： def train_seq2seq(model, criterion, optimizer, epochs): for epoch in range(epochs): encoder_input, decoder_input, target = make_data(sentences) # 训练数据的创建 hidden = torch.zeros(1, encoder_input.size(0), n_hidden) # 初始化隐藏状态 optimizer.zero_grad()# 梯度清零 output = model(encoder_input, hidden, decoder_input) # 获取模型输出 loss = criterion(output.view(-1, voc_size_en), target.view(-1)) # 计算损失 if (epoch + 1) % 40 == 0: # 打印损失 print(f"Epoch: {epoch + 1:04d} cost = {loss:.6f}") loss.backward()# 反向传播 optimizer.step()# 更新参数 # 训练模型 epochs = 400 # 训练轮次 criterion = nn.CrossEntropyLoss() # 损失函数 optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 优化器 train_seq2seq(model, criterion, optimizer, epochs) # 调用函数训练模型 解释前面的代码： flowchart TD subgraph Encoder A["enc_input&lt;br/&gt;(B, S)"] A -- "nn.Embedding(input_size, H)" --&gt; B["embedded&lt;br/&gt;(B, S, H)"] B -- "nn.RNN(H, H)" --&gt; C["encoder_output&lt;br/&gt;(B, S, H)"] C --&gt; D["encoder_hidden&lt;br/&gt;(L, B, H)&lt;br/&gt;Encoder 对整个输入序列的压缩上下文表示"] end D -- "作为 Decoder 初始 hidden state" --&gt; E subgraph Decoder F["dec_input&lt;br/&gt;(B, T)&lt;br/&gt;Decoder 输入序列"] F -- "nn.Embedding(output_size, H)" --&gt; G["embedded&lt;br/&gt;(B, T, H)"] G -- "nn.RNN(H, H)" --&gt; E["decoder_rnn_output&lt;br/&gt;(B, T, H)"] E --&gt; H["decoder_output&lt;br/&gt;(B, T, H)"] H -- "nn.Linear(H, output_size)" --&gt; I["output logits&lt;br/&gt;(B, T, V)&lt;br/&gt;Decoder 对词表中每个 token 的预测分数"] end 说明： 名称 含义 B: batch_size 一次并行处理多少个样本（通常是一批句子） S: seq_len 每个序列包含多少个 token（同一个 batch 里，所有句子会被 padding 成相同长度） T: target_seq_len decoder 输入/输出序列长度（训练时=label长度，推理时=生成长度） H: hidden_size RNN hidden state 维度（本例中 embedding 维度与其相同） V: vocab_size 输出词汇表大小 L: num_layers RNN 堆叠层数 注： 这个 Seq2Seq 结构简单，没有用到 encoder_output，后续注意力会用到。 这里引入了 Teacher Forcing，之前看到的训练过程是 输入→模型→预测→对比真实值算 loss。但在自回归模型中多了一步选择：各时间步的输入用预测值还是真实值？Teacher Forcing 即训练时使用真实 token 作为下一步输入。 书里的代码用来简要说明模型，仅教学作用，比如输出序列长度受限于 seq_len，若目标句子更长则会被截断。 2. 点积注意力 在干什么： 输入：X1 X2 输出：X1 形状相同的矩阵，对 X1 的单个 token，融合了 X2 的每个 token 的向量，得到的结果。 我的理解就是先计算 X1 X2 的相似度，然后进一步计算注意力(增加了softmax)，作为 X1 新的表示。 首先点积可以表示两个向量的相似程度: t1 t2 形状均为 (1, feature_dim), \( \text{dot}(t_1, t_2) = t_1 \cdot t_2^T \)，结果是一个 标量（scalar）， 代码在原书里有，这里记录下我理解的形状变化过程: 第 1 步： 初始时，x1.shape = (2, 3, 4) 、 x2.shape = (2, 5, 4)，即：x1: 3 个 token x2: 5 个 token , feature_dim=4 第 2 步： \( x_1 \cdot x_2^T = (\text{batch_size}, \text{seq_len}_1, \text{seq_len}_2) \), 其中 (b, i, j) 表示第 b 个样本中, x1 第 i 个元素与 x2 第 j 个元素的相似度 图源：《GPT图解-大模型是怎样构建的》 第 3 步： attn_weights.shape = (2, 3, 5), 矩阵表示 x1 x2 之间两两 token 的关注程度，其中一行表示 x1 的 1 个 token 对 x2 所有 token 的关注程度 例如： attn_weights = [ [ [0.5, 0.2, 0.1, 0.1, 0.1], [0.1, 0.3, 0.4, 0.1, 0.1], [0.2, 0.2, 0.2, 0.2, 0.2] ], [ [0.6, 0.1, 0.1, 0.1, 0.1], [0.2, 0.2, 0.3, 0.2, 0.1], [0.1, 0.1, 0.2, 0.3, 0.3] ] ] x1 第 1 个 token 对 x2 的第 1 个 token权重 0.5，第2 个 token 权重，0.2，…，也就是最关注第 1 个 第 4 步： attn_weights @ x2: 即 \( (\text{batch_size}, \text{seq_len}_1, \text{seq_len}_2) \times (\text{batch_size}, \text{seq_len}_2, \text{feature_dim}) = (\text{batch_size}, \text{seq_len}_1, \text{feature_dim}) \) 图源：《GPT图解-大模型是怎样构建的》 逻辑上理解这个过程： 基于每个 token 的向量表示，计算 x1 x2 之间两两每个 token 的相似度 对相似度矩阵每一行做 softmax， 得到 attention 权重， 表示： x1 的每个 token 应该关注 x2 各 token 的程度 使用 attention 权重 对 x2 的 token 向量加权求和， 得到新的上下文表示（context vector） 最终输出 shape： (batch_size, seq_len1, feature_dim) , 对应的是 x1 的每个 token “从 x2 中读取到的信息” 从 点积注意力(Dot-Product Attention) 到 缩放点积注意力(Scaled Dot-Product Attention) ，变化主要是引入了缩放因子。 是什么： 缩放点积注意力在计算注意力权重之前，会将点积结果也就是原始权重除以一个缩放因子，得到缩放后的原始权重。 通常缩放因子取值=输入特征维度的平方根。 为什么：使得 softmax 函数在一个较为平缓的区域内工作，从而减轻梯度消失问题。因此是在计算权重前引入这一步骤。 3. 带点积注意力的Seq2Seq 相比 1. Seq2Seq 的变化： decoder 增加输入：encoder_output(encoder 各个时间步的 hidden state) decoder RNN 层计算出 output 后，继续计算综合 encoder_output 的 attention contex 及 attention weight decoder 最终预测时，同时利用： Decoder 当前时刻状态（rnn_output） Encoder 提供的上下文信息（context） 因此相比原始 Seq2Seq， Decoder 不再只能依赖单个 encoder_hidden， 而是通过引入注意力，Seq2Seq 能够动态关注输入序列不同位置的信息(encoder_output)。 flowchart TD subgraph Encoder A["enc_input&lt;br/&gt;(B, S)"] A -- "nn.Embedding(input_size, H)" --&gt; B["embedded&lt;br/&gt;(B, S, H)"] B -- "nn.RNN(H, H)" --&gt; C["encoder_output&lt;br/&gt;(B, S, H)"] C --&gt; D["encoder_hidden&lt;br/&gt;(L, B, H)&lt;br/&gt;Encoder 对整个输入序列的压缩上下文表示"] end D -- "作为 Decoder 初始 hidden state" --&gt; E subgraph Decoder F["dec_input&lt;br/&gt;(B, T)&lt;br/&gt;Decoder 输入序列"] F -- "nn.Embedding(output_size, H)" --&gt; G["embedded&lt;br/&gt;(B, T, H)"] G --&gt; E["nn.RNN(H, H)"] E --&gt; H["rnn_output&lt;br/&gt;(B, T, H)"] %% Attention 新增部分 C -- "新增：encoder_output" --&gt; J["Attention"] H -- "新增：decoder query" --&gt; J J --&gt; K["新增：context&lt;br/&gt;(B, T, H)"] J --&gt; L["新增：attn_weights&lt;br/&gt;(B, T, S)"] %% concat 新增 H -- "新增：torch.cat" --&gt; M["concat"] K -- "新增：torch.cat" --&gt; M M --&gt; N["新增：dec_output&lt;br/&gt;(B, T, 2 * H)"] %% Linear 输入变化 N -- "修改：Linear 输入变为 2 * H" --&gt; O["output logits&lt;br/&gt;(B, T, V)&lt;br/&gt;Decoder 对词表中每个 token 的预测分数"] end %% 红色高亮 style J fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000 style K fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000 style L fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000 style M fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000 style N fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000 linkStyle 6 stroke:#ff0000,stroke-width:3px linkStyle 7 stroke:#ff0000,stroke-width:3px linkStyle 8 stroke:#ff0000,stroke-width:3px linkStyle 9 stroke:#ff0000,stroke-width:3px linkStyle 10 stroke:#ff0000,stroke-width:3px linkStyle 11 stroke:#ff0000,stroke-width:3px 其中红色部分是相比简化 Seq2Seq 增加的技术点。 总结Attention 的核心思想是： 输入: X1 X2 矩阵，对于 X1 中的每个 token，根据它与 X2 各 token 的相似度，计算 attention 权重；再使用这些权重对 X2 做加权求和，得到新的上下文表示（context vector）。 输出：shape 与 X1 相同的新矩阵，其中每个 token 的表示，已经融合了来自 X2 的相关信息。 在 Seq2Seq Attention 中： Decoder 输出（decoder hidden states）作为 Query Encoder 输出（encoder outputs）作为 Key 和 Value 因此： Decoder 在生成每个 token 时，可以动态关注 Encoder 不同位置的信息，而不再只依赖 Encoder 最后一个 hidden state。]]></summary></entry><entry><title type="html">思多乱其志，行者多披靡-读《被讨厌的勇气》</title><link href="https://izualzhy.cn/bei-tao-yan-de-yong-qi-reading" rel="alternate" type="text/html" title="思多乱其志，行者多披靡-读《被讨厌的勇气》" /><published>2026-05-23T09:34:02+00:00</published><updated>2026-05-23T09:34:02+00:00</updated><id>https://izualzhy.cn/bei-tao-yan-de-yong-qi-reading</id><content type="html" xml:base="https://izualzhy.cn/bei-tao-yan-de-yong-qi-reading"><![CDATA[<h2 id="1-关于本书">1. 关于本书</h2>

<p>之前我以为书名的意思，是人们逐渐失去了勇气，平庸且随波逐流，不想做出改变。读到一半才知道，不怕被人讨厌，是一种勇气。</p>

<p>书里如果单独抽出一句来，妥妥的鸡汤文，比如“人生的意义，由你自己决定”；有的则是语不惊人死不休，比如“自由就是被别人讨厌”。</p>

<p>但是这本书是以 哲人 和 青年 的对话展开，哲人的话有时鸡汤有时荒诞，因此我总是不自觉的代入青年的视角，一直在“怼”着哲人的观点。就好像原来看的一些访谈片段、心灵文章，很多当事人不过是“站着说话不腰疼”的胡说八道，在这本书里则可以对着“对面的教导者”一顿输出。</p>

<p>简言之，这本书的前面，青年一边思考一边组织语言对着哲人说：“你 TM 的是不是在扯淡？！”</p>

<p>而对于书籍之外的我，看着他俩的对话，不知不觉的就读完了这本书，还有很多的感触。所以，我觉得这是一本好书。</p>

<p>读这本书最开始，只是好奇翻开，看着看看居然就翻完了。就好像书里说的：</p>

<blockquote>
  <p>“人生就像是在每一个瞬间不停旋转起舞的连续的刹那。并且，暮然四顾时常常会惊觉：“已经来到这里了吗？”</p>
</blockquote>

<p>没有在意读这本书的目的，或者读完了有什么结果。很多人都会看这本书，看完了的评价感悟不同，甚至截然相反，在这件事情上各人结果不同。于我自己而言，最重要的则是这个过程里的思考和共鸣。这就是<strong>过程的重要</strong>。</p>

<p>阿德勒的心理学，主张的就是要关注这个过程而不是结果。我们听了很多的故事，少年如何立志，然后终究达到目的的故事，但是都是幸存者偏差。</p>

<p>或者说，如果你眼里只有结果，那么容易轻视过程，反而拿不到结果。人生可以计划，但是往往不会按照你想的来。</p>

<h2 id="2-因果目的论和自我接纳">2. 因果、目的论和自我接纳</h2>

<p>初始听确实有道理，可是我仔细一消化，感觉还是不对劲。</p>

<p>因为上述结论是不能类比和迁移的，读书这件事情，允许结果的多样性，甚至读一点放弃了也是一个结果。读书的过程，也是思考的过程，所以过程很重要。</p>

<p>但是很多事情不一样，比如你是否可以接受工作过程里付出很多，但是桃子被人摘了结果不是你的？比如你突然发现你努力一辈子的终点，可能还不如别人出生时的起点？比如你工作里明明判断出来事情有风险，但是因为人力被撤出导致虎头蛇尾？</p>

<p>也就是一因二果、他人的因也可能是你的果。</p>

<p>所以<strong>结果也同样重要</strong>。</p>

<p>阿德勒的心理学，是这么解释的，也很简单，就是想清楚你能做什么？如果有些你无法改变，那就需要接纳自己，然后努力改变自己能改变的。当然这个道理容易，做起来难，让我想起来《苏东坡新传》里提到的“总角闻道，白首无成”。</p>

<p>书里特别强调了 <strong>目的论</strong> 而非 弗洛伊德式的原因论。</p>

<p>原因论主张人受过去的自己支配，比如小时候家庭创伤会导致人变得脆弱敏感。最近看 AI 比较多，我理解 GPT 就是典型的原因论😂，即当前输出的 token 是由之前的 token 决定的，这类逻辑看得多了，似乎觉得人生也是这样。</p>

<p>而目的论，则再次强调当下的重要性，未来是当下而不是过去决定的。我无法判断哪个是对是错，但是毫无疑问，唯有接受目的论，才能不反复在过去里浪费精力。所以我觉得目的论，是一种更乐观的做法。</p>

<p>自我接纳和自我肯定存在很大差别，自我肯定容易陷入“怀才不遇”式的心理误区，书里讲：</p>

<blockquote>
  <p>任何情况下都只是攻击我的“那个人”有问题，而绝不是“大家”的错。</p>
</blockquote>

<p>其实大部分人都是无所谓的。</p>

<h2 id="3-心理学">3. 心理学</h2>

<p>第一本看完的心理学书籍，算是对心理学终于有了一个入门的认知。</p>

<p>阿德勒心理学追求自我改变，不能等着别人发生变化，也不要等着状况有所改变，而是由自己勇敢迈出第一步。</p>

<p>读完这本书，我觉得心理学一个神奇的地方在于：现状没变、要做的事情没变，但是人的内心里，对现状、对要做的事情，内心里有了变化，赋予了不同的价值。从而使人在做的过程中，更加的专注和心无旁骛，更加的不在乎他人的评价和视角，如此，反而更加容易做成一件事情。</p>

<p>当然，心理学不是万能的，阿德勒的心理学也不能改变他人，更加不是万能的。如果说对99.9999%的人有效的工具，大概只有货币。</p>

<p>我的理解就是要自我为中心，只有向前看才能解决问题，回顾和自责，适可而止。在真正落地时，心理学是生生不息的内驱力，而社会学、经济学、自身的专业知识是解决问题的马力。</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[1. 关于本书 之前我以为书名的意思，是人们逐渐失去了勇气，平庸且随波逐流，不想做出改变。读到一半才知道，不怕被人讨厌，是一种勇气。 书里如果单独抽出一句来，妥妥的鸡汤文，比如“人生的意义，由你自己决定”；有的则是语不惊人死不休，比如“自由就是被别人讨厌”。 但是这本书是以 哲人 和 青年 的对话展开，哲人的话有时鸡汤有时荒诞，因此我总是不自觉的代入青年的视角，一直在“怼”着哲人的观点。就好像原来看的一些访谈片段、心灵文章，很多当事人不过是“站着说话不腰疼”的胡说八道，在这本书里则可以对着“对面的教导者”一顿输出。 简言之，这本书的前面，青年一边思考一边组织语言对着哲人说：“你 TM 的是不是在扯淡？！” 而对于书籍之外的我，看着他俩的对话，不知不觉的就读完了这本书，还有很多的感触。所以，我觉得这是一本好书。 读这本书最开始，只是好奇翻开，看着看看居然就翻完了。就好像书里说的： “人生就像是在每一个瞬间不停旋转起舞的连续的刹那。并且，暮然四顾时常常会惊觉：“已经来到这里了吗？” 没有在意读这本书的目的，或者读完了有什么结果。很多人都会看这本书，看完了的评价感悟不同，甚至截然相反，在这件事情上各人结果不同。于我自己而言，最重要的则是这个过程里的思考和共鸣。这就是过程的重要。 阿德勒的心理学，主张的就是要关注这个过程而不是结果。我们听了很多的故事，少年如何立志，然后终究达到目的的故事，但是都是幸存者偏差。 或者说，如果你眼里只有结果，那么容易轻视过程，反而拿不到结果。人生可以计划，但是往往不会按照你想的来。 2. 因果、目的论和自我接纳 初始听确实有道理，可是我仔细一消化，感觉还是不对劲。 因为上述结论是不能类比和迁移的，读书这件事情，允许结果的多样性，甚至读一点放弃了也是一个结果。读书的过程，也是思考的过程，所以过程很重要。 但是很多事情不一样，比如你是否可以接受工作过程里付出很多，但是桃子被人摘了结果不是你的？比如你突然发现你努力一辈子的终点，可能还不如别人出生时的起点？比如你工作里明明判断出来事情有风险，但是因为人力被撤出导致虎头蛇尾？ 也就是一因二果、他人的因也可能是你的果。 所以结果也同样重要。 阿德勒的心理学，是这么解释的，也很简单，就是想清楚你能做什么？如果有些你无法改变，那就需要接纳自己，然后努力改变自己能改变的。当然这个道理容易，做起来难，让我想起来《苏东坡新传》里提到的“总角闻道，白首无成”。 书里特别强调了 目的论 而非 弗洛伊德式的原因论。 原因论主张人受过去的自己支配，比如小时候家庭创伤会导致人变得脆弱敏感。最近看 AI 比较多，我理解 GPT 就是典型的原因论😂，即当前输出的 token 是由之前的 token 决定的，这类逻辑看得多了，似乎觉得人生也是这样。 而目的论，则再次强调当下的重要性，未来是当下而不是过去决定的。我无法判断哪个是对是错，但是毫无疑问，唯有接受目的论，才能不反复在过去里浪费精力。所以我觉得目的论，是一种更乐观的做法。 自我接纳和自我肯定存在很大差别，自我肯定容易陷入“怀才不遇”式的心理误区，书里讲： 任何情况下都只是攻击我的“那个人”有问题，而绝不是“大家”的错。 其实大部分人都是无所谓的。 3. 心理学 第一本看完的心理学书籍，算是对心理学终于有了一个入门的认知。 阿德勒心理学追求自我改变，不能等着别人发生变化，也不要等着状况有所改变，而是由自己勇敢迈出第一步。 读完这本书，我觉得心理学一个神奇的地方在于：现状没变、要做的事情没变，但是人的内心里，对现状、对要做的事情，内心里有了变化，赋予了不同的价值。从而使人在做的过程中，更加的专注和心无旁骛，更加的不在乎他人的评价和视角，如此，反而更加容易做成一件事情。 当然，心理学不是万能的，阿德勒的心理学也不能改变他人，更加不是万能的。如果说对99.9999%的人有效的工具，大概只有货币。 我的理解就是要自我为中心，只有向前看才能解决问题，回顾和自责，适可而止。在真正落地时，心理学是生生不息的内驱力，而社会学、经济学、自身的专业知识是解决问题的马力。]]></summary></entry><entry><title type="html">《GPT 图解》笔记：N-Gram、NPLM、LSTM</title><link href="https://izualzhy.cn/llm-diagrammatize-ngram-nplm-lstm" rel="alternate" type="text/html" title="《GPT 图解》笔记：N-Gram、NPLM、LSTM" /><published>2026-05-10T08:00:35+00:00</published><updated>2026-05-10T08:00:35+00:00</updated><id>https://izualzhy.cn/llm-diagrammatize-ngram-nplm-lstm</id><content type="html" xml:base="https://izualzhy.cn/llm-diagrammatize-ngram-nplm-lstm"><![CDATA[<p>统计语言模型的发展路线：</p>

<figure>
  <img src="/assets/images/gpt-diagrammatize/0-13.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>核心：N-Gram → NPLM → RNN → LSTM/GRU → Transformer → BERT/GPT</p>

<p>这篇笔记主要记录：</p>

<ol>
  <li><strong>N-Gram</strong>: 统计前 n-1 个词出现的概率，计算 \( P(\text{word}<em>n \mid \text{word}</em>{n-1}, \text{word}_{n-2}) \)，不具备泛化能力。</li>
  <li><strong>NPLM</strong>: 引入 embedding（词向量），具备了 泛化 能力，但仍然是固定窗口</li>
  <li><strong>RNN/LSTM</strong>: 引入 hidden state 递归，支持了 变长 序列，不再是固定 n_step 的前文词长度</li>
</ol>

<h2 id="1-n-gram-和-bag-of-words">1. N-Gram 和 Bag-of-Words</h2>

<p><strong>N-Gram</strong> 模型是一种简化的概率模型，它通过计算前 N 个词的联合概率来预测下一个词，因此适用于文本生成的场景。</p>

<p>基础流程为：</p>
<ol>
  <li>对每个 N-Gram ，统计紧跟其后的第一个 token 数: <code class="language-plaintext highlighter-rouge">{$ngram : {$next-token-1: cnt-1, $next-token-2: cnt-2, ...}}</code></li>
  <li>基于1，就可以计算 $ngram 之后 $next-token-1 $next-token-2 等的概率</li>
  <li>基于2，当给定初始语句，就可以一直按照概率采样选择 next-token-x，预测新的句子</li>
</ol>

<p>该模型是基于以下两个假设：</p>
<ol>
  <li>贾里尼克假设：一个句子是否合理，取决于其出现在自然语言中的可能性的大小</li>
  <li>一阶马尔可夫假设：任意一个词出现的概率只同它前面的那一个词有关</li>
</ol>

<p>虽然现在的语言模型基本不再基于假设 2，但是从 N-Gram 到 Infini-Gram 还是一直有探索，比如 <a href="https://github.com/nathan-barry/tiny-infini-gram/tree/main">Tiny Infini-Gram</a>，基于超大的 token 统计，通过已有前缀选择下一个词，效果上类似“混搭”了多个来源的数据集。</p>

<p><strong>Bag-of-Words</strong> 对每个句子，记录了词汇表中每个词出现的次数，能够计算句子间的相似度，因此适用于计算文本相似度的场景</p>

<p>基础流程为：</p>
<ol>
  <li>分词( jieba etc.)</li>
  <li>建立词汇表，每个词给定唯一下标</li>
  <li>遍历全部句子逐个处理，生成<code class="language-plaintext highlighter-rouge">m*n</code>的结果，其中 m 是句子个数，n 是词汇表大小</li>
  <li>单个句子的处理逻辑：用长度为 n 的向量，如果句子包含该词，则在对应下标处记录词频</li>
  <li>计算不同向量的余弦相似度，即等价于这两个句子的相似程度</li>
</ol>

<p>注意 N-Gram 是指连续的 token 序列，不是单纯的字母、单词、字</p>
<ol>
  <li>英文: “playing”可以切分为 <code class="language-plaintext highlighter-rouge">["play", "##ing"]</code>, “unstoppable”可以切分为 <code class="language-plaintext highlighter-rouge">["un", "##stop", "##able"]</code></li>
  <li>中文: 孙悟空三打白骨精，可能是白骨精是一个 gram<br />
而 token 如何划分，则由 tokenizer 决定，例如：WordPiece、BPE、SentencePiece 等。</li>
</ol>

<p><strong>总结：</strong><br />
<strong>1. N-Gram 是一种基于统计的语言模型， 通过统计连续 token 序列的条件概率， 利用前 N-1 个 token 预测下一个 token。</strong><br />
<strong>2. Bag-of-Words 是一种基于词频统计的文本表示方法， 不考虑词序， 常用于文本分类和相似度计算。</strong></p>

<h2 id="2-word2veccbow模型和skip-gram模型">2. Word2Vec：CBOW模型和Skip-Gram模型</h2>

<p><strong>Word2Vec</strong>（Word to Vector）是一种词向量学习算法，通过上下文预测任务学习词的稠密向量表示。其核心思想是：语义相近的词， 其上下文分布也相近，因此在向量空间中会更加接近。</p>

<p>Word2Vec 和 BoW 都属于文本/词表示学习方法，但 BoW 基于词频统计，而 Word2Vec 通过上下文预测学习。在表示结果上，BoW 的 One-Hot 编码是稀疏的，有1 个 1 ，其余都是 0，而 Word2Vec 学习的向量是稠密的，能够捕捉到更多信息。</p>

<p>Word2Vec 里主要有两种实现方式：</p>
<ol>
  <li><strong>Skip-Gram</strong>：输入中心词，预测上下文</li>
  <li><strong>Continuous Bag of Words</strong>：输入上下文，预测中心词</li>
</ol>

<figure>
  <img src="/assets/images/gpt-diagrammatize/2-7.jpg" />
  <figcaption class="img-source">图源：《GPT图解-大模型是怎样构建的》</figcaption>
</figure>

<p>Skip-Gram 模型示例代码：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 定义 Skip-Gram 类
</span><span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span> <span class="c1"># 导入 neural network
</span><span class="k">class</span> <span class="nc">SkipGram</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">voc_size</span><span class="p">,</span> <span class="n">embedding_size</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">SkipGram</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span>
        <span class="c1"># 从词汇表大小到嵌入层大小（维度）的线性层（权重矩阵）
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">input_to_hidden</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">voc_size</span><span class="p">,</span> <span class="n">embedding_size</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>  
        <span class="c1"># 从嵌入层大小（维度）到词汇表大小的线性层（权重矩阵）
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">hidden_to_output</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">embedding_size</span><span class="p">,</span> <span class="n">voc_size</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>  
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">):</span> <span class="c1"># 前向传播的方式，X 形状为 (batch_size, voc_size)      
</span>        <span class="c1"># 通过隐藏层，hidden 形状为 (batch_size, embedding_size)
</span>        <span class="n">hidden</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">input_to_hidden</span><span class="p">(</span><span class="n">X</span><span class="p">)</span> 
        <span class="c1"># 通过输出层，output_layer 形状为 (batch_size, voc_size)
</span>        <span class="n">output</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">hidden_to_output</span><span class="p">(</span><span class="n">hidden</span><span class="p">)</span>  
        <span class="k">return</span> <span class="n">output</span>    
<span class="n">embedding_size</span> <span class="o">=</span> <span class="mi">2</span> <span class="c1"># 设定嵌入层的大小，这里选择 2 是为了方便展示
</span><span class="n">skipgram_model</span> <span class="o">=</span> <span class="n">SkipGram</span><span class="p">(</span><span class="n">voc_size</span><span class="p">,</span> <span class="n">embedding_size</span><span class="p">)</span>  <span class="c1"># 实例化 Skip-Gram 模型
</span><span class="k">print</span><span class="p">(</span><span class="s">"Skip-Gram 模型："</span><span class="p">,</span> <span class="n">skipgram_model</span><span class="p">)</span>
</code></pre></div></div>

<p>CBOW 模型示例代码：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 定义 CBOW 模型
</span><span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span> <span class="c1"># 导入 neural network
</span><span class="k">class</span> <span class="nc">CBOW</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">voc_size</span><span class="p">,</span> <span class="n">embedding_size</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">CBOW</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span>
        <span class="c1"># 从词汇表大小到嵌入大小的线性层（权重矩阵）
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">input_to_hidden</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">voc_size</span><span class="p">,</span> 
                                         <span class="n">embedding_size</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>  
        <span class="c1"># 从嵌入大小到词汇表大小的线性层（权重矩阵）
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">hidden_to_output</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">embedding_size</span><span class="p">,</span> 
                                          <span class="n">voc_size</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>  
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">):</span> <span class="c1"># X: [num_context_words, voc_size]
</span>        <span class="c1"># 生成嵌入：[num_context_words, embedding_size]
</span>        <span class="n">embeddings</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">input_to_hidden</span><span class="p">(</span><span class="n">X</span><span class="p">)</span>  
        <span class="c1"># 计算隐藏层，求嵌入的均值：[embedding_size]
</span>        <span class="n">hidden_layer</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">embeddings</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>  
        <span class="c1"># 生成输出层：[1, voc_size]
</span>        <span class="n">output_layer</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">hidden_to_output</span><span class="p">(</span><span class="n">hidden_layer</span><span class="p">.</span><span class="n">unsqueeze</span><span class="p">(</span><span class="mi">0</span><span class="p">))</span> 
        <span class="k">return</span> <span class="n">output_layer</span>    
<span class="n">embedding_size</span> <span class="o">=</span> <span class="mi">2</span> <span class="c1"># 设定嵌入层的大小，这里选择 2 是为了方便展示
</span><span class="n">cbow_model</span> <span class="o">=</span> <span class="n">CBOW</span><span class="p">(</span><span class="n">voc_size</span><span class="p">,</span><span class="n">embedding_size</span><span class="p">)</span>  <span class="c1"># 实例化 CBOW 模型
</span><span class="k">print</span><span class="p">(</span><span class="s">"CBOW 模型："</span><span class="p">,</span> <span class="n">cbow_model</span><span class="p">)</span>
</code></pre></div></div>

<p>实际场景里，<code class="language-plaintext highlighter-rouge">input_to_hidden</code>通常使用<code class="language-plaintext highlighter-rouge">nn.Embedding</code>. 例子里<code class="language-plaintext highlighter-rouge">nn.Linear</code>是通过 one-hot × W1 取出对应行，<code class="language-plaintext highlighter-rouge">nn.Embedding</code>则是通过 Embedding 直接索引取出对应行，两者是等价的，只是后者更加高效。</p>

<p>在 Word2Vec（Skip-Gram / CBOW）中，通过“预测词”的训练任务，学习得到参数矩阵 W1，其中每一行就是对应词的向量表示（通常作为词向量使用）。
即<strong>词向量不是“直接学出来的目标”，而是“为了完成预测任务而学到的中间表示”</strong>。</p>

<h2 id="3-nplm-和-rnnlstm">3. NPLM 和 RNN/LSTM</h2>

<p>这一章回到语言模型。</p>

<p>神经概率语言模型(<strong>Neural Probabilistic Language Model</strong>) 是一种早期神经语言模型。它通过 embedding 将离散 token 转换为连续向量，再通过 MLP（多层感知机）根据前 n_step 个词预测下一个词。</p>

<p>NPLM 和 Word2Vec 都会学习词向量 embedding，但两者目标不同：</p>
<ul>
  <li>Word2Vec 的核心目标是学习词表示；</li>
  <li>NPLM 的核心目标是预测下一个词（语言建模）。</li>
</ul>

<p>NPLM 基础流程为：</p>
<ol>
  <li>根据语料构建词汇表</li>
  <li>使用滑动窗口生成训练样本：前 n_step 个词作为输入(input_batch)，第 n_step+1 个词作为目标(target_batch)</li>
  <li>定义 NPLM 模型，由 nn.Embedding 和多个 nn.Linear 组成</li>
  <li>训练过程中，将 input_batch 输入模型，输出词表上的 logits，并通过 CrossEntropyLoss 计算预测结果与 target_batch 的差距，反向传播不断降低</li>
  <li>训练完成后，模型可以根据前面的 n_step 个词预测下一个词</li>
</ol>

<p>对应代码：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span> <span class="c1"># 导入神经网络模块
# 定义神经概率语言模型（NPLM）
</span><span class="k">class</span> <span class="nc">NPLM</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">NPLM</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span> 
        <span class="bp">self</span><span class="p">.</span><span class="n">C</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">voc_size</span><span class="p">,</span> <span class="n">embedding_size</span><span class="p">)</span> <span class="c1"># 定义一个词嵌入层
</span>        <span class="c1"># 第一个线性层，其输入大小为 n_step * embedding_size，输出大小为 n_hidden
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">linear1</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">n_step</span> <span class="o">*</span> <span class="n">embedding_size</span><span class="p">,</span> <span class="n">n_hidden</span><span class="p">)</span> 
        <span class="c1"># 第二个线性层，其输入大小为 n_hidden，输出大小为 voc_size，即词汇表大小
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">linear2</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">n_hidden</span><span class="p">,</span> <span class="n">voc_size</span><span class="p">)</span> 
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">):</span>  <span class="c1"># 定义前向传播过程
</span>        <span class="c1"># 输入数据 X 张量的形状为 [batch_size, n_step]
</span>        <span class="n">X</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">C</span><span class="p">(</span><span class="n">X</span><span class="p">)</span>  <span class="c1"># 将 X 通过词嵌入层，形状变为 [batch_size, n_step, embedding_size]        
</span>        <span class="n">X</span> <span class="o">=</span> <span class="n">X</span><span class="p">.</span><span class="n">view</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">n_step</span> <span class="o">*</span> <span class="n">embedding_size</span><span class="p">)</span> <span class="c1"># 形状变为 [batch_size, n_step * embedding_size]
</span>        <span class="c1"># 通过第一个线性层并应用 tanh 激活函数
</span>        <span class="n">hidden</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">tanh</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">linear1</span><span class="p">(</span><span class="n">X</span><span class="p">))</span> <span class="c1"># hidden 张量形状为 [batch_size, n_hidden]
</span>        <span class="c1"># 通过第二个线性层得到输出 
</span>        <span class="n">output</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">linear2</span><span class="p">(</span><span class="n">hidden</span><span class="p">)</span> <span class="c1"># output 形状为 [batch_size, voc_size]
</span>        <span class="k">return</span> <span class="n">output</span> <span class="c1"># 返回输出结果
</span></code></pre></div></div>

<p>使用该模型预测新词，<code class="language-plaintext highlighter-rouge">model(input_batch).data</code> 返回一个矩阵: n × m ， 其中
n = 样本个数（batch_size）
m = 词汇表大小（voc_size）
矩阵中的元素(i, j) 表示：第 i 个样本 → 预测为第 j 个词的“分数（logit）”</p>

<p><strong>总结：NPLM 是 固定窗口 + 全连接 的.</strong></p>
<ul>
  <li><strong>核心思想: 前面 n_step 个词组成一个固定长度特征向量</strong></li>
  <li><strong>代码: 对前 n_step 个 token 做 embedding，然后 flatten 成一个大向量， 再送入 MLP(Linear)。n_step 决定了能利用的前面词的个数，因此无法处理长距离依赖。也是固定窗口(n_step)和全连接(flatten)的由来</strong></li>
  <li><strong>形状变化：\( [\text{batch_size}, n_{step}] \rightarrow [\text{batch_size}, n_{step}, e] \rightarrow [\text{batch_size}, n_{step} \times e] \rightarrow \text{Linear层} \)</strong></li>
</ul>

<p>循环神经网络（Recurrent Neural Network）可以看作一个具有“记忆”的神经网络，RNN 通过 hidden state 递归传递历史信息， 从而支持变长序列(NPLM 里固定 n_step)。之所以叫做<strong>循环</strong>，是指当前时间步会使用上一个时间步的隐藏状态（hidden state）作为输入的一部分。</p>

<p>但普通 RNN 仍然存在长期依赖困难， 因此后来出现了 LSTM。</p>

<p>这里使用 LSTM 作为 RNN 的一种实现:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span> <span class="c1"># 导入神经网络模块
# 定义神经概率语言模型（NPLM）
</span><span class="k">class</span> <span class="nc">RNNLM</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">(</span><span class="n">RNNLM</span><span class="p">,</span> <span class="bp">self</span><span class="p">).</span><span class="n">__init__</span><span class="p">()</span> <span class="c1"># 调用父类的构造函数
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">C</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">voc_size</span><span class="p">,</span> <span class="n">embedding_size</span><span class="p">)</span> <span class="c1"># 定义一个词嵌入层
</span>        <span class="c1"># 用 LSTM 层替代第一个线性层，其输入大小为 embedding_size，隐藏层大小为 n_hidden
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">lstm</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">LSTM</span><span class="p">(</span><span class="n">embedding_size</span><span class="p">,</span> <span class="n">n_hidden</span><span class="p">,</span> <span class="n">batch_first</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span> 
        <span class="c1"># 第二个线性层，其输入大小为 n_hidden，输出大小为 voc_size，即词汇表大小
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">linear</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">n_hidden</span><span class="p">,</span> <span class="n">voc_size</span><span class="p">)</span> 
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">):</span>  <span class="c1"># 定义前向传播过程
</span>        <span class="c1"># 输入数据 X 张量的形状为 [batch_size, n_step]
</span>        <span class="n">X</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">C</span><span class="p">(</span><span class="n">X</span><span class="p">)</span>  <span class="c1"># 将 X 通过词嵌入层，形状变为 [batch_size, n_step, embedding_size]
</span>        <span class="c1"># 通过 LSTM 层
</span>        <span class="n">lstm_out</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">lstm</span><span class="p">(</span><span class="n">X</span><span class="p">)</span> <span class="c1"># lstm_out 形状变为 [batch_size, n_step, n_hidden]
</span>        <span class="c1"># 只选择最后一个时间步的输出作为全连接层的输入，通过第二个线性层得到输出 
</span>        <span class="n">output</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">linear</span><span class="p">(</span><span class="n">lstm_out</span><span class="p">[:,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="p">:])</span> <span class="c1"># output 的形状为 [batch_size, voc_size]
</span>        <span class="k">return</span> <span class="n">output</span> <span class="c1"># 返回输出结果
</span></code></pre></div></div>

<p><strong>总结: RNN/LSTM 语言模型是基于递归状态传递的序列建模</strong></p>
<ul>
  <li><strong>核心思想</strong>: 当前 token + 历史 hidden state 逐步更新上下文表示。注意区别，不再是“完整无损地保存所有 token”，而是整个历史的压缩表示</li>
  <li><strong>代码</strong>：按时间顺序逐 token 输入， hidden state 递归携带历史信息， 最后使用最后时间步 hidden state 预测下一个词</li>
  <li><strong>形状变化</strong>：\( [\text{batch_size}, n_{step}] \rightarrow [\text{batch_size}, n_{step}, e] \rightarrow [\text{batch_size}, n_{step}, h] \rightarrow [\text{batch_size}, h] \rightarrow \text{Linear层} \)</li>
  <li><strong>循环(Recurrent)的含义</strong>：当前时间步会使用上一个时间步的隐藏状态（hidden state）作为输入的一部分</li>
</ul>

<p>当然，因为 token_t 依赖 token_(t-1)，RNN/LSTM 无法像 Transformer 那样完全并行计算，
这也是 Transformer 希望解决的问题： 既提升并行计算能力， 又增强长距离依赖建模能力。</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[统计语言模型的发展路线： 图源：《GPT图解-大模型是怎样构建的》 核心：N-Gram → NPLM → RNN → LSTM/GRU → Transformer → BERT/GPT 这篇笔记主要记录： N-Gram: 统计前 n-1 个词出现的概率，计算 \( P(\text{word}n \mid \text{word}{n-1}, \text{word}_{n-2}) \)，不具备泛化能力。 NPLM: 引入 embedding（词向量），具备了 泛化 能力，但仍然是固定窗口 RNN/LSTM: 引入 hidden state 递归，支持了 变长 序列，不再是固定 n_step 的前文词长度 1. N-Gram 和 Bag-of-Words N-Gram 模型是一种简化的概率模型，它通过计算前 N 个词的联合概率来预测下一个词，因此适用于文本生成的场景。 基础流程为： 对每个 N-Gram ，统计紧跟其后的第一个 token 数: {$ngram : {$next-token-1: cnt-1, $next-token-2: cnt-2, ...}} 基于1，就可以计算 $ngram 之后 $next-token-1 $next-token-2 等的概率 基于2，当给定初始语句，就可以一直按照概率采样选择 next-token-x，预测新的句子 该模型是基于以下两个假设： 贾里尼克假设：一个句子是否合理，取决于其出现在自然语言中的可能性的大小 一阶马尔可夫假设：任意一个词出现的概率只同它前面的那一个词有关 虽然现在的语言模型基本不再基于假设 2，但是从 N-Gram 到 Infini-Gram 还是一直有探索，比如 Tiny Infini-Gram，基于超大的 token 统计，通过已有前缀选择下一个词，效果上类似“混搭”了多个来源的数据集。 Bag-of-Words 对每个句子，记录了词汇表中每个词出现的次数，能够计算句子间的相似度，因此适用于计算文本相似度的场景 基础流程为： 分词( jieba etc.) 建立词汇表，每个词给定唯一下标 遍历全部句子逐个处理，生成m*n的结果，其中 m 是句子个数，n 是词汇表大小 单个句子的处理逻辑：用长度为 n 的向量，如果句子包含该词，则在对应下标处记录词频 计算不同向量的余弦相似度，即等价于这两个句子的相似程度 注意 N-Gram 是指连续的 token 序列，不是单纯的字母、单词、字 英文: “playing”可以切分为 ["play", "##ing"], “unstoppable”可以切分为 ["un", "##stop", "##able"] 中文: 孙悟空三打白骨精，可能是白骨精是一个 gram 而 token 如何划分，则由 tokenizer 决定，例如：WordPiece、BPE、SentencePiece 等。 总结： 1. N-Gram 是一种基于统计的语言模型， 通过统计连续 token 序列的条件概率， 利用前 N-1 个 token 预测下一个 token。 2. Bag-of-Words 是一种基于词频统计的文本表示方法， 不考虑词序， 常用于文本分类和相似度计算。 2. Word2Vec：CBOW模型和Skip-Gram模型 Word2Vec（Word to Vector）是一种词向量学习算法，通过上下文预测任务学习词的稠密向量表示。其核心思想是：语义相近的词， 其上下文分布也相近，因此在向量空间中会更加接近。 Word2Vec 和 BoW 都属于文本/词表示学习方法，但 BoW 基于词频统计，而 Word2Vec 通过上下文预测学习。在表示结果上，BoW 的 One-Hot 编码是稀疏的，有1 个 1 ，其余都是 0，而 Word2Vec 学习的向量是稠密的，能够捕捉到更多信息。 Word2Vec 里主要有两种实现方式： Skip-Gram：输入中心词，预测上下文 Continuous Bag of Words：输入上下文，预测中心词 图源：《GPT图解-大模型是怎样构建的》 Skip-Gram 模型示例代码： # 定义 Skip-Gram 类 import torch.nn as nn # 导入 neural network class SkipGram(nn.Module): def __init__(self, voc_size, embedding_size): super(SkipGram, self).__init__() # 从词汇表大小到嵌入层大小（维度）的线性层（权重矩阵） self.input_to_hidden = nn.Linear(voc_size, embedding_size, bias=False) # 从嵌入层大小（维度）到词汇表大小的线性层（权重矩阵） self.hidden_to_output = nn.Linear(embedding_size, voc_size, bias=False) def forward(self, X): # 前向传播的方式，X 形状为 (batch_size, voc_size) # 通过隐藏层，hidden 形状为 (batch_size, embedding_size) hidden = self.input_to_hidden(X) # 通过输出层，output_layer 形状为 (batch_size, voc_size) output = self.hidden_to_output(hidden) return output embedding_size = 2 # 设定嵌入层的大小，这里选择 2 是为了方便展示 skipgram_model = SkipGram(voc_size, embedding_size) # 实例化 Skip-Gram 模型 print("Skip-Gram 模型：", skipgram_model) CBOW 模型示例代码： # 定义 CBOW 模型 import torch.nn as nn # 导入 neural network class CBOW(nn.Module): def __init__(self, voc_size, embedding_size): super(CBOW, self).__init__() # 从词汇表大小到嵌入大小的线性层（权重矩阵） self.input_to_hidden = nn.Linear(voc_size, embedding_size, bias=False) # 从嵌入大小到词汇表大小的线性层（权重矩阵） self.hidden_to_output = nn.Linear(embedding_size, voc_size, bias=False) def forward(self, X): # X: [num_context_words, voc_size] # 生成嵌入：[num_context_words, embedding_size] embeddings = self.input_to_hidden(X) # 计算隐藏层，求嵌入的均值：[embedding_size] hidden_layer = torch.mean(embeddings, dim=0) # 生成输出层：[1, voc_size] output_layer = self.hidden_to_output(hidden_layer.unsqueeze(0)) return output_layer embedding_size = 2 # 设定嵌入层的大小，这里选择 2 是为了方便展示 cbow_model = CBOW(voc_size,embedding_size) # 实例化 CBOW 模型 print("CBOW 模型：", cbow_model) 实际场景里，input_to_hidden通常使用nn.Embedding. 例子里nn.Linear是通过 one-hot × W1 取出对应行，nn.Embedding则是通过 Embedding 直接索引取出对应行，两者是等价的，只是后者更加高效。 在 Word2Vec（Skip-Gram / CBOW）中，通过“预测词”的训练任务，学习得到参数矩阵 W1，其中每一行就是对应词的向量表示（通常作为词向量使用）。 即词向量不是“直接学出来的目标”，而是“为了完成预测任务而学到的中间表示”。 3. NPLM 和 RNN/LSTM 这一章回到语言模型。 神经概率语言模型(Neural Probabilistic Language Model) 是一种早期神经语言模型。它通过 embedding 将离散 token 转换为连续向量，再通过 MLP（多层感知机）根据前 n_step 个词预测下一个词。 NPLM 和 Word2Vec 都会学习词向量 embedding，但两者目标不同： Word2Vec 的核心目标是学习词表示； NPLM 的核心目标是预测下一个词（语言建模）。 NPLM 基础流程为： 根据语料构建词汇表 使用滑动窗口生成训练样本：前 n_step 个词作为输入(input_batch)，第 n_step+1 个词作为目标(target_batch) 定义 NPLM 模型，由 nn.Embedding 和多个 nn.Linear 组成 训练过程中，将 input_batch 输入模型，输出词表上的 logits，并通过 CrossEntropyLoss 计算预测结果与 target_batch 的差距，反向传播不断降低 训练完成后，模型可以根据前面的 n_step 个词预测下一个词 对应代码： import torch.nn as nn # 导入神经网络模块 # 定义神经概率语言模型（NPLM） class NPLM(nn.Module): def __init__(self): super(NPLM, self).__init__() self.C = nn.Embedding(voc_size, embedding_size) # 定义一个词嵌入层 # 第一个线性层，其输入大小为 n_step * embedding_size，输出大小为 n_hidden self.linear1 = nn.Linear(n_step * embedding_size, n_hidden) # 第二个线性层，其输入大小为 n_hidden，输出大小为 voc_size，即词汇表大小 self.linear2 = nn.Linear(n_hidden, voc_size) def forward(self, X): # 定义前向传播过程 # 输入数据 X 张量的形状为 [batch_size, n_step] X = self.C(X) # 将 X 通过词嵌入层，形状变为 [batch_size, n_step, embedding_size] X = X.view(-1, n_step * embedding_size) # 形状变为 [batch_size, n_step * embedding_size] # 通过第一个线性层并应用 tanh 激活函数 hidden = torch.tanh(self.linear1(X)) # hidden 张量形状为 [batch_size, n_hidden] # 通过第二个线性层得到输出 output = self.linear2(hidden) # output 形状为 [batch_size, voc_size] return output # 返回输出结果 使用该模型预测新词，model(input_batch).data 返回一个矩阵: n × m ， 其中 n = 样本个数（batch_size） m = 词汇表大小（voc_size） 矩阵中的元素(i, j) 表示：第 i 个样本 → 预测为第 j 个词的“分数（logit）” 总结：NPLM 是 固定窗口 + 全连接 的. 核心思想: 前面 n_step 个词组成一个固定长度特征向量 代码: 对前 n_step 个 token 做 embedding，然后 flatten 成一个大向量， 再送入 MLP(Linear)。n_step 决定了能利用的前面词的个数，因此无法处理长距离依赖。也是固定窗口(n_step)和全连接(flatten)的由来 形状变化：\( [\text{batch_size}, n_{step}] \rightarrow [\text{batch_size}, n_{step}, e] \rightarrow [\text{batch_size}, n_{step} \times e] \rightarrow \text{Linear层} \) 循环神经网络（Recurrent Neural Network）可以看作一个具有“记忆”的神经网络，RNN 通过 hidden state 递归传递历史信息， 从而支持变长序列(NPLM 里固定 n_step)。之所以叫做循环，是指当前时间步会使用上一个时间步的隐藏状态（hidden state）作为输入的一部分。 但普通 RNN 仍然存在长期依赖困难， 因此后来出现了 LSTM。 这里使用 LSTM 作为 RNN 的一种实现: import torch.nn as nn # 导入神经网络模块 # 定义神经概率语言模型（NPLM） class RNNLM(nn.Module): def __init__(self): super(RNNLM, self).__init__() # 调用父类的构造函数 self.C = nn.Embedding(voc_size, embedding_size) # 定义一个词嵌入层 # 用 LSTM 层替代第一个线性层，其输入大小为 embedding_size，隐藏层大小为 n_hidden self.lstm = nn.LSTM(embedding_size, n_hidden, batch_first=True) # 第二个线性层，其输入大小为 n_hidden，输出大小为 voc_size，即词汇表大小 self.linear = nn.Linear(n_hidden, voc_size) def forward(self, X): # 定义前向传播过程 # 输入数据 X 张量的形状为 [batch_size, n_step] X = self.C(X) # 将 X 通过词嵌入层，形状变为 [batch_size, n_step, embedding_size] # 通过 LSTM 层 lstm_out, _ = self.lstm(X) # lstm_out 形状变为 [batch_size, n_step, n_hidden] # 只选择最后一个时间步的输出作为全连接层的输入，通过第二个线性层得到输出 output = self.linear(lstm_out[:, -1, :]) # output 的形状为 [batch_size, voc_size] return output # 返回输出结果 总结: RNN/LSTM 语言模型是基于递归状态传递的序列建模 核心思想: 当前 token + 历史 hidden state 逐步更新上下文表示。注意区别，不再是“完整无损地保存所有 token”，而是整个历史的压缩表示 代码：按时间顺序逐 token 输入， hidden state 递归携带历史信息， 最后使用最后时间步 hidden state 预测下一个词 形状变化：\( [\text{batch_size}, n_{step}] \rightarrow [\text{batch_size}, n_{step}, e] \rightarrow [\text{batch_size}, n_{step}, h] \rightarrow [\text{batch_size}, h] \rightarrow \text{Linear层} \) 循环(Recurrent)的含义：当前时间步会使用上一个时间步的隐藏状态（hidden state）作为输入的一部分 当然，因为 token_t 依赖 token_(t-1)，RNN/LSTM 无法像 Transformer 那样完全并行计算， 这也是 Transformer 希望解决的问题： 既提升并行计算能力， 又增强长距离依赖建模能力。]]></summary></entry><entry><title type="html">问汝平生功业-读《苏东坡新传》-4</title><link href="https://izualzhy.cn/sdpxz-reading-d" rel="alternate" type="text/html" title="问汝平生功业-读《苏东坡新传》-4" /><published>2026-04-26T01:19:13+00:00</published><updated>2026-04-26T01:19:13+00:00</updated><id>https://izualzhy.cn/sdpxz-reading-d</id><content type="html" xml:base="https://izualzhy.cn/sdpxz-reading-d"><![CDATA[<p>此为读《苏东坡新传》的笔记。</p>

<p>从二月份开始，断断续续的开始读这本书，地铁上、人群中、闹市里、闲暇时、忙里偷闲时，读了二十多个小时。中间因为掺杂众多古文，晦涩难懂，几欲放弃。却又总因对苏东坡生平的好奇、作者真诚的文字，坚持下来。</p>

<p>至此全部读完，合上书卷，竟有意犹未尽之感，不知不觉间，读完了这么一本好书。</p>

<p>如果有什么书能让我感觉相见恨晚，《苏东坡新传》必须算上一本。</p>

<p>李一冰先生说：</p>

<p><strong>每个时代，每一个人，都能从他这面大镜子里，发现自己怀有与他同样的感情，同样的理解，同样的诗情画意，只是我们说不出那些天生的好言语来</strong></p>

<p>这句话，正是我的感受。</p>

<p>苏轼说：“我读《汉书》，至今已经抄过三遍。第一次每段事抄三字为题，第二次两字为题，现在只用一字。”</p>

<p>这本书太厚，我无法将其读薄，且将一些碎碎念记录之。</p>

<h2 id="1-问汝平生功业黄州惠州儋州">1. 问汝平生功业，黄州惠州儋州。</h2>

<p>苏轼自题平生功业，是黄州、惠州、儋州，却不是京城、杭州，初时我非常不解。</p>

<p>黄州是苏轼因乌台诗狱被贬的地方，在这里，他遇到众多好友，精神上超脱自然；手自垦辟东坡，躬耕农亩，物质上自给自足。到了元祐朝，苏轼在政治迫害中，痛苦得无以自解时，竟想逃回这里。</p>

<p>对苏轼来讲，黄州是一个转折点，苏轼蜕变为苏东坡，他的诗词，有了佛学、哲学的理解。</p>

<p>这几首词按照时间年份整理，或许从中，能够一窥他境界上的变化</p>

<ol>
  <li>
    <p>最开始，他颇不适应，盼望北还：<strong>世事一场大梦，人生几度秋凉。夜来风叶已鸣廊，看取眉头鬓上。
酒贱常愁客少，月明多被云妨。中秋谁与共孤光，把盏凄然北望。</strong></p>
  </li>
  <li>
    <p>后来逐渐看淡：<strong>夜饮东坡醒复醉，归来仿佛三更。家童鼻息已雷鸣。敲门都不应，倚杖听江声。
长恨此身非我有，何时忘却营营。夜阑风静縠纹平。小舟从此逝，江海寄余生。</strong></p>
  </li>
  <li>
    <p>精神突围后，他写下了：<strong>大江东去，浪淘尽，千古风流人物。故垒西边，人道是，三国周郎赤壁。乱石穿空，惊涛拍岸，卷起千堆雪。江山如画，一时多少豪杰。
遥想公谨当年，小乔初嫁了，雄姿英发。羽扇纶巾，谈笑间，樯橹灰飞烟灭。故国神游，多情应笑我，早生华发。人生如梦，一樽还酹江月。</strong></p>
  </li>
  <li>
    <p>游黄州赤壁：<strong>盖将自其变者而观之，则天地曾不能以一瞬；自其不变者而观之，则物与我皆无尽也。</strong></p>
  </li>
  <li>
    <p>黄州看田回来：<strong>莫听穿林打叶声，何妨吟啸且徐行。竹杖芒鞋轻胜马，谁怕，一蓑烟雨任平生。料峭春风吹酒醒，微冷，山头斜照却相迎。回首向来萧瑟处，归去，也无风雨也无晴。</strong></p>
  </li>
  <li>
    <p>从黄州离开，到了庐山，写下了家喻户晓的一首诗：<strong>横看成岭侧成峰，远近高低各不同。不识庐山真面目，只缘身在此山中。</strong></p>
  </li>
</ol>

<h2 id="2-人而为仙">2. 人而为仙</h2>

<p>李白生而为仙，所以他写：仙人抚我顶，结发受长生。</p>

<p>而东坡，一代文豪之下，也是一个普通人。他经常不长记性，所以说自己：“虽知难,每以为戒,而临事不能自回”；御史台派人赴湖州逮捕苏轼，他从未见到过这种阵仗，惶恐不敢出见；老成之后，他对当初变法有了新的感悟，跟王荆公说“从公已觉十年迟”。</p>

<p>苏轼为坡仙，是人而为仙。</p>

<h2 id="3-总角闻道白首无成">3. 总角闻道，白首无成</h2>

<p>书的结尾，李雍写 缥缈孤鸿影：父亲与《苏东坡新传》 一节，让人颇为感慨。初时读这本书，是在 2023 年，偶尔翻几页看看，却一直没有继续再读。而即使随着我们长大，有些道理懂的不深，有些感悟、感情总不知道如何表达，佛语说：“念念成劫，尘尘成际”。时光过得那么快，俯仰之间，就过完一辈子。</p>

<p>苏轼如是说：</p>

<p>吾生本无待，俯仰了此世。
念念自成劫，尘尘各有际。
下观生物息，相吹等蚊蚋。</p>]]></content><author><name>ying</name></author><category term="read" /><summary type="html"><![CDATA[此为读《苏东坡新传》的笔记。 从二月份开始，断断续续的开始读这本书，地铁上、人群中、闹市里、闲暇时、忙里偷闲时，读了二十多个小时。中间因为掺杂众多古文，晦涩难懂，几欲放弃。却又总因对苏东坡生平的好奇、作者真诚的文字，坚持下来。 至此全部读完，合上书卷，竟有意犹未尽之感，不知不觉间，读完了这么一本好书。 如果有什么书能让我感觉相见恨晚，《苏东坡新传》必须算上一本。 李一冰先生说： 每个时代，每一个人，都能从他这面大镜子里，发现自己怀有与他同样的感情，同样的理解，同样的诗情画意，只是我们说不出那些天生的好言语来 这句话，正是我的感受。 苏轼说：“我读《汉书》，至今已经抄过三遍。第一次每段事抄三字为题，第二次两字为题，现在只用一字。” 这本书太厚，我无法将其读薄，且将一些碎碎念记录之。 1. 问汝平生功业，黄州惠州儋州。 苏轼自题平生功业，是黄州、惠州、儋州，却不是京城、杭州，初时我非常不解。 黄州是苏轼因乌台诗狱被贬的地方，在这里，他遇到众多好友，精神上超脱自然；手自垦辟东坡，躬耕农亩，物质上自给自足。到了元祐朝，苏轼在政治迫害中，痛苦得无以自解时，竟想逃回这里。 对苏轼来讲，黄州是一个转折点，苏轼蜕变为苏东坡，他的诗词，有了佛学、哲学的理解。 这几首词按照时间年份整理，或许从中，能够一窥他境界上的变化 最开始，他颇不适应，盼望北还：世事一场大梦，人生几度秋凉。夜来风叶已鸣廊，看取眉头鬓上。 酒贱常愁客少，月明多被云妨。中秋谁与共孤光，把盏凄然北望。 后来逐渐看淡：夜饮东坡醒复醉，归来仿佛三更。家童鼻息已雷鸣。敲门都不应，倚杖听江声。 长恨此身非我有，何时忘却营营。夜阑风静縠纹平。小舟从此逝，江海寄余生。 精神突围后，他写下了：大江东去，浪淘尽，千古风流人物。故垒西边，人道是，三国周郎赤壁。乱石穿空，惊涛拍岸，卷起千堆雪。江山如画，一时多少豪杰。 遥想公谨当年，小乔初嫁了，雄姿英发。羽扇纶巾，谈笑间，樯橹灰飞烟灭。故国神游，多情应笑我，早生华发。人生如梦，一樽还酹江月。 游黄州赤壁：盖将自其变者而观之，则天地曾不能以一瞬；自其不变者而观之，则物与我皆无尽也。 黄州看田回来：莫听穿林打叶声，何妨吟啸且徐行。竹杖芒鞋轻胜马，谁怕，一蓑烟雨任平生。料峭春风吹酒醒，微冷，山头斜照却相迎。回首向来萧瑟处，归去，也无风雨也无晴。 从黄州离开，到了庐山，写下了家喻户晓的一首诗：横看成岭侧成峰，远近高低各不同。不识庐山真面目，只缘身在此山中。 2. 人而为仙 李白生而为仙，所以他写：仙人抚我顶，结发受长生。 而东坡，一代文豪之下，也是一个普通人。他经常不长记性，所以说自己：“虽知难,每以为戒,而临事不能自回”；御史台派人赴湖州逮捕苏轼，他从未见到过这种阵仗，惶恐不敢出见；老成之后，他对当初变法有了新的感悟，跟王荆公说“从公已觉十年迟”。 苏轼为坡仙，是人而为仙。 3. 总角闻道，白首无成 书的结尾，李雍写 缥缈孤鸿影：父亲与《苏东坡新传》 一节，让人颇为感慨。初时读这本书，是在 2023 年，偶尔翻几页看看，却一直没有继续再读。而即使随着我们长大，有些道理懂的不深，有些感悟、感情总不知道如何表达，佛语说：“念念成劫，尘尘成际”。时光过得那么快，俯仰之间，就过完一辈子。 苏轼如是说： 吾生本无待，俯仰了此世。 念念自成劫，尘尘各有际。 下观生物息，相吹等蚊蚋。]]></summary></entry><entry><title type="html">从 ADK 看 Google 眼里的 MCP&amp;amp;A2A&amp;amp;Skill</title><link href="https://izualzhy.cn/agent-adk-mcp-a2a-skill" rel="alternate" type="text/html" title="从 ADK 看 Google 眼里的 MCP&amp;amp;A2A&amp;amp;Skill" /><published>2026-04-25T10:18:16+00:00</published><updated>2026-04-25T10:18:16+00:00</updated><id>https://izualzhy.cn/agent-adk-mcp-a2a-skill</id><content type="html" xml:base="https://izualzhy.cn/agent-adk-mcp-a2a-skill"><![CDATA[<p>我眼里的：</p>
<ol>
  <li>许多平台原来开放了 OpenAPI，<strong>MCP</strong> 能够对接到现在的 AI 生态，调用门槛大幅降低。</li>
  <li>当 Agent 多且杂(现实如此)，就会衍生出 Agent 之间的通信需求，<strong>A2A</strong> 像是在设想未来，姑且当做一个标准了解。</li>
  <li><strong>Skill</strong> 通过渐进式披露解决了模型上下文的问题，本质上还是计算机常用的分而治之思想；同时 Skill 支持描述顺序执行/条件分支，避免了拖拉拽搭建工作流。</li>
</ol>

<p>现在从 ADK 的实现和使用方式，来看看其怎么看待这三个概念/技术点的。</p>

<h2 id="1-mcp">1. MCP</h2>

<p>分别是 Client Server 两个角度：</p>
<ol>
  <li>Client: 如何将 ADK tool(s) 转为 MCP Server</li>
  <li>Server: 如何在 ADK agent 中接入其他 MCP Server</li>
</ol>

<p>ADK tool(s) 暴露为 MCP Server，第一步使用<code class="language-plaintext highlighter-rouge">FunctionTool</code>，例如<code class="language-plaintext highlighter-rouge">FunctionTool(load_web_page)</code>，之后就都可以借助 MCP/FastMCP 完成了。</p>

<p>Agent 接入 MCP Server，支持 STDIO StreamableHTTP 。在实现上，通过传入<code class="language-plaintext highlighter-rouge">tools=[MCPSet(..), ]</code>完成。</p>

<p><code class="language-plaintext highlighter-rouge">FunctionTool</code>是个很有用的封装，能够自动提取 name doc args 等信息。这些信息，MCP 和 Agent 都需要用到，因为<strong>两者的相同点是都需要拿到这些信息，区别则是 MCP 给到 Agent，而 Agent 给到大模型</strong>。</p>

<p>之前笔记定义 Agent 的例子里，传入的普通函数，实际是转为了<code class="language-plaintext highlighter-rouge">FunctionTool</code></p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">_convert_tool_union_to_tools</span><span class="p">(</span>
    <span class="n">tool_union</span><span class="p">:</span> <span class="n">ToolUnion</span><span class="p">,</span>
    <span class="n">ctx</span><span class="p">:</span> <span class="n">ReadonlyContext</span><span class="p">,</span>
    <span class="n">model</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">BaseLlm</span><span class="p">],</span>
    <span class="n">multiple_tools</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="bp">False</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">BaseTool</span><span class="p">]:</span>
  <span class="p">...</span>

  <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">tool_union</span><span class="p">,</span> <span class="n">BaseTool</span><span class="p">):</span>
    <span class="k">return</span> <span class="p">[</span><span class="n">tool_union</span><span class="p">]</span>
  <span class="k">if</span> <span class="nb">callable</span><span class="p">(</span><span class="n">tool_union</span><span class="p">):</span>
    <span class="k">return</span> <span class="p">[</span><span class="n">FunctionTool</span><span class="p">(</span><span class="n">func</span><span class="o">=</span><span class="n">tool_union</span><span class="p">)]</span>
</code></pre></div></div>

<p>结合上面的<code class="language-plaintext highlighter-rouge">tools=[MCPSet(..), ]</code>方法，我们可以看到 ADK 里一个非常好的设计，那就是用 <strong>ToolUnion 抽象了工具这一层</strong>。</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ToolUnion</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Union</span><span class="p">[</span><span class="n">Callable</span><span class="p">,</span> <span class="n">BaseTool</span><span class="p">,</span> <span class="n">BaseToolset</span><span class="p">]</span>
</code></pre></div></div>

<p>在 ADK 里，这些都是工具：</p>
<ol>
  <li>普通函数 (Callable): 如 get_weather</li>
  <li>工具实例 (BaseTool): 如 FunctionTool 实例</li>
  <li>工具集 (BaseToolset): 如 MCPToolset 实例</li>
</ol>

<p>区别是 MCPServer 自身的“工具”列表，需要连接后获取。</p>

<p>上述详细代码参见<sup>1<sup></sup></sup></p>

<h2 id="2-a2a">2. A2A</h2>

<p>MCP 协议里，统一了 Tools Resources Prompts 等的调用方式，那如果想要在 Agent 里，跟另一个 Agent 通信呢？</p>

<p>A2A 协议想要先发占领的，就是这个领域。</p>

<p>回归到问题本身，多个 Agent 之间，为何不能本地通信？ADK 里是这么说的：</p>

<ul>
  <li>Local Sub-Agents: These are agents that run within the same application process as your main agent. They are like internal modules or libraries, used to organize your code into logical, reusable components. Communication between a main agent and its local sub-agents is very fast because it happens directly in memory, without network overhead.</li>
  <li>Remote Agents (A2A): These are independent agents that run as separate services, communicating over a network. A2A defines the standard protocol for this communication.</li>
</ul>

<p>简言之:</p>
<ul>
  <li>什么时候用本地模式：代码内部、延迟要求低的内部操作、共享内存、简单的 helper function</li>
  <li>什么时候用 A2A：想要做成微服务架构、第三方提供的 agent 服务、跨语言agent通信、控制格式</li>
</ul>

<p>注：有点像是古老的 本地函数还是远程调用 的区分方式😅</p>

<p>怎么做的？我一直没搞懂的两点在这里明确了：</p>
<ol>
  <li>给每一个 agent 一个名片，包含了 name description 以及都有哪些能力</li>
  <li>Agent 通信实现用的 http 协议</li>
</ol>

<p>具体的，基于 ADK 里普通 agent，定义<code class="language-plaintext highlighter-rouge">a2a_app = to_a2a(some_agent, ...)</code>，然后可以使用<code class="language-plaintext highlighter-rouge">uvicorn xxx:a2a_app --host --port </code>启动 HTTP 服务，作为 A2A Server；然后定义 <code class="language-plaintext highlighter-rouge">root_agent: RemoteA2aAgent</code>，在<code class="language-plaintext highlighter-rouge">agent-card</code>里指定 server AGENT_CARD_WELL_KNOWN_PATH(e.g. /.well-known/agent-card.json) 地址。</p>

<p>在<code class="language-plaintext highlighter-rouge">adk web</code>里使用<code class="language-plaintext highlighter-rouge">root_agent</code>，就可以达到调用其他 Agent 的目的了。</p>

<p>其中 agent card 形如：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
    </span><span class="nl">"defaultInputModes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"text/plain"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"defaultOutputModes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"text/plain"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hello world agent that can roll a dice of 8 sides and check prime numbers."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hello_world_agent"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"preferredTransport"</span><span class="p">:</span><span class="w"> </span><span class="s2">"JSONRPC"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"protocolVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.3.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"skills"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hello world agent that can roll a dice of 8 sides and check prime numbers. </span><span class="se">\n</span><span class="s2">      I roll dice and answer questions about the outcome of the dice rolls.</span><span class="se">\n</span><span class="s2">      I can roll dice of different sizes.</span><span class="se">\n</span><span class="s2">      I can use multiple tools in parallel by calling functions in parallel(in one request and in one round).</span><span class="se">\n</span><span class="s2">      It is ok to discuss previous dice roles, and comment on the dice rolls.</span><span class="se">\n</span><span class="s2">      When I am asked to roll a die, I must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string.</span><span class="se">\n</span><span class="s2">      I should never roll a die on my own.</span><span class="se">\n</span><span class="s2">      When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. I should never pass in a string.</span><span class="se">\n</span><span class="s2">      I should not check prime numbers before calling the tool.</span><span class="se">\n</span><span class="s2">      When I am asked to roll a die and check prime numbers, I should always make the following two function calls:</span><span class="se">\n</span><span class="s2">      1. I should first call the roll_die tool to get a roll. Wait for the function response before calling the check_prime tool.</span><span class="se">\n</span><span class="s2">      2. After I get the function response from roll_die tool, I should call the check_prime tool with the roll_die result.</span><span class="se">\n</span><span class="s2">        2.1 If user asks I to check primes based on previous rolls, make sure I include the previous rolls in the list.</span><span class="se">\n</span><span class="s2">      3. When I respond, I must include the roll_die result from step 1.</span><span class="se">\n</span><span class="s2">      I should always perform the previous 3 steps when asking for a roll and checking prime numbers.</span><span class="se">\n</span><span class="s2">      I should not rely on the previous history on prime results.</span><span class="se">\n</span><span class="s2">    "</span><span class="p">,</span><span class="w">
            </span><span class="nl">"examples"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hello_world_agent"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"model"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"llm"</span><span class="w">
            </span><span class="p">]</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Roll a die and return the rolled result.</span><span class="se">\n\n</span><span class="s2">Args:</span><span class="se">\n</span><span class="s2">  sides: The integer number of sides the die has.</span><span class="se">\n</span><span class="s2">  tool_context: the tool context</span><span class="se">\n</span><span class="s2">Returns:</span><span class="se">\n</span><span class="s2">  An integer of the result of rolling the die."</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hello_world_agent-roll_die"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"roll_die"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"llm"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"tools"</span><span class="w">
            </span><span class="p">]</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Check if a given list of numbers are prime.</span><span class="se">\n\n</span><span class="s2">Args:</span><span class="se">\n</span><span class="s2">  nums: The list of numbers to check.</span><span class="se">\n\n</span><span class="s2">Returns:</span><span class="se">\n</span><span class="s2">  A str indicating which number is prime."</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hello_world_agent-check_prime"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"check_prime"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"llm"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"tools"</span><span class="w">
            </span><span class="p">]</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"supportsAuthenticatedExtendedCard"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
    </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:8001"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.0.1"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>详细代码可以参考：adk-python/contributing/samples a2a_root a2a_basic</p>

<h2 id="3-skill">3. Skill</h2>

<p>Skill 的介绍里，是允许它逐步加载，以尽量减少对代理操作上下文窗口的影响。但其实 MCP 的 list_tool，Agent 里的 card，都是这个效果，不是吗？我觉得 Skill 更被人容易接受主要是三点：</p>

<ol>
  <li>效果好：大模型的指令遵从性越来越强</li>
  <li>门槛低：不需要懂代码，只要你逻辑性强、懂金字塔原理，就可以很好的将自身业务经验转化为 Skill</li>
  <li>易传播：一个目录，copy 过来就能用</li>
</ol>

<p>所以我主要研究了 ADK 的实现里是如何支持 Skill 的。</p>

<p>在 ADK 里使用 skill，需要在 tools 里指定：<code class="language-plaintext highlighter-rouge">tools=[SkillToolSet(...), ], </code>(注：<code class="language-plaintext highlighter-rouge">SkillToolSet MCPToolSet</code>都属于<code class="language-plaintext highlighter-rouge">BaseToolSet</code>，即提供了工具集的工具)</p>

<p><code class="language-plaintext highlighter-rouge">SkillToolSet</code>里包含多个<code class="language-plaintext highlighter-rouge">Skill</code>，<code class="language-plaintext highlighter-rouge">Skill</code>定义为：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Skill</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
  <span class="s">"""Complete skill representation including frontmatter, instructions, and resources.

  A skill combines:
  - L1: Frontmatter for discovery (name, description).
  - L2: Instructions from SKILL.md body, loaded when skill is triggered.
  - L3: Resources including additional instructions, assets, and scripts,
  loaded as needed.

  Attributes:
      frontmatter: Parsed skill frontmatter from SKILL.md.
      instructions: L2 skill content: markdown instruction from SKILL.md body.
      resources: L3 skill content: additional instructions, assets, and scripts.
  """</span>
  <span class="p">...</span>
</code></pre></div></div>

<p>即：</p>

<p><img src="/assets/images/adk/part1-progressive-disclosure_1.original.png" alt="" width="600" /></p>

<p><strong>L1 -&gt; L2 -&gt; L3 从抽象到具体，从全局到细节，构建了整体的 Skill.</strong></p>

<p>当 Agent 里使用了 Skill，会读取 L1 的内容(<code class="language-plaintext highlighter-rouge">format_skills_as_xml</code>)，添加到给 LLM 的 prompt 里：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">experimental</span><span class="p">(</span><span class="n">FeatureName</span><span class="p">.</span><span class="n">SKILL_TOOLSET</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">SkillToolset</span><span class="p">(</span><span class="n">BaseToolset</span><span class="p">):</span>
  <span class="s">"""A toolset for managing and interacting with agent skills."""</span>
  <span class="k">async</span> <span class="k">def</span> <span class="nf">process_llm_request</span><span class="p">(</span>
      <span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">tool_context</span><span class="p">:</span> <span class="n">ToolContext</span><span class="p">,</span> <span class="n">llm_request</span><span class="p">:</span> <span class="n">LlmRequest</span>
  <span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""Processes the outgoing LLM request to include available skills."""</span>
    <span class="n">skills</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_list_skills</span><span class="p">()</span>
    <span class="n">skills_xml</span> <span class="o">=</span> <span class="n">prompt</span><span class="p">.</span><span class="n">format_skills_as_xml</span><span class="p">(</span><span class="n">skills</span><span class="p">)</span>
    <span class="n">instructions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">instructions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">_DEFAULT_SKILL_SYSTEM_INSTRUCTION</span><span class="p">)</span>
    <span class="n">instructions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">skills_xml</span><span class="p">)</span>
    <span class="n">llm_request</span><span class="p">.</span><span class="n">append_instructions</span><span class="p">(</span><span class="n">instructions</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">_DEFAULT_SKILL_SYSTEM_INSTRUCTION</code>这段提示词，告诉了模型：</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">load_skill</code>用于读取 full instruction</li>
  <li><code class="language-plaintext highlighter-rouge">load_skill_resource</code>用读取 directory (e.g., <code class="language-plaintext highlighter-rouge">references/*</code>, <code class="language-plaintext highlighter-rouge">assets/*</code>, <code class="language-plaintext highlighter-rouge">scripts/*</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">run_skill_script</code>用于执行脚本</li>
</ol>

<p>对应到<code class="language-plaintext highlighter-rouge">SkillToolset</code>本身提供的工具：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="bp">self</span><span class="p">.</span><span class="n">_tools</span> <span class="o">=</span> <span class="p">[</span>
        <span class="n">ListSkillsTool</span><span class="p">(</span><span class="bp">self</span><span class="p">),</span>       <span class="c1"># 给出所有的 Skills L1 内容
</span>        <span class="n">LoadSkillTool</span><span class="p">(</span><span class="bp">self</span><span class="p">),</span>        <span class="c1"># 指定 Skill 名字，读取 SKILL.md instructions 
</span>        <span class="n">LoadSkillResourceTool</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="c1"># 指定 Skill 名字，读取 resource 
</span>        <span class="n">RunSkillScriptTool</span><span class="p">(</span><span class="bp">self</span><span class="p">),</span>   <span class="c1"># 指定 Skill 名字，执行 script 
</span>    <span class="p">]</span>
</code></pre></div></div>

<p>注：<code class="language-plaintext highlighter-rouge">format_skills_as_xml</code>用的 XML 格式，之前看到有说法是 XML 的标签结构对 LLM 更友好，更容易被理解和解析，感觉不大可信。</p>

<p>接下来就是进入到 Agent Loop:</p>
<ol>
  <li>LLM prompt 会包含包含两部分 instruction：agent 自身的，以及上述加入的系统的</li>
  <li>工具列表会包含两部分：固定传递<code class="language-plaintext highlighter-rouge">SkillToolSet</code>自身的四个工具，以及随着 LLM 回复里动态加入的工具</li>
  <li>Skill.md 也是 instruction，但是实际是作为工具调用结果返回</li>
  <li>如果 Skill 里用到了 Agent 自身的工具，ADK 里使用 state 上下文来记录了该信息</li>
</ol>

<p>其他跟普通 Agent 实现区别不大，该调用嘛调用嘛。</p>

<p>ADK 里是这么介绍跟 Skill 的区别的：</p>

<blockquote>
  <p>What is the difference between ADK skills and tools?
Tools give agents the ability to take actions — call APIs, read files, query databases. Skills teach agents when and how to use those tools effectively. A tool is “call the weather API.” A skill is “when the user asks about travel, check weather for each destination, compare results, and format as an itinerary.” Skills compose on top of tools — see Part 1’s explanation for the full distinction.</p>
</blockquote>

<p>详细代码可以参考：adk-python/contributing/samples skills_agent</p>

<h2 id="4-总结">4. 总结</h2>

<p>如果基于当前的现状看，我觉得三者没什么太大联系，相同点是都是 AI 进程里非常火的概念。</p>

<ol>
  <li>MCP: 无论是 HTTP 还是本地调用，预期都能够产生明确的调用结果、返回</li>
  <li>A2A: 当你需要远程调用一个 Agent 时</li>
  <li>Skill: 用文字作为入口，在需要确定性的时候，则执行代码</li>
</ol>

<p>但是随着发展，你很难说当 MCP 成为事实标准后，会不会又在 resource prompt tool 之上，提出 agent system module 等各种概念，那尽管 A2A 有先发优势，实际遵循的可能也是 MCP 协议。</p>

<p>而 Agent 之间确实有通信需求吗？我想 A2A 如果有足够动力，对于框架使用者来说，完全可以像 aka 那样，使用者无需感知是本地还是远程，通过协议本身<code class="language-plaintext highlighter-rouge">local://weather_agent..</code> <code class="language-plaintext highlighter-rouge">cluster:svc_name://weather_agent..</code>来区分，将复杂度留给框架而不是用户自己区分。</p>

<p>Agent 开发平台，试图通过拖拉拽搭建出来确定逻辑的工作流(比如 Dify Coze 试图做的那样)，但是拖拉拽这项低代码技术一直都不温不火（高不成低不就，会写代码的觉得不灵活，不会写代码的觉得太费劲，纯个人见解），而 Skill 则支持了普通用户用文字描述这个流程。</p>

<p>但是 Skill 装多了，一上来也会用掉很多 token ，所以也会有说法是自己根据情况加载，这种时候，使用能通信的 agent 集群，就是一个更好的选择了吧？而 Agent 之间的通信、何时调用 Skill、MCP、其他 Agent，就是 Agent 框架里要做的事情了。比如你不能在 Skill 里写，在 xxx 情况下连接这个 MCP，连接这个 Agent.</p>

<p>整体上，他们都有的特点：：</p>
<ol>
  <li>多方受益，符合经济学</li>
  <li>分而治之，符合技术思想</li>
  <li>使用简单，符合用户习惯</li>
</ol>

<h2 id="5-参考资料">5. 参考资料</h2>

<ol>
  <li><a href="https://github.com/izualzhy/AI-Systems/tree/main/google_adk/agent_using_fs_mcp_server">agent_using_fs_mcp_server</a></li>
  <li><a href="https://developers.googleblog.com/developers-guide-to-building-adk-agents-with-skills/">developers-guide-to-building-adk-agents-with-skills</a></li>
  <li><a href="https://lavinigam.com/posts/adk-skill-design-patterns/?utm_source=lavinigam&amp;utm_medium=reddit&amp;utm_campaign=adk-skill-design-patterns">5 Agent Skill Design Patterns Every ADK Developer Should Know</a></li>
</ol>]]></content><author><name>ying</name></author><category term="AI" /><summary type="html"><![CDATA[我眼里的： 许多平台原来开放了 OpenAPI，MCP 能够对接到现在的 AI 生态，调用门槛大幅降低。 当 Agent 多且杂(现实如此)，就会衍生出 Agent 之间的通信需求，A2A 像是在设想未来，姑且当做一个标准了解。 Skill 通过渐进式披露解决了模型上下文的问题，本质上还是计算机常用的分而治之思想；同时 Skill 支持描述顺序执行/条件分支，避免了拖拉拽搭建工作流。 现在从 ADK 的实现和使用方式，来看看其怎么看待这三个概念/技术点的。 1. MCP 分别是 Client Server 两个角度： Client: 如何将 ADK tool(s) 转为 MCP Server Server: 如何在 ADK agent 中接入其他 MCP Server ADK tool(s) 暴露为 MCP Server，第一步使用FunctionTool，例如FunctionTool(load_web_page)，之后就都可以借助 MCP/FastMCP 完成了。 Agent 接入 MCP Server，支持 STDIO StreamableHTTP 。在实现上，通过传入tools=[MCPSet(..), ]完成。 FunctionTool是个很有用的封装，能够自动提取 name doc args 等信息。这些信息，MCP 和 Agent 都需要用到，因为两者的相同点是都需要拿到这些信息，区别则是 MCP 给到 Agent，而 Agent 给到大模型。 之前笔记定义 Agent 的例子里，传入的普通函数，实际是转为了FunctionTool async def _convert_tool_union_to_tools( tool_union: ToolUnion, ctx: ReadonlyContext, model: Union[str, BaseLlm], multiple_tools: bool = False, ) -&gt; list[BaseTool]: ... if isinstance(tool_union, BaseTool): return [tool_union] if callable(tool_union): return [FunctionTool(func=tool_union)] 结合上面的tools=[MCPSet(..), ]方法，我们可以看到 ADK 里一个非常好的设计，那就是用 ToolUnion 抽象了工具这一层。 ToolUnion: TypeAlias = Union[Callable, BaseTool, BaseToolset] 在 ADK 里，这些都是工具： 普通函数 (Callable): 如 get_weather 工具实例 (BaseTool): 如 FunctionTool 实例 工具集 (BaseToolset): 如 MCPToolset 实例 区别是 MCPServer 自身的“工具”列表，需要连接后获取。 上述详细代码参见1 2. A2A MCP 协议里，统一了 Tools Resources Prompts 等的调用方式，那如果想要在 Agent 里，跟另一个 Agent 通信呢？ A2A 协议想要先发占领的，就是这个领域。 回归到问题本身，多个 Agent 之间，为何不能本地通信？ADK 里是这么说的： Local Sub-Agents: These are agents that run within the same application process as your main agent. They are like internal modules or libraries, used to organize your code into logical, reusable components. Communication between a main agent and its local sub-agents is very fast because it happens directly in memory, without network overhead. Remote Agents (A2A): These are independent agents that run as separate services, communicating over a network. A2A defines the standard protocol for this communication. 简言之: 什么时候用本地模式：代码内部、延迟要求低的内部操作、共享内存、简单的 helper function 什么时候用 A2A：想要做成微服务架构、第三方提供的 agent 服务、跨语言agent通信、控制格式 注：有点像是古老的 本地函数还是远程调用 的区分方式😅 怎么做的？我一直没搞懂的两点在这里明确了： 给每一个 agent 一个名片，包含了 name description 以及都有哪些能力 Agent 通信实现用的 http 协议 具体的，基于 ADK 里普通 agent，定义a2a_app = to_a2a(some_agent, ...)，然后可以使用uvicorn xxx:a2a_app --host --port 启动 HTTP 服务，作为 A2A Server；然后定义 root_agent: RemoteA2aAgent，在agent-card里指定 server AGENT_CARD_WELL_KNOWN_PATH(e.g. /.well-known/agent-card.json) 地址。 在adk web里使用root_agent，就可以达到调用其他 Agent 的目的了。 其中 agent card 形如： { "capabilities": {}, "defaultInputModes": [ "text/plain" ], "defaultOutputModes": [ "text/plain" ], "description": "hello world agent that can roll a dice of 8 sides and check prime numbers.", "name": "hello_world_agent", "preferredTransport": "JSONRPC", "protocolVersion": "0.3.0", "skills": [ { "description": "hello world agent that can roll a dice of 8 sides and check prime numbers. \n I roll dice and answer questions about the outcome of the dice rolls.\n I can roll dice of different sizes.\n I can use multiple tools in parallel by calling functions in parallel(in one request and in one round).\n It is ok to discuss previous dice roles, and comment on the dice rolls.\n When I am asked to roll a die, I must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string.\n I should never roll a die on my own.\n When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. I should never pass in a string.\n I should not check prime numbers before calling the tool.\n When I am asked to roll a die and check prime numbers, I should always make the following two function calls:\n 1. I should first call the roll_die tool to get a roll. Wait for the function response before calling the check_prime tool.\n 2. After I get the function response from roll_die tool, I should call the check_prime tool with the roll_die result.\n 2.1 If user asks I to check primes based on previous rolls, make sure I include the previous rolls in the list.\n 3. When I respond, I must include the roll_die result from step 1.\n I should always perform the previous 3 steps when asking for a roll and checking prime numbers.\n I should not rely on the previous history on prime results.\n ", "examples": [], "id": "hello_world_agent", "name": "model", "tags": [ "llm" ] }, { "description": "Roll a die and return the rolled result.\n\nArgs:\n sides: The integer number of sides the die has.\n tool_context: the tool context\nReturns:\n An integer of the result of rolling the die.", "id": "hello_world_agent-roll_die", "name": "roll_die", "tags": [ "llm", "tools" ] }, { "description": "Check if a given list of numbers are prime.\n\nArgs:\n nums: The list of numbers to check.\n\nReturns:\n A str indicating which number is prime.", "id": "hello_world_agent-check_prime", "name": "check_prime", "tags": [ "llm", "tools" ] } ], "supportsAuthenticatedExtendedCard": false, "url": "http://localhost:8001", "version": "0.0.1" } 详细代码可以参考：adk-python/contributing/samples a2a_root a2a_basic 3. Skill Skill 的介绍里，是允许它逐步加载，以尽量减少对代理操作上下文窗口的影响。但其实 MCP 的 list_tool，Agent 里的 card，都是这个效果，不是吗？我觉得 Skill 更被人容易接受主要是三点： 效果好：大模型的指令遵从性越来越强 门槛低：不需要懂代码，只要你逻辑性强、懂金字塔原理，就可以很好的将自身业务经验转化为 Skill 易传播：一个目录，copy 过来就能用 所以我主要研究了 ADK 的实现里是如何支持 Skill 的。 在 ADK 里使用 skill，需要在 tools 里指定：tools=[SkillToolSet(...), ], (注：SkillToolSet MCPToolSet都属于BaseToolSet，即提供了工具集的工具) SkillToolSet里包含多个Skill，Skill定义为： class Skill(BaseModel): """Complete skill representation including frontmatter, instructions, and resources. A skill combines: - L1: Frontmatter for discovery (name, description). - L2: Instructions from SKILL.md body, loaded when skill is triggered. - L3: Resources including additional instructions, assets, and scripts, loaded as needed. Attributes: frontmatter: Parsed skill frontmatter from SKILL.md. instructions: L2 skill content: markdown instruction from SKILL.md body. resources: L3 skill content: additional instructions, assets, and scripts. """ ... 即： L1 -&gt; L2 -&gt; L3 从抽象到具体，从全局到细节，构建了整体的 Skill. 当 Agent 里使用了 Skill，会读取 L1 的内容(format_skills_as_xml)，添加到给 LLM 的 prompt 里： @experimental(FeatureName.SKILL_TOOLSET) class SkillToolset(BaseToolset): """A toolset for managing and interacting with agent skills.""" async def process_llm_request( self, *, tool_context: ToolContext, llm_request: LlmRequest ) -&gt; None: """Processes the outgoing LLM request to include available skills.""" skills = self._list_skills() skills_xml = prompt.format_skills_as_xml(skills) instructions = [] instructions.append(_DEFAULT_SKILL_SYSTEM_INSTRUCTION) instructions.append(skills_xml) llm_request.append_instructions(instructions) _DEFAULT_SKILL_SYSTEM_INSTRUCTION这段提示词，告诉了模型： load_skill用于读取 full instruction load_skill_resource用读取 directory (e.g., references/*, assets/*, scripts/*) run_skill_script用于执行脚本 对应到SkillToolset本身提供的工具： self._tools = [ ListSkillsTool(self), # 给出所有的 Skills L1 内容 LoadSkillTool(self), # 指定 Skill 名字，读取 SKILL.md instructions LoadSkillResourceTool(self) # 指定 Skill 名字，读取 resource RunSkillScriptTool(self), # 指定 Skill 名字，执行 script ] 注：format_skills_as_xml用的 XML 格式，之前看到有说法是 XML 的标签结构对 LLM 更友好，更容易被理解和解析，感觉不大可信。 接下来就是进入到 Agent Loop: LLM prompt 会包含包含两部分 instruction：agent 自身的，以及上述加入的系统的 工具列表会包含两部分：固定传递SkillToolSet自身的四个工具，以及随着 LLM 回复里动态加入的工具 Skill.md 也是 instruction，但是实际是作为工具调用结果返回 如果 Skill 里用到了 Agent 自身的工具，ADK 里使用 state 上下文来记录了该信息 其他跟普通 Agent 实现区别不大，该调用嘛调用嘛。 ADK 里是这么介绍跟 Skill 的区别的： What is the difference between ADK skills and tools? Tools give agents the ability to take actions — call APIs, read files, query databases. Skills teach agents when and how to use those tools effectively. A tool is “call the weather API.” A skill is “when the user asks about travel, check weather for each destination, compare results, and format as an itinerary.” Skills compose on top of tools — see Part 1’s explanation for the full distinction. 详细代码可以参考：adk-python/contributing/samples skills_agent 4. 总结 如果基于当前的现状看，我觉得三者没什么太大联系，相同点是都是 AI 进程里非常火的概念。 MCP: 无论是 HTTP 还是本地调用，预期都能够产生明确的调用结果、返回 A2A: 当你需要远程调用一个 Agent 时 Skill: 用文字作为入口，在需要确定性的时候，则执行代码 但是随着发展，你很难说当 MCP 成为事实标准后，会不会又在 resource prompt tool 之上，提出 agent system module 等各种概念，那尽管 A2A 有先发优势，实际遵循的可能也是 MCP 协议。 而 Agent 之间确实有通信需求吗？我想 A2A 如果有足够动力，对于框架使用者来说，完全可以像 aka 那样，使用者无需感知是本地还是远程，通过协议本身local://weather_agent.. cluster:svc_name://weather_agent..来区分，将复杂度留给框架而不是用户自己区分。 Agent 开发平台，试图通过拖拉拽搭建出来确定逻辑的工作流(比如 Dify Coze 试图做的那样)，但是拖拉拽这项低代码技术一直都不温不火（高不成低不就，会写代码的觉得不灵活，不会写代码的觉得太费劲，纯个人见解），而 Skill 则支持了普通用户用文字描述这个流程。 但是 Skill 装多了，一上来也会用掉很多 token ，所以也会有说法是自己根据情况加载，这种时候，使用能通信的 agent 集群，就是一个更好的选择了吧？而 Agent 之间的通信、何时调用 Skill、MCP、其他 Agent，就是 Agent 框架里要做的事情了。比如你不能在 Skill 里写，在 xxx 情况下连接这个 MCP，连接这个 Agent. 整体上，他们都有的特点：： 多方受益，符合经济学 分而治之，符合技术思想 使用简单，符合用户习惯 5. 参考资料 agent_using_fs_mcp_server developers-guide-to-building-adk-agents-with-skills 5 Agent Skill Design Patterns Every ADK Developer Should Know]]></summary></entry><entry><title type="html">Google ADK 是如何实现可观测的？</title><link href="https://izualzhy.cn/agent-adk-observability" rel="alternate" type="text/html" title="Google ADK 是如何实现可观测的？" /><published>2026-04-18T06:18:16+00:00</published><updated>2026-04-18T06:18:16+00:00</updated><id>https://izualzhy.cn/agent-adk-observability</id><content type="html" xml:base="https://izualzhy.cn/agent-adk-observability"><![CDATA[<h2 id="1-何时需要可观测性">1. 何时需要可观测性</h2>

<p>智能体交互变慢了，是调用工具还是查询知识库变慢了？工具调用了第三方的 API，估计是网络波动。哦不，知识库底层的 Milvus 没有做存算隔离，可能是这里的性能瓶颈？或者是系统缺少隔离，其他用户影响了当前用户？</p>

<p>当你开始考虑这个问题时，就说明系统缺少可观测的能力了。</p>

<p>得益于微服务架构的发展，智能体技术从构建之初，就可以基于成熟的可观测的能力。该能力，是解决智能体<strong>从有用到可用</strong>的关键点。</p>

<p><em>注：除了 CodeAgent 以外，为何大部分的智能体都只是 demo 而没有用，就是另外一个话题了。</em></p>

<p>从架构角度，需要提前考虑如何让智能体变得可用。无论系统设计的如何健壮，SRE 如何运维和弹性伸缩资源，系统长时间运行，总会出问题，而哪里会出问题则是不可预测的。</p>

<p>可观测性也是生产系统的基础：</p>
<ol>
  <li>发现问题：比如观察到 调用逐渐变慢、返回错误 status 的次数 在逐渐增加</li>
  <li>止损：发现是第三方工具调用变慢，则替换或者下掉工具</li>
  <li>报告：是哪里出了问题，是否有连锁反应？通过指标，我们还观察到哪里也是隐患，只是本次没有暴露出来</li>
  <li>改进：基于问题发生时的指标，比如 P99 延迟、错误率、业务逻辑指标等，避免只是追责而不知道如何优化</li>
</ol>

<p>上述效果也离不开传统的监控能力，或者我们通常说监控是从指标角度，而可观测则是换到了系统角度。</p>

<h2 id="2-adk-的可观测能力">2. ADK 的可观测能力</h2>

<p>ADK 原生支持接入多种可观测平台<sup>1</sup>，我用来验证的 agent 设计如下(实在不知道什么 agent 好用，只好总是用天气介绍😂)：</p>

<pre><code class="language-mermaid">graph TB
    root_agent[主 Agent: root_agent&lt;br/&gt;协调调用子Agent] --&gt; agent_a[Agent A: weather_agent&lt;br/&gt;查询城市天气]
    root_agent --&gt; agent_b[Agent B: clothing_agent&lt;br/&gt;提供穿衣建议]
    
    agent_a --&gt; get_weather[工具: get_weather&lt;br/&gt;返回固定天气数据&lt;br/&gt;延迟2.5秒 + 自定义trace属性]
    agent_b --&gt; get_clothing_advice[工具: get_clothing_advice&lt;br/&gt;返回固定穿衣建议&lt;br/&gt;延迟1.5秒]
    
    style root_agent fill:#e1f5fe
</code></pre>

<p>调用上述 agent 查看链路：</p>

<p><img src="/assets/images/adk/jaeger_adk.png" alt="" /></p>

<p>可以看到：</p>
<ol>
  <li>一次 QA 的<strong>完整流程</strong>：例如从<code class="language-plaintext highlighter-rouge">root_agent</code> transfer 到<code class="language-plaintext highlighter-rouge">weather_agent</code>，以及调用 LLM(call_llm)、tool(execute_tool) 在哪个阶段发生</li>
  <li><strong>性能数据</strong>：例如<code class="language-plaintext highlighter-rouge">generate_content</code>一共 5.59s，其中调用<code class="language-plaintext highlighter-rouge">get_weather</code>这个 tool 用了 2.5s.</li>
  <li><strong>链路对比</strong>：例如两次调用 agent，有一次输出似乎没有使用本地工具，也可以对比正常、异常两次 traceID，使用 Compare 来对比链路的差异</li>
</ol>

<h3 id="21-可观测链路">2.1. 可观测链路</h3>

<p>因为只验证效果，所以采用 adk 直连 Jaeger 的方式</p>

<p>图片是 Jaeger 介绍的直连架构<sup>2</sup>：
<img src="/assets/images/jaeger/architecture-v2-2024.png" alt="" /></p>

<p>Jaeger 上述组件在 V2 版本简化为了一个 bin，启动后我们的实际链路非常简洁：</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph App[adk web]
        SDK[OpenTelemetry SDK&lt;br/&gt;OTEL_SERVICE_NAME=google_adk]
    end

    subgraph Backend[localhost:4318]
        Jaeger[Jaeger 后端]
    end

    SDK -- "Traces" --&gt; Jaeger

    style SDK fill:#e1f5fe
    style Jaeger fill:#fff3e0
</code></pre>

<p>集成可观测能力并不复杂，ADK 在内部已经实现了打点，我们要做的是在<code class="language-plaintext highlighter-rouge">adk web</code>前设置 OTLP 的环境变量，提供遥测数据发送的后端地址。</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">OTEL_EXPORTER_OTLP_ENDPOINT</span><span class="o">=</span><span class="s1">'http://localhost:4318'</span>
<span class="nb">export </span><span class="nv">OTEL_EXPORTER_OTLP_PROTOCOL</span><span class="o">=</span><span class="s1">'http/protobuf'</span>
<span class="nb">export </span><span class="nv">OTEL_SERVICE_NAME</span><span class="o">=</span><span class="s1">'google_adk'</span>
</code></pre></div></div>

<p>当然也可以自定义属性，例如：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_weather</span><span class="p">(</span><span class="n">city</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="s">"""查询指定城市的天气"""</span>
    <span class="c1"># 模拟耗时：调用`get_weather`耗时 2.5 
</span>    <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mf">2.5</span><span class="p">)</span>
    <span class="n">span</span> <span class="o">=</span> <span class="n">trace</span><span class="p">.</span><span class="n">get_current_span</span><span class="p">()</span>
    <span class="n">span</span><span class="p">.</span><span class="n">set_attribute</span><span class="p">(</span><span class="s">"custom_key"</span><span class="p">,</span> <span class="s">"current tool is get_weather"</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"--- Tool: get_weather called for </span><span class="si">{</span><span class="n">city</span><span class="si">}</span><span class="s"> ---"</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"city"</span><span class="p">:</span> <span class="n">city</span><span class="p">,</span> <span class="s">"weather"</span><span class="p">:</span> <span class="s">"晴天"</span><span class="p">,</span> <span class="s">"temperature"</span><span class="p">:</span> <span class="s">"25°C"</span><span class="p">}</span>
</code></pre></div></div>

<p><img src="/assets/images/adk/jaeger_custom_attr.png" alt="" /></p>

<p>如果放到生产环境，可能还要考虑两点：</p>
<ol>
  <li>不丢数据：Jaeger 提供了 Via Kafka<sup>2</sup>的方式</li>
  <li>ADK 还集成了发送 log 的能力，如果不存在 log 端点会报错<code class="language-plaintext highlighter-rouge">Failed to export logs batch code: 404, reason: 404 page not found</code>，可能需要引入 OpenTeLemetry Collector 作为中转组件。</li>
</ol>

<p>测试代码放到了 github<sup>3</sup> 上</p>

<h3 id="22-如何做到的">2.2. 如何做到的</h3>

<ol>
  <li>启动时：初始化各类 provider,例如<code class="language-plaintext highlighter-rouge">TracerProvider LoggerProvider MeterProvider</code>，读取环境变量并初始化对应的处理器(<code class="language-plaintext highlighter-rouge">maybe_set_otel_providers</code>)</li>
  <li>调用时：例如 agent <code class="language-plaintext highlighter-rouge">run_async</code>调用的实现，会首先用<code class="language-plaintext highlighter-rouge">with tracer.start_as_current_span(...)</code>记录下来，即人工埋点</li>
</ol>

<p>如果是 adk web 的使用场景，也可以直接使用其 trace 能力
<img src="/assets/images/adk/adk_web_traces_sample.png" alt="" width="300" /></p>

<h2 id="3-参考资料">3. 参考资料</h2>

<ol>
  <li><a href="https://adk.dev/observability/">ADK - Observability for agents</a></li>
  <li><a href="https://www.jaegertracing.io/docs/2.17/architecture/">Jaeger - architecture</a></li>
  <li><a href="https://github.com/izualzhy/AI-Systems/tree/main/google_adk/observability_agent">Github - AI-Stems</a></li>
</ol>]]></content><author><name>ying</name></author><category term="AI" /><summary type="html"><![CDATA[1. 何时需要可观测性 智能体交互变慢了，是调用工具还是查询知识库变慢了？工具调用了第三方的 API，估计是网络波动。哦不，知识库底层的 Milvus 没有做存算隔离，可能是这里的性能瓶颈？或者是系统缺少隔离，其他用户影响了当前用户？ 当你开始考虑这个问题时，就说明系统缺少可观测的能力了。 得益于微服务架构的发展，智能体技术从构建之初，就可以基于成熟的可观测的能力。该能力，是解决智能体从有用到可用的关键点。 注：除了 CodeAgent 以外，为何大部分的智能体都只是 demo 而没有用，就是另外一个话题了。 从架构角度，需要提前考虑如何让智能体变得可用。无论系统设计的如何健壮，SRE 如何运维和弹性伸缩资源，系统长时间运行，总会出问题，而哪里会出问题则是不可预测的。 可观测性也是生产系统的基础： 发现问题：比如观察到 调用逐渐变慢、返回错误 status 的次数 在逐渐增加 止损：发现是第三方工具调用变慢，则替换或者下掉工具 报告：是哪里出了问题，是否有连锁反应？通过指标，我们还观察到哪里也是隐患，只是本次没有暴露出来 改进：基于问题发生时的指标，比如 P99 延迟、错误率、业务逻辑指标等，避免只是追责而不知道如何优化 上述效果也离不开传统的监控能力，或者我们通常说监控是从指标角度，而可观测则是换到了系统角度。 2. ADK 的可观测能力 ADK 原生支持接入多种可观测平台1，我用来验证的 agent 设计如下(实在不知道什么 agent 好用，只好总是用天气介绍😂)： graph TB root_agent[主 Agent: root_agent&lt;br/&gt;协调调用子Agent] --&gt; agent_a[Agent A: weather_agent&lt;br/&gt;查询城市天气] root_agent --&gt; agent_b[Agent B: clothing_agent&lt;br/&gt;提供穿衣建议] agent_a --&gt; get_weather[工具: get_weather&lt;br/&gt;返回固定天气数据&lt;br/&gt;延迟2.5秒 + 自定义trace属性] agent_b --&gt; get_clothing_advice[工具: get_clothing_advice&lt;br/&gt;返回固定穿衣建议&lt;br/&gt;延迟1.5秒] style root_agent fill:#e1f5fe 调用上述 agent 查看链路： 可以看到： 一次 QA 的完整流程：例如从root_agent transfer 到weather_agent，以及调用 LLM(call_llm)、tool(execute_tool) 在哪个阶段发生 性能数据：例如generate_content一共 5.59s，其中调用get_weather这个 tool 用了 2.5s. 链路对比：例如两次调用 agent，有一次输出似乎没有使用本地工具，也可以对比正常、异常两次 traceID，使用 Compare 来对比链路的差异 2.1. 可观测链路 因为只验证效果，所以采用 adk 直连 Jaeger 的方式 图片是 Jaeger 介绍的直连架构2： Jaeger 上述组件在 V2 版本简化为了一个 bin，启动后我们的实际链路非常简洁： flowchart LR subgraph App[adk web] SDK[OpenTelemetry SDK&lt;br/&gt;OTEL_SERVICE_NAME=google_adk] end subgraph Backend[localhost:4318] Jaeger[Jaeger 后端] end SDK -- "Traces" --&gt; Jaeger style SDK fill:#e1f5fe style Jaeger fill:#fff3e0 集成可观测能力并不复杂，ADK 在内部已经实现了打点，我们要做的是在adk web前设置 OTLP 的环境变量，提供遥测数据发送的后端地址。 export OTEL_EXPORTER_OTLP_ENDPOINT='http://localhost:4318' export OTEL_EXPORTER_OTLP_PROTOCOL='http/protobuf' export OTEL_SERVICE_NAME='google_adk' 当然也可以自定义属性，例如： def get_weather(city: str) -&gt; dict: """查询指定城市的天气""" # 模拟耗时：调用`get_weather`耗时 2.5 time.sleep(2.5) span = trace.get_current_span() span.set_attribute("custom_key", "current tool is get_weather") print(f"--- Tool: get_weather called for {city} ---") return {"city": city, "weather": "晴天", "temperature": "25°C"} 如果放到生产环境，可能还要考虑两点： 不丢数据：Jaeger 提供了 Via Kafka2的方式 ADK 还集成了发送 log 的能力，如果不存在 log 端点会报错Failed to export logs batch code: 404, reason: 404 page not found，可能需要引入 OpenTeLemetry Collector 作为中转组件。 测试代码放到了 github3 上 2.2. 如何做到的 启动时：初始化各类 provider,例如TracerProvider LoggerProvider MeterProvider，读取环境变量并初始化对应的处理器(maybe_set_otel_providers) 调用时：例如 agent run_async调用的实现，会首先用with tracer.start_as_current_span(...)记录下来，即人工埋点 如果是 adk web 的使用场景，也可以直接使用其 trace 能力 3. 参考资料 ADK - Observability for agents Jaeger - architecture Github - AI-Stems]]></summary></entry></feed>