Chapter 09

Prompt / Prefix / P-Tuning:不改权重的另一条路

📌 commit a106ff4c7061 把可学参数塞进输入端的三种方法

LoRA 系是"改 Linear 权重"的思路;本章的方法走另一条路—— 权重一行不动,在输入或每层注入可学的"软 prompt"。 这种路径参数量更小、保存更轻,但训练通常更难调。

9.1三种方法的关系

方法注入位置注入形式论文
Prompt Tuning 仅 embedding 层 $n$ 个可学 token embedding,prepend Lester et al. 2021
Prefix Tuning 每个 transformer 层 注入到 K 和 V 的 cache Li & Liang 2021
P-Tuning v1 仅 embedding 层 同 Prompt Tuning,但中间过 MLP 重参数化Liu et al. 2021
P-Tuning v2 每个 transformer 层 同 Prefix,但去掉重参数化 MLP Liu et al. 2022
Multitask Prompt 仅 embedding 层 跨任务共享的 prompt + 任务特定 prompt Wang et al. 2023

9.2Prompt Tuning(最简单)

设可学 prompt $P = [p_1, p_2, \ldots, p_n] \in \mathbb{R}^{n \times d}$,把它prepend 到输入 embedding 前:

$$H_0 = [P \;;\; \text{Embed}(x_1), \ldots, \text{Embed}(x_T)] \in \mathbb{R}^{(n+T)\times d}$$

训练时只更新 $P$ 这 $nd$ 个参数。LLaMA-7B 用 $n=20$,总可训参数仅 80K

用法

from peft import PromptTuningConfig, PromptTuningInit, get_peft_model, TaskType

cfg = PromptTuningConfig(
    task_type=TaskType.CAUSAL_LM,
    num_virtual_tokens=20,
    prompt_tuning_init=PromptTuningInit.TEXT,          # 用真实文本初始化
    prompt_tuning_init_text="Translate the input English text to French:",
    tokenizer_name_or_path="meta-llama/Llama-2-7b-hf",
)
model = get_peft_model(model, cfg)

实现:src/peft/tuners/prompt_tuning/model.pyPromptTuningInit 取值:

9.3Prefix Tuning

Prompt Tuning 只在 embedding 层注入,信息要"流"过整个 transformer 才能到达深层。Prefix Tuning 直接在每一层都注入——具体是给 KV cache 加上可学的"虚拟 key/value":

$$K_l^{new} = [P^K_l \;;\; K_l],\quad V_l^{new} = [P^V_l \;;\; V_l],\;\; l = 1\ldots L$$

每层 prefix 长度 $n$、维度 $d$,所以总参数 $2Lnd$。$L = 32$、$d = 4096$、$n = 20$ 时约 5.2M——比 Prompt Tuning 大但仍然小。

from peft import PrefixTuningConfig, get_peft_model

cfg = PrefixTuningConfig(
    task_type=TaskType.CAUSAL_LM,
    num_virtual_tokens=20,
    prefix_projection=True,          # ★ 中间过一层 MLP 重参数化(稳定训练)
    encoder_hidden_size=512,         # MLP 隐层
)
model = get_peft_model(model, cfg)

实现:src/peft/tuners/prefix_tuning/。注意 prefix_projection=True 是必要的——直接学 $P^K, P^V$ 不稳,要经过一个 MLP $\phi$ 再映射出来:

$$P^K_l, P^V_l = \phi_l(\text{Embed}(\text{ids}))$$

9.4P-Tuning v1 / v2

版本关键差异对应 PEFT
P-Tuning v1 Prompt Tuning + 中间用 LSTM/MLP 重参数化 PromptEncoderConfig
P-Tuning v2 Prefix Tuning + 去掉 MLP / 应用 BERT 类任务PrefixTuningConfig(prefix_projection=False)

P-Tuning v1 用法

from peft import PromptEncoderConfig, PromptEncoderReparameterizationType, get_peft_model

cfg = PromptEncoderConfig(
    task_type=TaskType.SEQ_CLS,
    num_virtual_tokens=20,
    encoder_reparameterization_type=PromptEncoderReparameterizationType.MLP,
    encoder_hidden_size=128,
    encoder_num_layers=2,
)

实现:src/peft/tuners/p_tuning/model.py

9.5训练时的特殊处理

这一系列方法在 forward 时需要"把 prefix 拼到 input",PEFT 通过 PeftModelForXXX 子类的 forward 实现。看 src/peft/peft_model.py::PeftModelForCausalLM.forward 的简化逻辑:

