第一次把一段"只加了一个向量"的模型输出发给同伴看时,对方的判断是"你偷偷做了微调吧"。那次实验里,权重文件一个字节没动,训练脚本没跑一行,我只是在推理时往某一层的隐藏状态上叠了一个方向向量,模型的说话风格就从平铺直叙变成了带有明显个人色彩的叙述口吻。这就是Activation Steering,也常被叫作激活引导、激活工程或者表征工程——核心动作是:不改参数、不重训、不加长提示词,直接在模型前向过程的中间激活值上做加法或投影,从而改变模型的行为倾向。
这篇东西写给两类人:一类是想搞清楚"为什么高维空间里加个方向就能改行为"的原理派,另一类是已经准备好写 hook、只想知道层怎么选、系数怎么标、坑在哪的实操派。我会把线性表征的底层逻辑、对比样本的构造、hook 的最小实现、强度标定的量纲问题、以及我在层扫描和多向量冲突上踩过的坑全部摊开讲。需要提前说明的是,本篇聚焦风格控制、事实性校准与可解释性分析这类场景,不涉及、也不会演示任何削弱模型安全边界的用法。
1. 残差流凭什么是可以被"加一个向量"改写的通道
1.1 从注意力输出到残差流的线性叠加结构
要理解激活引导,得先看清楚 Transformer 每一层到底在做什么。一个解码层里,多头注意力和前馈网络各自算出一个增量,然后这个增量被加回主干的隐藏状态上,形成残差连接。也就是说,第 l 层的输出可以粗略写成 h_l = h_{l-1} + Attn(...) + FFN(...)。整条主干从 embedding 一路流到最后的输出投影,中间从来没有被强制归一化成某种"不可加的形态",除了 LayerNorm 会在每层入口做尺度调整之外,信息通道本身是一个持续累加的高维向量空间,业内习惯叫它残差流。
这个结构决定了两个关键事实。第一,残差流里的向量是可加的——如果你在某层往里塞一个方向,后续所有层都会把这个方向当作上下文的一部分继续处理,它会被后续的注意力读到、被 FFN 变换、最终影响 logits。第二,残差流里的向量是可分的——大量工作观察到,语义上互相独立的特征在高维空间里大致占据互相近似正交的子空间,所以塞一个方向进去,对其他能力的干扰比想象中小得多。这跟往一张已经画满的图上涂颜料不一样,更像是往一个有很多空频道的无线电里加一路信号。
顺带说一个常见的误解:有人以为激活引导是"改某个神经元的输出"。不是。它是往整条残差流里加一个 d_model 维的向量,这个向量通常是稠密的,几乎每个维度都有非零值。所谓"方向",指的是这个向量在高维空间里指向哪儿,而不是"第几个神经元"。
1.2 线性表征假说与方向向量为什么能分离出来
激活引导能成立,依赖一个经验性假设:模型内部对某个高层概念(比如"语气正式程度""是否在陈述事实""情绪极性")的编码,在一个相当大的范围内是线性可分的,也就是说存在一个方向 v,使得沿着 v 的正方向移动会让模型更偏向概念 A,反方向移动更偏向概念 B。
这个假设不是凭空来的。探针实验反复发现,用线性分类器就能从中间层激活里读出相当高准确率的语义属性;主成分分析也常常发现,某些语义差异恰好对应激活空间里的前几个主成分。更直白的证据是"向量算术"类现象——把"正向情感"的均值向量减掉"负向情感"的均值向量,得到的差值方向在不同提示词上都能稳定地推动输出往正向走。既然差异主要体现在一个方向上,那把这个方向单独拎出来、在推理时按需叠加,逻辑上就是通的。
不过得诚实一点:线性可分不等于"这个概念就存在这一个方向"。同一个概念在不同层、不同上下文里可能有多个子方向,也可能和别的概念共享方向。这正是后面层选择和多向量冲突问题的根源,也是为什么"抄一个开源向量直接用"经常效果一般——它是在别人的模型、别人的数据分布上算出来的。
1.3 和微调、提示词、LoRA 的差别到底在哪儿
把三种手段摆在一起对比最清楚。提示词是输入端干预,靠自然语言描述去激活模型已有的能力,问题是对细微的风格量、事实性偏移控制力很弱,而且长提示会吃掉上下文预算。微调和 LoRA 是参数端干预,改的是权重,效果好但成本高、周期长,而且容易在目标能力提升的同时带来灾难性遗忘。激活引导是中间态干预,只在推理时生效,改的是"这一次前向计算里的中间结果",权重保持不变。
这个定位带来几个很实际的好处:一是可逆且可调,同一个模型可以挂不同的向量服务不同场景,切换成本几乎为零;二是强度连续可调,系数从 0 到 2 之间连续变化,效果是渐变的,不像微调那样只能在几个检查点之间跳;三是便于做因果分析,因为你能精确控制"在某层加了多大的什么方向",这比观察相关性要有说服力得多。
代价也明显:它不增加模型的新知识,只能放大或抑制模型已有的倾向;它对层和系数敏感,需要针对具体模型做标定;它和推理框架的结合需要额外工程,尤其是带 KV cache 的批量生成场景,位置处理稍不注意就会出错。这些代价后面会逐条展开。
2. 从对比样本里把方向向量"算"出来
2.1 对比数据集的构造原则:单变量、配对、长度均衡
方向向量的质量九成取决于数据。最常用的做法是构造配对样本:同一个提示词,配上两种不同风格的回复,一正一负。比如同一段产品说明,一个版本客观克制,一个版本热情外放;或者同一道事实题,一个版本给出正确结论,一个版本给出编造结论。然后分别跑前向,取指定位置的激活,做差再平均。
这里有几个必须守住的纪律。单变量:一对样本之间只能有一个维度不同,如果一边既要它更热情又要它更简短,那算出来的方向就是"热情+简短"的混合体,注入后你会同时得到两个副作用。配对:正负样本最好共享前缀,让激活差异只来自目标属性,而不是来自话题、长度、句式。长度均衡:如果正样本平均比负样本长 30 个 token,均值差里就会混进"长度"这个方向的成分,注入后模型可能只是变啰嗦。实操里我会把样本按 token 数分桶,只在同桶内配对,或者干脆在算差值前对激活做长度归一化。
样本量上,几十对只能算跑通,想要稳定我一般准备 200 到 500 对,并且留出 20% 做验证——验证集的作用不是测准确率,而是测"用 A 组算出的向量,在 B 组上是否还有效",能有效过滤掉过拟合到特定句式的伪方向。
2.2 均值差、主成分、逻辑回归法向量:三种取法的取舍
拿到正负激活集合之后,至少有三种方式把它们变成一个方向。
| 方法 | 计算方式 | 优点 | 适用与风险 |
|---|---|---|---|
| 均值差 | v = mean(h_pos) - mean(h_neg) | 实现最简单,方向直观,样本少也能跑 | 容易被样本整体的均值偏移污染,要先做中心化 |
| 主成分法 | 对 (h_pos - h_neg) 集合做 PCA,取第一主成分 | 抓住差异中方差最大的那部分,抗噪更好 | 第一主成分未必是语义方向,可能混入格式差异 |
| 判别式法 | 训练线性分类器,取权重向量作为 v | 直接优化"能不能分开",判别性强 | 小样本容易过拟合;权重尺度需要重新标定 |
我自己的默认选择是中心化之后的均值差,理由是它可解释、可复现、调试时能对得上号。具体做法是先把所有正负激活合在一起算一个全局均值,正负集合各自减掉它,再做差。这一步能显著削弱"所有样本共有的那部分偏移"(比如某些 token 频率带来的固定偏置)对方向的影响。如果发现注入后方差很大、不稳定,再退回去用 PCA 第一主成分,并检查它和均值差的余弦相似度——如果两者相似度低于 0.5,说明差异集合内部本身就不一致,该回头检查数据了。
判别式法的价值在"稀疏化"。有些工作会在这个权重向量上取 top-k 个维度保留、其余置零,得到稀疏方向,好处是副作用更局部、更可控,代价是需要更仔细地调 k。
2.3 层选择和前向抓取的具体写法
层选错了,后面所有努力都白费。经验规律是:太浅的层方向还没成形,注入后基本被后续计算洗掉,表现为"系数调到 3 也没反应";太深的层已经接近输出投影,注入后容易直接影响词表分布,表现为"效果是有了,但语言流畅度掉得厉害"。真正好用的区间通常在模型深度的 40% 到 70% 之间,具体位置必须靠层扫描确定,不能拍脑袋。
抓取激活的最小实现大概是这样。注意不同版本的模型实现里,解码层返回的可能是张量也可能是元组,得兼容一下。
import torch def collect_last_token_hidden(model, tokenizer, prompts, layer_idx, batch_size=4): """收集指定层、最后一个 token 位置的隐藏状态""" store = [] def hook(module, args, output): hs = output[0] if isinstance(output, tuple) else output # hs: [B, T, D],取每条序列最后一个有效 token store.append(hs[:, -1, :].detach().to(torch.float32).cpu()) handle = model.model.layers[layer_idx].register_forward_hook(hook) try: with torch.no_grad(): for i in range(0, len(prompts), batch_size): batch = prompts[i:i + batch_size] enc = tokenizer(batch, return_tensors="pt", padding=True, padding_side="left", truncation=True, max_length=2048) enc = {k: v.to(model.device) for k, v in enc.items()} model(**enc) finally: handle.remove() return torch.cat(store, dim=0)这段代码有两个细节值得单独提。padding_side 设成 left,因为 decoder-only 模型在左侧补齐时,最右侧位置才是真实内容的末尾;如果默认右侧补齐,hs[:, -1, :]取到的就是 padding 位置,算出来的向量全是噪声。强制转 float32 再存,bfloat16 做几百个向量的均值差,精度损失会实打实体现在方向漂移上,我吃过这个亏——用 bf16 累加,得到的向量注入后效果比预期弱将近一半。
还有一点是取哪个位置。对指令微调模型,我通常取"回复的第一个 token 之前那个位置",也就是把 prompt 和回复拼接后,取回复起始前一位的激活。取整个序列最后一个 token 也行,但如果每条样本的回复长度差异大,这个位置的信息成分会很杂。
3. 注入环节:hook 怎么写、加在哪些 token、系数怎么标
3.1 前向 hook 改写隐藏状态的最小实现
注入本质上就是把抓取时的 hook 换个写法:在同一个位置,把输出改成"原输出 + alpha * v"。这里最关键的是返回值的形态必须和原函数一致,元组要按元组返回,张量按张量返回,否则模型后续的计算图会直接报错。
def make_steering_hook(vec, alpha, dtype=torch.bfloat16): v = vec.to(dtype).view(1, 1, -1) # [1, 1, D] def hook(module, args, output): hs = output[0] if isinstance(output, tuple) else output hs = hs + alpha * v if isinstance(output, tuple): return (hs,) + output[1:] return hs return hook挂载和卸载要成对出现,用 try/finally 包住,否则一次异常就会让 hook 残留,后续所有推理都被污染,而且这种污染极难排查——现象是"换个脚本效果就没了"。
handle = model.model.layers[layer_idx].register_forward_hook( make_steering_hook(vec, alpha)) try: out = model.generate(**inputs, max_new_tokens=256) finally: handle.remove()如果要做更精细的控制,比如"只在生成阶段注入、不干预 prompt 的理解",可以在 hook 里加一个开关标志,先跑一遍 prompt 前向把 KV cache 建好,再打开标志进入生成循环。有些实现会利用model.generate的回调接口在 decode 阶段切换,也有直接手写贪心解码循环的,看团队工程习惯。
3.2 全序列注入还是只打末位:位置选择的实际影响
这个选择影响很大,而且经常被忽略。全序列注入是指对[B, T, D]张量的每个位置都加同一个 v。它的优点是简单、对 KV cache 友好(因为 prefill 阶段一次性把带方向的 KV 全部算好),缺点是会干扰模型对 prompt 本身的理解——如果 prompt 里有需要精确解析的指令,全序列注入可能把指令也"染色"了。
末位注入只加在最后一个 token 上,干扰最小,但要注意:在自回归生成过程中,后续每个新 token 的前向都会重新经过这一层,如果 hook 一直挂着,实际上每个新生成的 token 位置也都会被加一次。这其实正是我们想要的——生成阶段的持续引导。所以常见的组合是"prefill 阶段全序列或末位、decode 阶段每位都加",实现上就是一个开关。
还有一个折中方案是只在回复区注入。如果你的场景是"用户提问要原样理解,但回答要换风格",就把 prompt 部分的位置 mask 掉,只对回复 token 位置加 v。这个在批量训练式构造数据时很有用。
3.3 alpha 的量纲问题与归一化策略
这是最容易翻车的地方。如果你直接拿原始均值差当 v,它的范数取决于激活本身的尺度,可能是 5,也可能是 80,那 alpha 就没有通用意义了——换个模型、换个层,同样 alpha=1 的效果能差十倍。
标准做法是先把 v 归一化,再乘回一个与激活同量级的目标范数:
def normalize_vec(vec, target_norm): return vec / vec.norm() * target_norm # 先统计该层激活的典型范数(取中位数比均值稳) h_norm = hidden_stats.median() vec_unit = normalize_vec(vec, h_norm)这样 alpha 的含义就变成了"注入一个与原始激活同量级的方向的多少比例",alpha=1 意味着注入的扰动幅度约等于该层激活本身的典型幅度。有了这个基准,不同模型、不同层之间的 alpha 才有可比性,扫描时也有明确的搜索范围:一般从 0.1 扫到 3,绝大多数有效点落在 0.3 到 1.5 之间。
一定要避开的一个坑是:不要用均值做范数基准。激活范数在样本间是长尾分布,少数离群样本会把均值拉得很高,导致归一化后的 alpha 整体偏小,表现出来就是"怎么调都没效果",白白浪费两轮实验。
4. 调参路上的三类硬坑:强度、层位、多向量打架
4.1 alpha 过大时的崩坏模式与识别方法
系数调大不是"效果更强",而是越过某个阈值后行为质变。我总结了几种典型崩坏,都有明显的可识别特征。
第一种是复读与循环。输出开始重复同一句话,或者反复输出同一段结构。原因是过强的方向叠加把残差流推出了模型训练时见过的分布,注意力机制在异常激活上退化成固定模式。第二种是语言混杂与语法崩解,输出里出现不成词的片段、标点错乱、中英混杂无规律。第三种最隐蔽,叫**"表演式满足"**:模型看起来在朝目标方向走,但内容空洞,比如要求"更严谨",结果它开始大量堆砌"需要指出的是""值得注意的是"这类连接词,实质信息量反而下降。
识别方法不能只看几条样例。我一般会同时记录三个指标:目标行为打分、困惑度、以及输出长度的方差。当 alpha 增大时,如果目标打分先升后平而困惑度开始明显上扬(比如相对基线涨了 30% 以上),那个拐点就是上限;如果输出长度方差突然扩大,说明模型在部分样本上已经失控。把这三个量画在同一张图上,最佳 alpha 的位置通常一目了然。
4.2 层扫描矩阵:把"选哪层"变成一张可看的表
层选择不该靠猜。最有效的做法是做一张二维扫描表:横轴是层号,纵轴是 alpha,格子里填目标行为得分,同时用另一张表记录困惑度变化。模型 32 层的话,扫 8 个候选层乘 5 个 alpha,一共 40 次小规模评测,用几百条样本就能跑完,比反复瞎试快得多。
我在一次针对"输出风格正式化"的实验里得到过一张很典型的表:浅层(前 25%)无论 alpha 多大得分都不动,说明方向在那里还没成形;中层偏后(45% 到 60%)有清晰的单峰,alpha 在 0.8 附近达到最优,困惑度只涨了不到 5%;再往深走(75% 以后),得分也能上去,但困惑度同步飙升,生成开始出现明显的模板化句式。最终的落点选中层,不是因为它得分最高,而是因为它在同样的得分下副作用最小。
这个"看副作用而非只看效果"的原则很重要。很多人扫描完只看效果列,挑了个分数最高的深层配置,上线后发现输出全是一个味儿,回头再查成本就高了。
4.3 多个方向同时注入时的正交化处理
真实场景很少只控一个属性。你可能既要语气正式、又要避免过度承诺、还要保持信息密度。这时候如果直接把三个向量加起来,很可能互相抵消或者产生意外的合成方向。
第一步永远是先量相似度。算两两余弦相似度,绝对值超过 0.3 就要警惕。如果两个方向高度相似(比如"正式"和"简洁"在你的数据里恰好总是同时出现),那说明它们在你构造的数据里没有被解耦,得回去改数据,而不是靠数学手段硬拆。
第二步做正交化。用 Gram-Schmidt 把后加入的向量对已有向量做投影剔除:
def orthogonalize(v, basis, eps=1e-6): for b in basis: v = v - (v @ b) / (b @ b + eps) * b return v正交化之后,每个向量的范数会变小,需要重新归一化到目标范数。这里有个副作用要留意:正交化会削掉一部分原始方向,如果被削掉的比例太大(比如超过 40%),即使数学上正交了,实际效果也会明显打折,这时候应该考虑换层——不同属性在不同层的可分离程度不一样,换个层往往比硬正交更有效。
第三步是联合扫描。多向量场景下各系数不是独立的,我会固定其中一个在扫描出的最优值,再扫另外两个,做两轮坐标下降。样本量不大时这个方法足够用,比网格搜索省一大半算力。
5. 怎么确认它真的生效了,而不是我在自我安慰
5.1 行为指标和困惑度必须成对出现
只测目标行为是最常见的自欺。你调了一个"增强事实性"的向量,测试集上准确率涨了 8 个点,看起来很棒——但如果同时困惑度涨了 40%,那你可能只是把模型推到了一个它不太会说话的状态,准确率的提升来自"回答变短、回避了难题",而不是真的更准。
所以评测至少要两条腿走路:一条是目标行为指标,用规则匹配、小分类器或者人工抽检打分都行;另一条是通用能力保全指标,困惑度是最便宜的代理,条件允许的话再加一组通用任务的小样本评测(比如常识问答、指令跟随的简单用例)。两条曲线的关系比单条曲线的绝对值更有信息量。
我自己的判定标准是:目标指标提升明显,通用指标下降不超过 3% 到 5% 就算可用;如果通用指标掉超过 10%,那不管目标指标多好看都得回头重调。
5.2 三组对照实验,缺一不可
激活引导的假阳性特别多,因为只要往激活里加点噪声,输出分布就会变,而人会不自觉地把"变化"解读成"朝目标方向变化"。要排除这种确认偏误,至少做三组对照。
第一组:随机方向对照。采样一个随机高斯向量,归一化到同样的范数,用同样的 alpha 注入。如果随机向量也让目标指标涨了一半,那说明你的评测指标本身对扰动敏感,结论不可信。我遇到过最尴尬的一次就是随机向量让"正式度"打分涨了 6 个点,最后发现是打分器把"句子变长"当成了正式。
第二组:标签打乱对照。把正负样本的标签随机打乱后重算向量,正常情况这个向量应该基本无效。如果它还有明显效果,说明数据里存在与标签无关的系统性差异(比如正样本全部来自同一个模板),你算出来的根本不是语义方向。
第三组:错层注入对照。把在 L 层算出的向量注入到明显不同的另一层。如果效果差不多,说明这个方向是"通用扰动"而非层特异的语义编码,那你对"机制"的解释就站不住脚。
这三组做完,如果真正的向量仍然显著优于全部对照,结论才勉强算立得住。过程枯燥,但省下的返工时间远超投入。
6. 上线前值得多花半小时的几个工程细节
6.1 批量推理时每条样本的系数可能需要不同
单条测试调好的 alpha,搬到批量服务里经常表现不一致。原因在于总量控制:当一批里混着不同长度的输入时,长序列的每个位置都加了一次扰动,累积效应更强。如果业务里长短差异大,我会按输入长度分桶,不同桶用不同的 alpha(短桶可以稍大,长桶稍小),或者干脆把注入改成对序列长度做归一化的形式——注入量按 1/sqrt(T) 衰减。这个改动很小,但对稳定性的提升很明显。
6.2 和 KV cache 的配合要专门验证
带缓存生成时,prefill 阶段算出的 K/V 已经包含了注入方向的影响,decode 阶段如果继续注入,实际上是"叠加了两次"。很多实现问题就出在这里:单轮对话看着正常,多轮对话越来越怪,因为历史缓存的 K/V 是带着旧强度的方向算出来的。
比较稳妥的做法是把注入和缓存生命周期对齐:要么只在 prefill 注入一次,decode 阶段完全不动;要么在 decode 阶段注入但保证强度与 prefill 一致,且多轮之间重置状态。这两种策略我都用过,前者更稳、后者更灵活,选哪个取决于场景对"持续引导"的需求强度。上线前一定要跑一轮多轮对话的压力测试,重点看第三轮以后的表现有没有漂移。
6.3 版本变更后必须重算向量
激活向量和模型权重是绑定的。哪怕只是换了一个微调检查点、做了一次量化,激活空间的尺度都会变,原来的向量很可能失效甚至反向。我现在的习惯是把向量的计算脚本和模型版本号绑在一起放进配置管理,模型一变就触发重算,并在评测流水线里加一条"向量有效性回归",用固定的几十条样本快速验证方向和强度还在不在预期区间。
量化尤其要注意。int8 或 4bit 量化之后,激活的数值分布会被压缩和截断,范数基准变了,原来 alpha=0.8 对应的实际扰动幅度可能变成 1.5 倍,直接导致输出崩坏。稳妥的做法是量化后重新统计激活范数中位数,重新标定 alpha。
7. 这套方法的能力边界和使用红线
7.1 它放大的是已有倾向,不是无中生有的能力
必须说清楚一点:激活引导改变的是倾向性分布,不是知识本身。如果模型压根不知道某个事实,注入"事实性"向量不会让它凭空知道,只会让它在不确定时更倾向于说"我不确定"而不是编一个。这个区别决定了它的适用场景:适合做表达风格、语气、谨慎程度、格式偏好这类"模型本来就会、只是不常这样表现"的调控;不适合做领域知识注入,后者还是得靠检索或微调。
同理,它对强对抗场景也不够可靠。面对刻意引导的输入,单一方向的线性干预很容易被绕过,因为对抗样本可以把你注入的方向在语义上"抵消"掉。把它当成一个温和的、概率意义上的偏置调节器比较符合实际。
7.2 明确不做什么
我在项目里给自己划的一条线是:不把激活干预用于削弱模型的安全行为。这类干预一旦被用于压制拒绝倾向,就变成了绕过防护的手段,风险远大于收益。所以我的向量库里有"更简洁""更谨慎""更少主观断言""更贴合格式要求"这些方向,但没有、也不打算有削弱安全边界的方向。
另一条线是可解释性结论的谨慎表述。层扫描能告诉你"在这一层、这个方向上有效",这属于因果证据的一部分,但不足以支撑"模型内部就是用它表示这个概念的"这种强断言。写文档时我会明确区分"观测到的干预效果"和"推测的机制解释",避免后续被人当成定论引用。
在同一个模型上,我通常只启用一到两个方向,系数控制在扫描最优值附近,同时保留一个关闭开关和一份基线输出样本,方便随时对比回退。这套组合用下来,在实际业务里的收益是"细粒度、低成本、可随时撤回的风格调控",而不是"换一个模型"。认清这一点,用起来就不会失望。