最近在折腾模型可解释性,想把大模型“撬开”看看内部到底怎么表征语义的。试了挺多路子之后,我彻底迷上了 steering vectors(转向向量)这种干预手段——它不是去微调参数,而是在模型推理时往内部激活上加一个方向向量,就能改变输出风格、事实倾向、甚至角色人格。网上讲转向向量的文章不少,但大部分都集中在 residual stream、attention head 空间这些“大众热点”上。我们实验时偶然发现,在某层经过低秩分解得到的子空间上,属性特征特别集中,信噪比远高于传统取法。这个子空间我们内部叫它 J space。这篇文章就把“如何从 J space 提取转向向量”的完整过程写清楚,包括数学直觉、激活采集、PCA 降维、干预验证,以及我踩过的一堆坑。适合已经了解 Transformer 基础、想动手做模型控制的人,也适合刚接触可解释性但对“提取一个能控制生成方向的向量”这件事好奇的新手。
1. 为什么是 J space:转向向量真正的“落点”在哪
先别急着上代码,我们得先搞清楚几个基本概念:什么是 steering vector,J space 又是从哪冒出来的,为什么非要在 J space 里找向量。
1.1 转向向量的常规提法
所谓 steering vector,本质上是一个和模型内部激活同维度(有时是子维度)的方向向量。推理时,如果你在前向传播的某个位置拿到激活向量 h,把它加上一个系数 α 乘以 steering vector v,即 h' = h + α·v,模型的后续输出就会往 v 所编码的那一侧偏移。
举个例子,假设你想让模型生成更“幽默”的文案。你先准备一组“正经文案”和一组“幽默文案”的输入,让模型跑前向,记下两组输入在某一层的激活;然后计算两组激活的均值差,或者做 PCA 取第一主成分,得到一个方向。这个方向就近似编码了“正经 ↔ 幽默”这个语义轴。推理时往激活里加上这个方向,模型就会更倾向产出幽默的句子。这就是 steering vector 的最小闭环。
这类方法在 2022 年之后火起来,代表作包括 Anthropic 的“特征方向干预”、OpenAI 那套 activation addition 的实践,还有后来的 CAA(Causal Analysis by Activation Addition)。原理不复杂,但要把它做得稳定、通用,细节非常多。
1.2 J space 的来历与定义
那 J space 是什么?你可以把 Transformer 每一层残差流里的隐藏状态看作一个高维向量,但高维并不是“均匀”的——很多方向是噪声,真正有语义结构的特征集中在少数低维子空间里。我们实验中反复对比后发现,对某一层的残差流激活做奇异值分解(SVD),取最重要的前几百个奇异向量张成的低维子空间,往往能让“语义方向”显现得更干净。
我们内部把第 16 层(我们用的模型是 7B 规模,层数共 32 层)残差流经过 SVD 压缩后的这个子空间叫 J space。因为当时跑实验时这个子空间在第 j 个奇异值截断点附近表现最好,随口就叫成了 J space,后来沿用了下来。你可以把它理解成一个“被清理过的语义空间”——去除了大量噪声维度,保留了语言模型推理时的主要变化方向。
J space 的维度通常只有 128 或 256 维,而原始隐状态维度动不动就是 4096 或 5120。在这几百维空间里做向量提取和干预,无论是计算效率还是稳定性都明显更好。
1.3 为什么 J space 比普通 residual stream 更适合提取
你可能要问:直接在原始隐状态上算均值差不就行了?为什么非要先做低秩投影到 J space?
我一开始也这么想的,但实验结果打脸了。原始隐状态维度高、噪声大,计算均值差的时候会被大量任务无关信息干扰,提取出来的方向不够“纯”。举个例子,你本来想提取“积极”和“消极”的方向,但两组语料的句式长度、用词频率都有差异,这些差异在 4096 维空间里会被扩散到许多维度上,导致最终的方向向量里混入了“语气强弱”“主语类型”等无关特征。
而 J space 因为丢掉了那些变化很小、解释力很弱的维度,相当于做了第一次噪声过滤。在这个子空间里,语义属性对激活的影响会被“放大”,均值差向量通常更干净,干预效果更可预测。我们做过定量对比:在同一个任务上,用原始残差流提取的向量干预,风格转换成功率大约 64%;而用 J space 提取后,成功率能到 81%,输出也更稳定。
所以,J space 不是玄学,它本质上是一个降维后的信号集中区。理解了这个,后面提取向量的每一步你就知道是在干什么了。
2. 提取转向向量的前两步:激活采集与配对样本设计
核心思路很简单:找两组只在目标属性上有差异的句子,让模型分别跑前向,抠出 J space 上的激活,然后相减、降维,得到一个方向。但实际动手时,样本设计比代码更花心思。
2.1 设计对比语料:控制变量是第一原则
这一步没有任何代码,但直接决定向量质量。所有实验里最常见的错误就是语料混杂了多个属性。比如你想提取“正式 ↔ 随意”方向,结果正式语料全是商务邮件体,随意语料全是网络聊天体,那两组之间不仅风格不同,还混入了“话题领域”差异。最终提取的向量既管风格,又管话题,干预时会出现莫名其妙的结果。
我的做法是设计“最小对”(minimal pair)语料。每对样本只在目标属性上对立,其余保持尽量一致。例如:
| 属性 | 正向样本 | 反向样本 |
|---|---|---|
| 正式 ↔ 随意 | “您的请求已收到,我们将在三个工作日内处理。” | “收到啦,我们三天内搞定。” |
| 积极 ↔ 消极 | “我对这次合作充满信心。” | “我担心这次合作问题很多。” |
| 直言 ↔ 委婉 | “这个方案成本太高。” | “这个方案成本还有优化的空间。” |
每对样本的长度、主体、结构尽量接近。如果只有 50 对,也比混入 500 对噪声强。我们通常准备 200 到 500 对样本,效果会比较好。
另外,有条件的可以加负采样:把“中性样本”也加进来,计算差值时做一个三元组的对比,比如(正向 - 中性)与(中性 - 反向)的均值,这样能进一步抵消那些与属性无关的偏差。
2.2 前向传播并缓存 J space 激活
设计好语料后,写一个 hook 函数把模型指定层的激活抠出来。如果你用 HuggingFace Transformers,可以注册一个 forward hook 到第 16 层。这里有一个细节:我们要先做一次模型在若干输入上的前向,存下那些层输出,做一次小规模的 SVD,从而确定 J space 的投影矩阵 U。之后再把激活投影到 U 所张成的子空间。
下面的代码展示了如何通过 hook 拿到第 16 层的残差流输出,并缓存下来:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "your/7b-model" model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16, device_map="cuda") tokenizer = AutoTokenizer.from_pretrained(model_name) layer_index = 16 activation_cache = {} def hook_fn(module, input, output): # 残差流输出通常是 (batch, seq_len, hidden_dim) activation_cache["layer_16"] = output[0].detach().float() hook_handle = model.model.layers[layer_index].register_forward_hook(hook_fn) def get_activations(texts): encodings = tokenizer(texts, return_tensors="pt", padding=True, truncation=True, max_length=128).to("cuda") activation_cache.clear() with torch.no_grad(): model(**encodings) return activation_cache["layer_16"] # [batch, seq, dim]这里我取的是output[0],因为 HF 里层输出的第一个元素通常是隐藏状态,也就是残差流后的值。如果你用的是别的模型结构,需要确认一下这一层输出到底是不是你想要的“残差流激活”,有些实现把 attention 输出和 MLP 输出分开,别搞混了。
拿到激活后,还需要把它压缩到 J space。这一步是先在所有语料上跑一遍,收集激活矩阵,然后做 SVD。我把具体流程放在 3.2 节,这里先假设你已经有了投影矩阵 U,维度是[hidden_dim, j_dim],例如 4096x256。那么投影就是:
def project_to_j_space(hidden_states, U): # hidden_states: [batch, seq, hidden_dim] # U: [hidden_dim, j_dim] return torch.matmul(hidden_states, U) # [batch, seq, j_dim]2.3 激活差与中心化:方向从哪来
有了 J space 中的激活,接下来就要合成方向。先对每个样本做序列维度的池化。序列长度不一样,直接取 all token 的平均或取最后 token 的激活都行。实验下来,取倒数第二或最后一层 token 的激活通常比平均效果好,因为生成任务里最后 token 集成了全句语义。如果做的是分类风格提取,平均池化也很稳定。
假设正向样本组的 J space 激活集合是 P,反向样本组是 N。先各自求均值中心p_mean和n_mean,再相减得到原始方向:
v_raw = p_mean - n_mean这一步可以简单理解成:把正反样本在子空间里的“质心”连成一条线,这条线指向的方向,就是最能区分两组样本的方向。如果语料设计得好,这个方向就近似目标属性轴。
不过注意,直接相减得到的向量还带着一些其他方向的信息,例如两组样本在长度、话题上的残余偏差。所以还需要下一步的降维和去噪。
3. 从原始差值到干净向量:PCA、白化与归一化的取舍
这一章节是整个流程里最“技术核心”的部分。很多人提取完差值就直接拿去干预了,结果时好时坏。问题通常出在噪声没除干净、方向被少数极端样本带偏,或者尺度不对。
3.1 为什么要降维:原始差值里都是什么
我们从 J space 提取的v_raw其实已经是降过一维的了(比如 256 维),但 256 维仍然不算“干净”。你想,两组语料即使控制得很好,也难免有几十个维度是由句子中的残差信息、语境差异主导的。直接拿去干预,等于往激活里加了一大堆和“目标属性”无关的噪声,轻则效果打折,重则模型胡言乱语。
所以,更稳妥的做法是:先不直接算均值差,而是把两组样本的 J space 激活堆叠起来,做成一个矩阵,在这个矩阵上做 PCA,然后取第一主成分作为转向向量。也可以对均值差做进一步分解,提取主要方向。
实际上,在 J space 里做 PCA 和在原始空间做 PCA 有本质区别。J space 本身已经去掉了大量完全无关的维度,PCA 再做一次“属性分离”,往往第一主成分就对应你要控制的属性。
3.2 PCA 主方向提取的实操
我们做法是这样的。假设你有 M 对正反样本,每对样本得到一个差值向量d_i(正样本激活向量减去负样本激活向量,维度 j_dim),这样得到一个[M, j_dim]的差值矩阵 D。然后在这个矩阵上做 PCA。
import numpy as np from sklearn.decomposition import PCA # diffs: numpy array of shape [num_pairs, j_dim] pca = PCA(n_components=1) pca.fit(diffs) steering_vector = pca.components_[0] # [j_dim] explained_var = pca.explained_variance_ratio_[0] print(f"第一主成分解释方差比例: {explained_var:.4f}")这里第一个主成分对应的方向,就是所有差值向量中变化最剧烈的方向。如果我们的样本设计有效,这个方向通常就是目标属性轴。解释方差比例可以当做一个质量指标:如果这个值非常高(比如 >0.7),说明差值向量高度一致,方向明确;如果很低(<0.3),说明正反样本之间并没有统一的差异方向,可能是语料太杂或者是模型在这个层根本没有明显区分这个属性,需要回头调整样本或换层。
有朋友一开始直接对两组样本做线性判别分析(LDA),结果也行,但 LDA 对类别协方差矩阵求逆,在维度高、样本少的时候特别容易过拟合。PCA 更稳,而且不需要标签就能跑,所以我们日常还是以 PCA 为主。
3.3 归一化与尺度问题
提取出来的steering_vector是一个单位向量(PCA 的components_已经是归一化的)。使用的时候,我们需要一个系数 α 来控制干预强度。α 的正常范围因模型和空间而异,不要觉得“值越大效果越好”,过大的 α 会导致输出退化为重复或乱码。
我们的经验是:先在 1 到 10 之间线性扫描,找一个“有效区间”。例如对 7B 模型,在 J space 中干预时,α 常用范围在 3 到 8 之间;在原始残差流空间干预时,α 可能需要 0.1 到 0.5 这种小数。这是因为 J space 的激活范数和原始激活范数不一样,压缩后所有值都会偏小,但方向极其灵敏。
另一个容易忽略的点是:如果你把 steering vector 加在 J space 里,但后续要用它去干预原始残差流,需要先通过投影矩阵的伪逆把它映射回原始空间,否则维度对不上。我们的流程里,因为干预也发生在 J space 投影后,所以不需要这一步。但如果你是在模型内部实时做投影和加向量,务必保持投影矩阵的一致性。
3.4 白化处理的时机
有些论文里会提到对提取的向量做“白化”(whitening)处理,本质上是把协方差矩阵归一化,把不同维度的方差拉平。白化后,所有维度对方向的贡献是“公平”的,不会被个别方差极大的维度主导。
我在实践中发现,白化在以下场景很有用:
- 正反样本在某个维度上存在系统性偏差(比如正向样本比反向样本普遍多几个 token)。
- 你希望转向向量对多个独立特征都能同时响应,而不是单个特征主导。
但白化也有副作用:它会放大噪声维度,尤其当样本量少、协方差矩阵估计不准确时,白化容易把某些估计误差放大。所以,我的建议是:如果样本对很干净、PCA 第一主成分解释方差已经很高,就不要白化;如果第一主成分解释方差低于 0.4,建议尝试白化后的结果,对比一下哪个让下游干预更稳定。
4. 验证提取结果:干预实验设计案例
向量提取出来后,最关键的是验证它“真的有用”。很多人省了这一步,直接把向量加到模型里,发现效果不好就怀疑整个方法不行。其实问题往往出在验证方式太粗糙。
4.1 加法干预的两种方式
拿到 steering vectorv后,有两种干预方式。
第一种是直接在生成循环里往每一层(或某层)的激活上加 α·v。这种方式需要侵入式修改模型,但更精确。代码看起来像是这样:
def steering_hook_fn(module, input, output): h = output[0] # 把 v 加到最后一层 token 位置 h[:, -1, :] += alpha * steering_vector_orig return (h,) + output[1:]注意,这里的steering_vector_orig需要是原始 hidden 空间的向量,如果你是在 J space 算的,得先U @ steering_vector映射回原空间。我们在 J space 里做的是:先对 hidden 投影到 J space,加完向量,再投影回原始空间。两种方式等价,但注意别搞混维度。
第二种是非侵入式的:把提取的向量存下来,在推理时在 J space 投影后加了上去,然后继续正向传播。这通常需要你手工改写模型 forward 逻辑,或者用支持自定义 hook 的库。
4.2 一个具体实验:用 steering vector 控制积极/消极倾向
我们拿“积极 ↔ 消极”这个属性举个例子。先准备 200 对样本,按第 2 节的方法提取出一个 J space steering vector。然后准备一批中性测试句子,比如“公司发布了季度财报”、“外面在下雨”。
不加干预时,模型输出可能语气平平,带一点点客观倾向:
“公司发布了季度财报,营收有所增长,但利润率下降了。”
加上积极的 steering vector(α = 5.0)之后,相同输入生成的输出变成了:
“公司发布了季度财报,营收实现了显著增长,前景看好!”
加上消极的向量(α = -5.0)后:
“公司发布了季度财报,但利润率令人失望,未来可能面临更多挑战。”
这种对比足够直观。但光看两个例子不够,我们通常做批量评估:准备 100 条中性 prompt,分别用正向/负向/无干预生成,然后让另一个分类器或人工对输出情感打正负分。我们实验结果是:无干预时正负比例约为 52:48,正向干预后变为 88:12,负向干预后变为 13:87。这组数字说明向量方向确实是准确的。
4.3 如何判定向量真的管用:边际效应与强度曲线
一个简单的检验法则是“强度曲线”:不断改变 α,从 0 慢慢增加到 10,观察输出的属性评分变化。如果评分呈单调递增(或递减),说明向量方向正确;如果评分在 α 较大时突然反向或塌缩,说明向量里可能有其他属性的干扰。
我们还常用另一个指标——干预向量与模型原有权重的余弦相似度。如果提取出的 steering vector 和模型某层权重矩阵里的主成分方向重叠太多,说明它更多是在改变模型的通用“说话方式”而不是目标属性,这时候就要警惕了。理想的 steering vector 应该与大多数无关方向正交,只在目标属性轴上有一个清晰的分量。
最后,可以做一个“负对照”:随机采一个同等维度的向量,或者换一组无关属性(例如“名词 ↔ 动词”)来提取的向量,再用同样方法干预,看是否有效果。如果负对照也有类似改变,说明你的干预方式本身就会干扰生成,和目标属性无关。这一步能筛掉很多假阳性。
5. 我在实际操作中踩过的坑
这部分是花了不少冤枉钱才换来的,值得多看两遍。
5.1 J space 的“位置错觉”:不同层、不同任务差异大
一开始我们以为只要找到了 J space,就能一劳永逸地在所有任务上用。后来发现,J space 不是固定的。它依赖于你取哪一层的输出、用哪个模型、甚至哪类语料。我们在第 16 层提取的“正式↔随意”向量效果很好,但同一个位置提取“事实↔虚构”向量就非常差,第一主成分解释方差只有 0.15。
所以,不要指望“全局最优 J space”。更务实的做法是:每换一个目标属性,就在 8、12、16、20 层各试一次,对比第一主成分解释方差,挑最大的那一层作为该属性的 J space。这个过程很快,4 层全部跑一遍加上 PCA,十分钟就搞定。
另外,模型规模不同,J space 的位置也会漂移。小模型靠近后层,大模型反而靠近中层。我们试过 1.5B、7B、13B 三个规模,共同规律是中间层(总层数的一半左右)往往表现最稳。
5.2 样本对数量不足导致方向偏斜
另一个常见坑:样本对太少,提取的向量会被几对异常样本带偏。比如 20 对样本里,有一对正样本特别长,可能就拉偏了方向。我的建议是最少 60 对,最好 200 对。如果实在没有足够人工数据,可以对语料做同义改写、倒装句、主语替换等增强手段,但不能改变目标属性。
还有一个技巧:做 PCA 之前,先把差值向量做一次异常值剔除。我们用简单的“马氏距离”筛查,把偏离整体方向太远的样本去掉,再重新做 PCA。这一步很管用。
5.3 归一层与 scale 的坑
有一次,我们提取了一个“幽默”方向的向量,α 调到 6 时效果很好,但 α 超过 10 后模型开始疯狂输出 emoji 和语气词,已经不像正常文本了。后来分析发现,alpha 过大把激活推到了模型训练时没见过的高范数区域,模型只能靠输出一些高频 token 来“稳定”自己。
解决办法很粗暴也有效:限定 α 最大值,并且把生成的输出困惑度作为监控指标。困惑度突然升高时,说明干预强度过头了。另一方面,如果你在 J space 里干预完马上投影回原始空间,要保证投影矩阵 U 是规范正交基,否则投影后会引入额外的范数变化,把整个激活分布带偏。
5.4 与 LoRA 等其他干预叠加时的顺序效应
我们团队还在模型上挂了 LoRA 做风格微调,后来跑 steering vector 干预时发现效果互相打架。LoRA 的权重更新是在每个线性层内部做低秩变换,而 steering vector 是在残差流上做加法。两者叠加时,如果顺序不对,LoRA 的适配方向可能把 steering vector 的修正“吃掉”。
解决办法是:先固定 LoRA 权重,然后重新提取一轮转向向量,让向量在“已经有 LoRA”的环境下自适应。也就是说,提取向量时所处环境必须与最终部署环境一致。如果想同时用 LoRA 和 steering vector,优先保证 LoRA 不改变你提取目标属性的那一层,或者直接把 LoRA 冻结后再做提取。
6. 后续还能怎么扩展
这次从 J space 提取 steering vectors 的经验,可以直接扩展成更系统的工具。比如把不同属性的转向向量收集到一个“方向库”里,用属性名索引,推理时按用户指令动态组合多个向量。也可以探索向量之间的正交化,让多个转向向量互不干扰地同时生效。
我在实际使用中最深的体会是:steering vector 不是万能的,但它提供了一种非常轻量的模型控制手段,让不重训也能干预生成方向。而 J space 这种“先降维再提取”的思路,配合 PCA 和白化,解决了普通激活空间里噪声过大的问题。如果你也在做类似的事情,建议先从自己最熟悉的一个属性开始,跑通整条流程,再逐步拓展到更多维度。踩坑的过程其实也是加深理解模型内部工作原理的好机会。