从 LLM 到 Agent Skill,龙虾的技术基础 · ① LLM
从 LLM 到 Agent Skill,龙虾的技术基础 · ① LLM
本系列共 8 篇,从 LLM 一直讲到 Agent Skill。总览:从 LLM 到 Agent Skill:我理解的 AI 应用进化路线
这篇写给谁
你可能已经听过 LLM、Transformer、注意力 这些词,也知道大模型在「猜下一个词」,但中间这一大段——神经网络怎么一步步长成今天这个样子——有点模糊。这篇就用尽量少术语、尽量说人话的方式,帮你把这条线捋直。
读完你不需要会推公式,只要心里有一张粗线条地图:后面看别人聊架构、聊论文,不至于完全接不上。
神经网络到底在干什么
可以把它想成很多层「小开关」叠在一起。
- 输入:比如一张手写数字图片,展平成一长串数字(像素明暗)。
- 神经元:对上一层的数加权求和,再过一个非线性函数(你可以先理解成「把结果掰弯一下,不然多层叠起来和一层没区别」)。
- 多层:浅层可能只看出边缘、小块;层数多了,可以组合成更抽象的东西(笔画、部件,再到「像几」)。
- 学习:就是在调这些权重和偏置,让输出越来越接近你想要的答案(比如「这是数字 7」)。
训练时常说的梯度下降、反向传播,你可以先记一句:算出差了多少,把误差一层层往回传,告诉每一层该怎么微调。 不必深究细节,知道「模型是在调参数」就够了。
MLP:最朴素的「全连接」网络
**MLP(多层感知机)**听起来唬人,其实就是:一层一层全连接,数据从入口单向流到出口,中间没有「绕圈」。
- 全连接的意思是:这一层的每个点,都和上一层的每一个点相连。参数多,但结构简单。
- 适合表格数据、向量特征,也常作为别的模型后面的「分类头」。
后面出现的 CNN、RNN、Transformer,本质上还是「前向算一遍、反向更新参数」,只是连接方式变了——为了更省参数、更适合某种数据(图像、序列、文本)。
CNN:给图像「省参数」的办法
图像是整整齐齐的格子(像素)。如果对每个像素都全连接,参数会爆炸。
**卷积神经网络(CNN)**用了两个很自然的想法:
- 只看一小块:就像用一个小窗口在图上滑,先看局部有没有边、角、纹理,再组合成更大图案。这叫局部连接,参数少很多。
- 同一套权重到处用:同一个「小探测器」在整幅图上滑,重复用同一组数。这叫权值共享,参数再砍一截,而且对图像平移之类的情况更稳。
图像领域 CNN 火了很久。它说明一件事:网络结构不是越复杂越好,而是要符合数据的样子。 文本后来也走过类似思路——先为序列设计结构(比如 RNN),再演进到更适合并行训练的 Transformer。
RNN / LSTM:先学会「读一串字」
文字、语音、股价,都是按顺序来的。循环神经网络(RNN)的思路是:每一步不光看当前输入,还带上上一步留下的状态,像带一点「短期记忆」。
问题是:句子一长,很久以前的信息很难传到最后,训练时还容易梯度消失(可以理解为误差传回去时越变越弱)。于是有了 LSTM、GRU 这类带「门」的结构,专门缓解长距离依赖——在 Transformer 流行之前,做翻译、语音识别很多都用它们。
你只要记住:RNN 系是「一个字一个字往后啃」;后来大家发现,这种啃法并行度差、长句吃力,才更积极地去用「注意力」那一套。
NLP 几条线,串到「下一词预测」
和 LLM 关系比较大的几件事,用大白话说一下:
- 统计语言模型:数一数词挨着出现的频率,猜下一个词——这是最朴素的「下一词」思想。
- 词向量(如 Word2Vec):把每个词变成一串数字(向量),意思相近的词,在空间里离得近。神经网络处理文本时,终于有了一个稳定的输入格式。
- Seq2Seq(编码器–解码器):一端读进去,一端生成出来,典型如机器翻译;后面又加上注意力,让生成时不是死背编码,而是能「回头看」原文不同位置。
这些都在解决同一件事:文本怎么变成计算机能学的表示,以及长距离信息怎么传。 搞顺了,你就容易理解为什么 Transformer 会出现。
Transformer:为什么现在大家言必称它
2017 年的论文《Attention Is All You Need》提出了 Transformer。用人话概括几个关键点:
(1)自注意力:每个位置都能「看见」其它位置
不像 RNN 那样严格按顺序传,自注意力让一句话里任意两个词可以直接建立联系(权重由内容算出来)。长距离依赖比纯 RNN 自然很多。
(2)并行训练更友好
RNN 一步一步算,GPU 很难吃满;Transformer 这种结构更容易一块算,大模型时代这点特别重要。
(3)顺序不能丢:位置编码
注意力本身不区分「谁在前谁在后」,所以必须额外告诉模型每个词的位置(正弦编码或可学习位置嵌入等)。你可以记:没有位置信息,句子就是一袋词,顺序乱了模型也不知道。
(4)多头注意力
同一层里做好几套「注意力」,再合起来,相当于让模型从多个角度看同一句话(有的偏语法,有的偏指代……不必记死,知道「多头 = 多视角」即可)。
(5)一块块叠起来
典型是一块里:注意力 → 简单的前馈网络(可以理解为每个位置各自过一个小 MLP)→ 加残差、做归一化,然后堆很多层。你听说过的 BERT 偏编码、GPT 偏解码生成,都是在这套骨架上换花样,再加大规模数据和算力。
今天说的 LLM,绝大多数就是 Transformer 这一脉 练出来的大模型——差别主要在数据、规模、对齐方式,而不是换一个完全不同的数学物种。
vLLM 采样参数详解:Temperature、Top-p、Top-k
模型算完一轮,得到的是每个候选 token 的 logits(可以理解为「原始得分」,还没变成概率)。真正吐出下一个词之前,还要经过:缩放 → 变成概率 →(可选)裁掉一批低概率候选 → 再按概率随机抽一个。
Temperature、Top-p、Top-k 就是在这条链路上动手脚的三类旋钮——vLLM、OpenAI API、HuggingFace 推理里名字大同小异,直觉是共通的。
下面分开说「人话版含义」,再给一段 NumPy 演示代码,把流程跑通一遍(不是 vLLM 源码,只为理解数学过程)。
说明: 真实推理框架里,Top-k 与 Top-p 谁先谁后、是否和论文里的 nucleus 实现细节完全一致,以各库文档为准;这里演示用的是一种常见教学顺序:先 temperature + softmax,再 top-p,再 top-k,最后归一化采样。
Temperature(温度)在干什么
- logits 会先除以
temperature,再去做 softmax 变成概率。 - 温度越低(比如 0.2、0.5):概率分布越「尖」,最高分那几个 token 更占优势,输出更稳、更像复读机,创意变少。
- 温度越高(比如 1.2、1.5):分布更「平」,低分 token 也有机会被抽到,输出更随机、更发散,但也更容易胡说八道。
- 温度 = 1:一般不改变 logits 的相对比例(只是除以 1),相当于「按模型原始自信程度采样」。
一句话:温度调的是「有多敢乱选」——低则保守,高则放飞。
Top-p(核采样,Nucleus Sampling)
- 先把所有 token 按概率从大到小排队,从队首开始累加概率,直到累加和超过你设的
top_p(例如 0.9),就只在这「一小块高概率集合」里玩。 - 直觉:不看长尾里那一堆几乎不可能的词,避免模型在很离谱的选项里抽奖;
top_p越小,圈子缩得越狠,输出越稳。 top_p = 1:不裁,保留全体 token(在 softmax 之后、若不再配合极小 top_k,理论上仍可能抽到极低概率词)。
一句话:Top-p 是「只保留概率质量够集中的一坨」,动态决定保留几个候选。
Top-k
- 只保留概率最高的 k 个 token,其余直接扔掉,再在这 k 个里归一化、采样。
- k 越小:越死板、越可预测;k 越大:候选越多,多样性上来,但计算和随机性都增加。
- 常和 Top-p 一起用:先缩小候选池,再控制随机性(具体顺序以引擎为准)。
一句话:Top-k 是「硬截断:最多只留前 k 名」。
和「贪心解码」对比
- 贪心(greedy):每一步永远选概率最大的那个 token——没有随机性,温度、top-p、top-k 往往就失去意义(或退化成只留一个候选)。
- 采样(sampling):按概率抽,才有「同一句上文,每次回答略不同」的体验。
演示代码(综合 temperature + top-p + top-k)
下面用一组虚构的 logits 走完整流程,你可以改 temperature、top_p、top_k 看多轮采样的频率变化。
## vLLM采样参数详解(教学演示)
import numpy as np
from collections import Counter
import random
def demonstrate_sampling_logic(temperature=1.0, top_p=1.0, top_k=None):
"""
演示综合采样逻辑:同时使用temperature、top_p和top_k参数
采样流程:
1. 应用temperature缩放logits
2. 应用softmax得到概率分布
3. 应用top_p过滤(核采样)
4. 应用top_k过滤
5. 重新归一化并采样
Args:
temperature: 温度参数,控制随机性(默认1.0)
top_p: 核采样参数,累积概率阈值(默认1.0,即不限制)
top_k: Top-k参数,保留概率最高的k个token(默认None,即不限制)
"""
# 模拟语言模型输出的logits(未归一化的概率)
logits = np.array([3.2, 3.1, 2.1, 2.5, 1.8, 1.2, 0.5, -0.2, -1.0, -2.5])
token_names = ['the', 'a', 'is', 'was', 'and', 'or', 'but', 'if', 'then', 'else']
print("=" * 60)
print("=== 综合采样逻辑演示 ===")
print(f"参数设置: temperature={temperature}, top_p={top_p}, top_k={top_k}")
print("=" * 60)
print(f"\n原始logits: {logits}")
print(f"Token名称: {token_names}\n")
# 步骤1: 应用temperature缩放
print("步骤1: 应用Temperature缩放")
print("-" * 40)
scaled_logits = logits / temperature
print(f"缩放后logits: {scaled_logits}\n")
# 步骤2: 应用softmax得到概率分布
print("步骤2: 应用Softmax得到概率分布")
print("-" * 40)
exp_logits = np.exp(scaled_logits)
probabilities = exp_logits / np.sum(exp_logits)
print("温度调整后的概率分布:")
for i, (token, prob) in enumerate(zip(token_names, probabilities)):
print(f" {token}: {prob:.4f}")
print()
# 步骤3: 应用top_p过滤(核采样)
print(f"步骤3: 应用Top-p过滤 (top_p={top_p})")
print("-" * 40)
# 按概率从高到低排序
sorted_indices = np.argsort(probabilities)[::-1]
sorted_probs = probabilities[sorted_indices]
sorted_tokens = [token_names[i] for i in sorted_indices]
# 计算累积概率
cumulative_probs = np.cumsum(sorted_probs)
# 找到累积概率≤top_p的token
if top_p < 1.0:
valid_mask = cumulative_probs <= top_p
if not np.any(valid_mask):
# 如果累积概率都超过top_p,至少保留概率最高的token
valid_mask[0] = True
filtered_probs = sorted_probs[valid_mask]
filtered_tokens = [sorted_tokens[i] for i in range(len(sorted_tokens)) if valid_mask[i]]
print(f"Top-p过滤后选择的token数量: {len(filtered_tokens)}")
print(f"选择的token: {filtered_tokens}")
print(f"累积概率: {cumulative_probs[valid_mask]}")
else:
# top_p=1.0时不进行过滤
filtered_probs = sorted_probs
filtered_tokens = sorted_tokens
print("Top-p=1.0,不进行过滤,保留所有token")
print()
# 步骤4: 应用top_k过滤
print(f"步骤4: 应用Top-k过滤 (top_k={top_k})")
print("-" * 40)
if top_k is not None and top_k < len(filtered_tokens):
# 只保留前top_k个token
final_probs = filtered_probs[:top_k]
final_tokens = filtered_tokens[:top_k]
print(f"Top-k过滤后选择的token数量: {len(final_tokens)}")
print(f"选择的token: {final_tokens}")
else:
final_probs = filtered_probs
final_tokens = filtered_tokens
if top_k is None:
print("Top-k=None,不进行过滤")
else:
print(f"Top-k={top_k},但过滤后的token数量({len(filtered_tokens)})≤top_k,保留所有token")
print()
# 步骤5: 重新归一化并采样
print("步骤5: 重新归一化概率分布并采样")
print("-" * 40)
normalized_probs = final_probs / np.sum(final_probs)
print("最终归一化概率分布:")
for token, prob in zip(final_tokens, normalized_probs):
print(f" {token}: {prob:.4f}")
print()
# 模拟多次采样
random.seed(42)
num_samples = 20
samples = np.random.choice(final_tokens, size=num_samples, p=normalized_probs)
sample_counts = Counter(samples)
print(f"{num_samples}次采样结果:")
print(f" 采样序列: {list(samples)}")
print(f" 采样频率统计:")
for token in final_tokens:
count = sample_counts.get(token, 0)
freq = count / num_samples
print(f" {token}: {count}次 ({freq:.2%})")
print("\n" + "=" * 60)
print("演示完成!")
print("=" * 60 + "\n")
if __name__ == "__main__":
demonstrate_sampling_logic(temperature=0.4, top_p=0.9, top_k=1)
怎么玩: 把上面代码存成 .py,装好 numpy 后运行。试着改三处:
- 只动
temperature:看概率分布变尖还是变平; - 固定温度,改小
top_p:看候选 token 是否被砍掉一大截; - 设
top_k=3:看最终是否只剩三个词在竞争。
Transformer 推理在干什么:自回归 + KV 缓存
训练好的生成式 LLM,推理时做的是一件事:反复「猜下一个 token」,直到凑够长度或遇到结束符。这叫自回归生成——每产出一位新词,就把它拼进上下文,再算下一位。
为什么不能每次都把整段话重新算一遍
最笨的办法:第 1 步输入 5 个 prompt token,第 2 步输入 6 个,第 3 步输入 7 个……每一步都对整段序列做一遍注意力。这样第 (n) 步的计算量大致随长度平方涨,长对话会慢到不可用。
KV 缓存就是为这个问题准备的:历史 token 的 Key、Value 在每一步已经算过了,而且只要上下文不变,它们就不会变。所以可以把每一层算出来的 K、V 存下来;新一步只给模型最新那一个 token,用它的 Q 去和「缓存里所有历史 K」算注意力,再聚合 V。这样每步只算新 token 对应的投影,长度很长时加速非常明显(vLLM、llama.cpp 等都在这条思路上做工程优化)。
为什么通常只缓存 K、V,不缓存 Q
- Q 表示「当前这个位置在查谁」。每生成新 token,只出现新的查询向量,必须现算。
- K、V 表示「历史每个位置能提供什么信息」。已经生成过的 token 不会再变,所以 K、V 可以增量追加进缓存。
直观类比:图书馆里书架(K/V)已经摆好了;你每问一个新问题,只更新你手里的「问题卡」(Q),不用把书架重摆一遍。
实现时要注意的一处细节:位置编码
增量解码时,每一步喂给模型的往往只有长度 1 的 input_ids(新 token)。如果还像「整段预填」那样只用 pos_encoding[:1],新 token 会永远被当成第 0 个位置,语义就错了。正确做法是按全局下标取位置向量,例如用 pos_offset 表示「当前片段在整句里的起点」。下面代码里用 pos_offset 做了这件事(很多教学 demo 会漏掉这一点)。
下面这段代码在演示什么
用 PyTorch 写了一个迷你 Transformer(多层自注意力 + 前馈 + LayerNorm),在 MultiHeadAttention 里实现了 K/V 拼接缓存;demonstrate_transformer_inference 模拟:第一步吃完整 prompt,之后每步只吃新 token,并打印每层 K 缓存的长度是否在增长。需要本机已安装 PyTorch。完整脚本与下文一致,也可直接打开同目录下的 simple_transformer_kv_demo.py 运行。
"""
简单的Transformer推理框架演示
用于理解Transformer架构和KV缓存的工作原理
"""
import math
from typing import List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadAttention(nn.Module):
"""多头注意力:Q/K/V 线性投影 + 缩放点积注意力;推理时可传入历史 K/V 缓存。"""
def __init__(self, d_model: int, num_heads: int, d_k: Optional[int] = None):
super().__init__()
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_k if d_k is not None else d_model // num_heads
# 将同一输入投影为 Q、K、V(自注意力时 query/key/value 同源)
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
self.scale = math.sqrt(self.d_k)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
batch_size, seq_len, _ = query.shape
# Q 必须用当前输入现算(新 token 的「查询」);K、V 可与缓存拼接
Q = self.W_q(query)
K = self.W_k(key)
V = self.W_v(value)
if kv_cache is not None:
K_cache, V_cache = kv_cache
K = torch.cat([K_cache, K], dim=1)
V = torch.cat([V_cache, V], dim=1)
Q = Q.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
K = K.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
V = V.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale
attention_weights = F.softmax(scores, dim=-1)
context = torch.matmul(attention_weights, V)
context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
output = self.W_o(context)
# 返回本步之后「全长」K、V,供下一步继续追加
new_kv_cache = (
K.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model),
V.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model),
)
return output, new_kv_cache
class FeedForward(nn.Module):
def __init__(self, d_model: int, d_ff: int):
super().__init__()
self.linear1 = nn.Linear(d_model, d_ff)
self.linear2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(0.1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.linear2(F.relu(self.linear1(x)))
class TransformerBlock(nn.Module):
def __init__(self, d_model: int, num_heads: int, d_ff: int):
super().__init__()
self.attention = MultiHeadAttention(d_model, num_heads)
self.feed_forward = FeedForward(d_model, d_ff)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(0.1)
def forward(
self,
x: torch.Tensor,
kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
attn_output, new_kv_cache = self.attention(x, x, x, kv_cache)
x = self.norm1(x + attn_output)
ff_output = self.feed_forward(x)
x = self.norm2(x + ff_output)
return x, new_kv_cache
class SimpleTransformer(nn.Module):
def __init__(self, vocab_size: int, d_model: int, num_layers: int, num_heads: int, d_ff: int):
super().__init__()
self.d_model = d_model
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoding = nn.Parameter(torch.randn(1000, d_model))
self.layers = nn.ModuleList([TransformerBlock(d_model, num_heads, d_ff) for _ in range(num_layers)])
self.output_layer = nn.Linear(d_model, vocab_size)
def forward(
self,
input_ids: torch.Tensor,
kv_cache: Optional[List[Tuple[torch.Tensor, torch.Tensor]]] = None,
pos_offset: int = 0,
) -> Tuple[torch.Tensor, List[Tuple[torch.Tensor, torch.Tensor]]]:
batch_size, seq_len = input_ids.shape
x = self.embedding(input_ids)
# 增量解码:按全局位置取 pos,避免 seq_len=1 时永远用第 0 个位置编码
x = x + self.pos_encoding[pos_offset : pos_offset + seq_len].unsqueeze(0)
new_kv_cache: List[Tuple[torch.Tensor, torch.Tensor]] = []
for i, layer in enumerate(self.layers):
layer_kv = kv_cache[i] if kv_cache is not None else None
x, layer_new_kv = layer(x, layer_kv)
new_kv_cache.append(layer_new_kv)
logits = self.output_layer(x)
return logits, new_kv_cache
def demonstrate_transformer_inference(num_steps: int = 8):
vocab_size = 1000
d_model = 128
num_layers = 2
num_heads = 4
d_ff = 512
model = SimpleTransformer(vocab_size, d_model, num_layers, num_heads, d_ff)
model.eval()
print("=== Transformer 推理演示 ===\n")
print(f"词汇表: {vocab_size}, d_model: {d_model}, 层数: {num_layers}, 头数: {num_heads}\n")
prompt = torch.tensor([[1, 2, 3, 4, 5]])
print(f"初始提示词: {prompt[0].tolist()}")
kv_cache = None
for step in range(num_steps):
print(f"\n--- 生成步骤 {step + 1} ---")
if step == 0:
current_input = prompt
pos_offset = 0
else:
current_input = next_token
pos_offset = prompt.shape[1] - current_input.shape[1]
with torch.no_grad():
logits, new_kv_cache = model(current_input, kv_cache, pos_offset=pos_offset)
last_logits = logits[0, -1, :]
next_token = torch.argmax(last_logits).unsqueeze(0).unsqueeze(0)
print(f" 本步输入长度: {current_input.shape[1]}, pos_offset: {pos_offset}")
print(f" logits 形状: {tuple(logits.shape)}, 下一 token: {next_token.item()}")
prompt = torch.cat([prompt, next_token], dim=1)
kv_cache = new_kv_cache
if kv_cache is not None:
print(f" 第1层 K 缓存序列长度: {kv_cache[0][0].shape[1]}")
print(f"\n最终序列: {prompt[0].tolist()}")
if __name__ == "__main__":
demonstrate_transformer_inference(num_steps=8)
与本地可运行脚本 逐行一致 时,请以 simple_transformer_kv_demo.py 为准(含打印格式与 num_steps);上表为便于排版略有压缩,复制运行请直接运行该 .py 文件。
收束:落到「你用的那个聊天模型」
前面从 MLP、CNN、RNN 讲到 Transformer,不是为了背名词,而是让你知道:LLM 不是天上掉下来的,是在「怎么表示文本、怎么传信息、怎么并行训练」这条路上长出来的。
落到日常做产品、调应用,记住三句就够:
- LLM 本质:在给定上下文的情况下,预测下一个 token(词片段)。
- 它不是搜索引擎:事实、时效、私有数据,要靠 RAG、工具、校验 补。
- 体验和成本:上下文怎么裁剪、输出怎么约束,直接决定效果和账单——这就是本系列后面会展开的 Token、Context 等话题。
小结(带走这张地图)
- 神经网络:层层加权 + 非线性,通过调参数拟合任务。
- MLP:最简单的全连接堆叠,是理解其它结构的底板。
- CNN:利用局部性和权值共享,特别适合图像。
- RNN/LSTM:按顺序处理序列,曾长期统治 NLP,但长句与并行是短板。
- Transformer:用自注意力建立全局联系、易并行,成为现代 LLM 的主干。
- 推理采样:logits → softmax 成概率后,用 temperature 调「保守/放飞」,用 top-p 裁掉长尾,用 top-k 限制候选个数,再抽样得到下一个 token(与 vLLM 等引擎里常见参数一一对应)。
- 推理加速:自回归生成时复用历史 KV 缓存,避免每步对整段序列重复算 K/V;增量解码时别忘了给新 token 正确的位置编码(如
pos_offset)。
下一篇:《② Token》——为什么说 token 是成本、延迟和「能记多少」的硬约束。
延伸阅读(想啃原文时)
- 论文:Attention Is All You Need(NeurIPS 2017)
- 概念梳理:维基百科 · Attention Is All You Need
系列:龙虾的技术基础 ①/8
更多推荐


所有评论(0)