def forward(self, input_ids, attention_mask, ...):
    peft_type = self.peft_config[self.active_adapter].peft_type
    if peft_type == PeftType.PROMPT_TUNING:
        # 1) 生成 prompt embedding
        prompts = self.get_prompt(batch_size)
        # 2) 拼到 inputs_embeds 前
        inputs_embeds = self.word_embeddings(input_ids)
        inputs_embeds = torch.cat([prompts, inputs_embeds], dim=1)
        # 3) attention_mask 也要扩长
        attention_mask = torch.cat([torch.ones(b, n).to(...), attention_mask], dim=1)
        return self.base_model(inputs_embeds=inputs_embeds, attention_mask=attention_mask, ...)
    elif peft_type == PeftType.PREFIX_TUNING:
        # 1) 生成每层 past_key_values
        past_key_values = self.get_prompt(batch_size)
        return self.base_model(input_ids=input_ids, past_key_values=past_key_values, ...)

所以这些方法必须PeftModelForXXX.forward,不能像 LoRA 那样直接 model.base_model.forward

9.6三种方法的优劣对照

方法参数量(7B)效果训练稳定性推理开销
Prompt Tuning ~80K 较低(small)
Prefix Tuning ~5M 中(KV 变长)
P-Tuning v1 ~200K + MLP较 PT 好 较好
P-Tuning v2 ~5M 接近全参 FT 较好
LoRA r=8 ~3M 低(可 merge)

结论:新项目几乎都选 LoRA,因为它训练稳定、效果好、可 merge。Prompt 系适合:

9.7实战:用 Prompt Tuning 做"客户专属语气"

场景:base 模型回答正常,想给客户 A 专属语气(如总是"亲,您好~"开头)。

cfg = PromptTuningConfig(
    task_type=TaskType.CAUSAL_LM,
    num_virtual_tokens=10,
    prompt_tuning_init=PromptTuningInit.TEXT,
    prompt_tuning_init_text="Reply in friendly tone like a customer service rep:",
    tokenizer_name_or_path=MODEL,
)
model = get_peft_model(model, cfg)

# 每个客户的 prompt 训完只需 ~80 KB 存储
model.save_pretrained(f"./prompts/customer_a")

同一台 GPU 可以同时挂上千个客户的 prompt——这是 Prompt Tuning 真正发力的场景。

9.8常见踩坑

现象原因处理
Prompt Tuning loss 不降 用了 RANDOM 初始化 换 TEXT 初始化
Prefix Tuning 训中崩 没开 prefix_projection prefix_projection=True
推理时输出多了奇怪开头 generate 时 prompt 没被自动去除 PeftModel.generate 而非 base_model.generate
attention_mask 长度对不上 自己处理 mask 时忘了加 virtual tokens 的部分 model.prepare_inputs_for_generation 自动处理
num_virtual_tokens=100 反而变差 过多 virtual token 抢占了真实输入的注意力预算 典型 sweet spot 是 10-30

9.9四种 Prompt 系实现的源码锚

四个 prompt 系算法在 PEFT 里都是独立 tuner,差异主要在"虚拟 token 怎么变成 prompt embedding"这一步:

tuner位置核心类 / 字段
PromptTuning tuners/prompt_tuning/ PromptTuningConfig (config.py:30)PromptEmbedding(直接 nn.Embedding,无 encoder)
PrefixTuning tuners/prefix_tuning/ PrefixTuningConfig (config.py:23)PrefixEncoder(可选 MLP encoder)
PTuning tuners/p_tuning/ PromptEncoderConfig (config.py:29)PromptEncoder(MLP 或 LSTM)
MultitaskPromptTuning tuners/multitask_prompt_tuning/ 继承 PromptTuningConfig + 多任务 init 字段(见 ch08.10)

9.10三者的真实差异(按"动哪里")

课本上说三者都是"prompt-based",但在"prompt embedding 作用于模型哪个位置"这一点上很不同:

方法作用位置参数量encoder 重参数化
Prompt Tuning 仅在 input embedding 前面 prepend N 个虚拟 token N × hidden_size 无(直接 Embedding)
Prefix Tuning 每一层的 K/V 前面注入 N 个 prefix N × num_layers × 2 × hidden_size 可选 MLP(prefix_projection=True
P-Tuning (v1) 仅在 input embedding,但过一个 encoderN × hidden_size + encoder 参数 必须有(MLP / LSTM)
P-Tuning v2 ≈ Prefix Tuning(生产代码统一用 Prefix Tuning 表达) 同 Prefix Tuning

9.11PromptEncoderReparameterizationType

P-Tuning 的 encoder_reparameterization_type(config.py:41–56)有两种取值:

类型实现
"MLP" 2 层 MLP(默认 encoder_num_layers=2 主流;快
"LSTM" 双向 LSTM 原始论文用;现在很少用,慢且效果接近 MLP

PromptEncoder 的字段:encoder_hidden_sizeencoder_num_layersencoder_dropout

9.12这章你需要带走的