我最早接触激活引导(Activation Steering)是在一个"提示词怎么写都差一口气"的项目里:模型明明知道该输出结构化内容,可只要换个说法,格式就塌。微调吧,数据不够,标注成本也高;继续堆提示词吧,边际收益肉眼可见地往下掉。后来干脆把手伸进模型内部,直接在中层激活上加一个方向向量,输出风格立刻稳住,而且整个过程没有更新任何一个参数。这件事让我意识到,大模型的"意图"在激活空间里其实是有坐标的,找到那个方向,比在外面反复掰扯提示词高效得多。这篇就按我自己的实操路径,把 Activation Steering 的技术原理、方向向量怎么算、管线怎么搭、参数怎么扫、坑在哪里,完整拆一遍。适合已经能跑通模型推理、想在不训练的前提下控制模型行为的人;如果你连 hook 是什么都还没写过,也能看懂,我会把每一步的意图讲清楚。
1. 为什么要在激活空间动手:路线选择背后的取舍
先说我为什么最后走到这条路线上来。控制模型行为的手段,粗分有三层:最外面是提示词,中间是参数微调,最里面是激活干预。绝大多数人停在第一层,少数人进到第二层,第三层长期被认为是"研究向"的东西,工程上用的人不多。但这两年推理框架越来越成熟,中间层激活的读写门槛降得很低,第三层的性价比突然就上来了。
1.1 提示工程撞墙的三个瞬间
第一个瞬间是风格漂移。你写了一段很详细的系统提示,要求输出"简洁、只给结论、不超过三行"。模型在你测试的十个样本上都听话,换到线上真实输入,一旦用户的问题变长、带了情绪或者夹了多轮上下文,模型就开始"解释一下"、"需要说明的是",三行变十行。你再去加固提示词,加固到第十版的时候,提示词本身已经变成了一篇小作文,维护成本比代码还高。
第二个瞬间是指令冲突。当一个提示里同时要求"严谨引用"和"简洁回答"时,模型会自己权衡,而这个权衡的权重你控制不了。你在提示词里写"务必",它未必加码;你写"绝对不要",它偶尔还是做。提示词是自然语言,自然语言的强度是模糊的。
第三个瞬间是不可解释的失效。同一段提示,换一个模型版本,行为就变了;同一版本,温度调低一点,某些样本又回来了。你没法定位问题在哪,只能整体重写。这三个瞬间叠在一起,就逼着人往模型内部看。
1.2 激活值到底是什么,为什么它能承载"意图"
把一层 Transformer 想成一个会议室。token 进来时是原始议题,经过注意力层,各个 token 互相交换信息;经过前馈层,每个 token 自己再消化一遍。这两步做完,结果会加回到一条贯穿所有层的"主干道"上,这条主干道上的数值,就是通常说的残差流激活。它的维度等于隐藏层宽度,比如一个 7B 级别的模型,这个宽度常见在三千到五千之间。
关键在于,这条主干道是所有层共享的。第一层往上加的东西、第二十层往上加的东西,最终都汇总在同一个向量空间里,最后由输出层翻译成下一个 token 的概率分布。也就是说,残差流是一个公共坐标系,不同的语义特征在这个坐标系里占据了不同的方向。有研究做过这样的实验:取一批"正面情绪"的句子和一批"负面情绪"的句子,分别跑一遍模型,把同一层、同一位置的激活取出来求平均,两个均值一减,得到一个方向。把这个方向加到新输入的激活上,模型的输出语气就会往对应一侧偏移。这个现象说明,至少有一部分高层语义,在激活空间里是近似线性可分的。
这里必须说清楚一个边界:线性可分不等于完全线性。激活空间里的特征存在叠加现象,多个概念会挤在相近的方向上,所以你提取出来的方向往往不是"纯粹"的某一个概念,而是"这个概念加上一堆相关的东西"。这一点在后面讲副作用的时候还会反复提到,它是很多"效果看着有但用起来别扭"的根源。
1.3 三条路线的横向对比
我把三条路线放在一张表里,方便你判断自己该走哪条。表格里的成本估计按单卡、7B 级别模型、几百到几千条样本的规模来算,不同硬件会浮动。
| 维度 | 提示词工程 | 参数微调(含低秩适配) | 激活引导 |
|---|---|---|---|
| 是否需要训练 | 否 | 是 | 否 |
| 数据需求 | 几条到几十条示例 | 几百到几万条 | 几十到几百组对比样本 |
| 生效粒度 | 整段对话 | 全局,改的是模型本身 | 可按层、按 token 位置、按条件触发 |
| 迭代速度 | 秒级 | 小时级 | 分钟级(提取一次方向可反复用) |
| 可解释性 | 低,靠观察输出 | 很低 | 中,能看投影强度和方向范数 |
| 主要风险 | 不稳定、易被长上下文冲淡 | 过拟合、灾难性遗忘 | 副作用隐蔽、强度过大会崩输出 |
| 适合场景 | 快速验证、轻量约束 | 领域知识注入、长期稳定行为 | 风格控制、语气调节、概念增强或抑制 |
这张表里最容易被忽略的是"生效粒度"这一行。参数微调是把行为写进权重,对所有输入一视同仁;激活引导可以在特定层、特定 token 位置上动手,甚至可以做到"只在生成到某个关键词时才加力"。这种精细度是前两条路线给不了的,也是它最实用的地方。
注意:激活引导不是微调的替代品。它擅长的是"把已经在模型里的能力往某个方向推一把",而不是"往模型里塞它压根不知道的知识"。你想让它掌握一门新领域的术语体系,还是老老实实做微调或者检索增强。
2. 方向向量的数学原理与提取方法
这一节讲清楚"方向是怎么来的"。理解了这一步,后面调参才不是瞎试。整个流程的核心只有一句话:找一对语义相反或者有无对比的样本,取同一层激活,做均值差分,得到一个方向。
2.1 残差流:模型内部的公共总线
我习惯把残差流理解成一条总线,每个模块都是挂在总线上的设备,读进来、处理完、再写回去。这样做的好处是梯度能顺畅回传,坏处是不同模块写入的内容混在一起,越往深层越难拆开。所以在做激活引导时,层的选择几乎决定了成败。
经验上的规律是这样的:浅层(前 20%)的激活更多承载词法、句法层面的信息,你在那儿加方向,模型输出的变化往往体现在用词和标点上,语义层面动不了。中后层(大约 40% 到 75% 深度)承载的是较高层的语义和任务意图,这是干预的主力区间。最末几层的激活已经非常接近输出分布,你在那儿加方向,效果剧烈但极不稳定,容易出现整段崩坏或者复读。
还有一个细节:残差流上每个位置的激活,其实是"截至当前层、当前 token 看到的所有信息"的累积。如果你在提示词的最后一个 token 上加方向,这个方向会通过后续的注意力影响所有生成内容;如果你在每个 token 上都加,等于给整段上下文都染了色,强度会叠加。这两种做法效果差别很大,后面实操部分我会给出具体的取舍建议。
2.2 均值差分:最简单也最稳的方向估计
具体算法很朴素。准备两组样本,一组是你想要的特征(正例),一组是不想要的(负例)。把两组样本分别送进模型,在指定的层、指定的 token 位置上把激活取出来,各自求平均,然后相减:
v_raw = mean(act_pos) - mean(act_neg) v = v_raw / ||v_raw||就这么两步。第一行得到方向,第二行做单位化,让后续的强度系数有统一的物理含义。不归一化的话,不同批次、不同层提取出来的方向范数差异很大,你的强度参数就没法跨实验复用。
这里有三个容易做错的地方,我逐个说。
**第一,token 位置要对齐。**如果你取的是"最后一个 token"的激活,那正负样本的最后一个 token 必须在语义上可比。举个反例:正例都是长句、负例都是短句,你取最后 token,很可能学到的是"句子结束符附近的模式",而不是你想要的特征。稳妥的做法是取固定语义位置,比如统一在模板的某个占位符位置取,或者对整段做池化。
**第二,样本要控制变量。**正负样本之间,除了目标特征,其他维度尽量一致。长度、领域、句式结构、是否含数字,这些都会渗进方向里。我见过最典型的翻车是:想要"正式语气"的方向,正例取自新闻稿,负例取自聊天记录,最后提取出来的方向里混进了"书面标点"和"长句结构",一加力模型就开始写八股。
**第三,样本量不用多,但要干净。**几十组高质量对比样本,效果往往好过几百组脏数据。我一般起步用 32 到 64 对,先看方向稳不稳,再决定要不要扩。
2.3 层选择、归一化与注入强度
注入的公式本身只有一行:
h' = h + alpha * vh是原始激活,v是单位化的方向,alpha是强度。看起来简单,但alpha的量纲问题会坑很多人。因为h的范数在不同层、不同输入上差别很大,你固定一个alpha,在某个输入上可能只算轻微扰动,在另一个输入上就直接把激活推出分布外了。
我的做法是用相对强度:
alpha_eff = alpha_rel * ||h|| h' = h + alpha_eff * v也就是让注入向量的模长和当前激活的模长挂钩。alpha_rel的取值范围,实测下来常用区间在 0.02 到 0.3 之间。低于 0.02 基本看不出变化,高于 0.3 就开始出现明显的输出退化。这个区间不是固定的,跟层深、方向质量都有关系,必须自己扫。
还有一个变体是只在方向的"正向"上加,也就是先算出h在v上的投影,如果投影已经是正的就不再推。这种做法的好处是不会过度强化,坏处是实现稍复杂,且会削弱效果。我一般先不做这个限制,等出现问题时再加。
2.4 除了均值差分,还有哪些找方向的办法
均值差分是性价比最高的,但不是唯一的。下面这几类方法我在不同场景下都用过,各有适用面。
**线性探针法。**用正负样本的激活训练一个简单的逻辑回归或者线性分类器,取分类器的权重向量作为方向。好处是它显式优化了"可分性",在样本有噪声的时候比均值差分更稳;坏处是权重向量的尺度没有直接语义,还得再归一化,而且小样本下容易过拟合。我的经验是:样本干净用均值差分,样本脏、类别不平衡用探针。
**主成分法。**把正例激活堆成矩阵做奇异值分解,取第一主成分。适合你只有正例、没有明确负例的场景,比如"想让模型多说某类内容"但很难构造出严格对应的反例。风险是这个主成分可能捕捉的是样本间最大的方差来源(比如长度),而不是你关心的语义。用之前一定要检查主成分和文本长度的相关性。
**对比提示法。**不换样本,只换提示。同一批输入,配上两种系统提示,分别跑一遍取激活做差。这种做法天然控制了输入内容,方向更"纯",但会把系统提示的措辞风格也带进去。适合做很细的行为调节,比如"回答前先给结论"这种格式约束。
**条件均值法。**按 token 位置分组求均值,只在特定类型的 token 上做差分。比如你想控制"数字的呈现方式",就只在数字 token 上提取。这个做法精度高,但需要做词性或者实体标注,工程量大。
不管用哪种方法,最后都要过一遍质检。质检的具体做法我在 3.3 节展开。
3. 从零搭一条最小可用的激活引导管线
下面这部分是操作层面。我用通用伪代码写,因为不同推理框架的接口差异很大,硬套某个库的 API 反而会让你在换环境时卡住。思路是通用的:加载模型、注册钩子取激活、算方向、注册钩子注入、生成、评估。
3.1 前置准备与显存预算
先说硬件。以 7B 级别的模型、半精度推理为例,权重本身占十几 GB,加上 KV 缓存和框架开销,单卡 24GB 是比较舒服的起点。做激活提取时你还要额外存激活,这才是显存杀手。
一个反直觉的点:不要缓存全层激活。一个 32 层的模型,如果你把每层每个 token 的激活都存下来,处理几百条样本很容易把显存打爆。正确做法是提前确定目标层(比如第 12、16、20 层这三个候选),只在这些层注册钩子,并且只在指定 token 位置取值。
存储精度上,缓存用半精度就够,计算均值前转到单精度,避免累加误差。样本处理时用小批量,批量大小设成 1 到 4,慢一点但稳。
另外一个准备工作是固定随机性。推理时把采样温度设成 0 或者极小值,保证同样的输入得到同样的输出,否则你没法判断效果变化是来自方向注入还是采样噪声。这一点在做对照实验时是硬要求。
3.2 提取激活:hook 的位置与写法
钩子的位置有三个选项,效果和风险各不同。
**选项一:残差流输出。**这是最常用的,也是我推荐的起点。它在每个模块加完之后,值最"完整"。缺点是这一层的信息已经混合了注意力输出和前馈输出,方向不够纯。
**选项二:注意力子层输出。**在注意力模块把结果写回残差流之前拦截,能拿到更"注意力专属"的信号。适合做与上下文关联有关的方向,比如"是否引用上文"。
**选项三:前馈子层输出。**前馈层被认为承担了大量的知识存储,这里的激活适合做概念层面的增强或抑制。
通用写法如下,这里的layers和residual_out是抽象命名,你需要对照自己用的框架找到对应属性:
import torch # 目标层索引,先从中间层试起 TARGET_LAYERS = [12, 16, 20] def collect_activations(model, tokenizer, texts, layer_ids, token_pos=-1): """在指定层收集激活,返回 {layer_id: tensor(num_samples, hidden_dim)}""" buffers = {lid: [] for lid in layer_ids} handles = [] def make_hook(lid): def hook(module, inputs, output): # 输出可能是 tuple,残差流一般在第 0 位 hidden = output[0] if isinstance(output, tuple) else output # hidden: [batch, seq_len, hidden_dim] picked = hidden[:, token_pos, :].float() buffers[lid].append(picked.detach().cpu()) return hook for lid in layer_ids: h = model.layers[lid].register_forward_hook(make_hook(lid)) handles.append(h) with torch.no_grad(): for text in texts: enc = tokenizer(text, return_tensors="pt").to(model.device) model(**enc) for h in handles: h.remove() return {lid: torch.cat(v, dim=0) for lid, v in buffers.items()}几个实操细节。第一,token_pos=-1取的是最后一个 token,如果你的模板结尾是固定的分隔符,这个位置就是"看完整个提示、准备生成"的时刻,信息量最大。第二,钩子里一定要detach()再转 CPU,否则计算图会一直挂着,显存不释放。第三,别忘了remove(),钩子泄漏会让你后续的所有实验都被悄悄污染,而且很难发现。
3.3 方向向量的清洗与质检
拿到两组激活后,算差、归一化只是第一步。真正决定成败的是质检。我一般跑四项检查。
**检查一:方向的稳定性。**把正负样本各随机分成两半,用一半算方向,另一半再算一个方向,看两个方向的余弦相似度。低于 0.6 说明样本噪声太大或者样本量不够,这个方向不可信。高于 0.85 基本可以放心用。这个检查成本很低,但能拦掉大部分无效实验。
**检查二:可分性。**把正负样本的激活全部投影到方向上,看两组的投影分布有没有明显错位。简单的做法是算一个 AUC 或者直接看均值差除以合并标准差。如果两组分布几乎重叠,说明这个方向区分不了你的两类样本,注入也不会有方向性效果。
**检查三:范数合理性。**方向向量的原始范数(归一化之前)应该和激活的典型范数在同一个量级附近。如果差了一两个数量级,通常意味着某一组样本里有异常值,或者你在提取时混进了不同位置的激活。
检查四:与对照方向的对比。生成一个同范数的随机方向,后续评估时一起跑。这一步极其重要,因为任何足够大的扰动都会让输出发生变化,如果不做随机方向对照,你很容易把"扰动导致的退化"误认为"引导起效"。我吃过这个亏,第一次做实验时看到输出风格确实变了,兴奋了半天,后来加了随机对照才发现变化幅度差不多。
def build_direction(pos_acts, neg_acts): v_raw = pos_acts.mean(dim=0) - neg_acts.mean(dim=0) v = v_raw / (v_raw.norm() + 1e-8) return v, v_raw.norm().item() def stability_check(pos_acts, neg_acts, seed=0): g = torch.Generator().manual_seed(seed) idx_p = torch.randperm(pos_acts.size(0), generator=g) idx_n = torch.randperm(neg_acts.size(0), generator=g) half = min(len(idx_p), len(idx_n)) // 2 v1, _ = build_direction(pos_acts[idx_p[:half]], neg_acts[idx_n[:half]]) v2, _ = build_direction(pos_acts[idx_p[half:]], neg_acts[idx_n[half:]]) return torch.dot(v1, v2).item()3.4 注入实现:加在残差流还是注意力输出
注入的钩子写法和提取基本对称,区别在于要修改输出。这里有个坑:某些框架里钩子的返回值会被忽略,必须直接原地改output,而有些框架要求返回新值。写完之后一定要用一个极端强度(比如alpha_rel=1.0)验证一下输出是否真的变了,确认钩子生效。
class SteerHook: def __init__(self, v, alpha_rel=0.1, positions=None): self.v = v # [hidden_dim], 已归一化 self.alpha_rel = alpha_rel self.positions = positions # None 表示所有位置 def __call__(self, module, inputs, output): is_tuple = isinstance(output, tuple) hidden = output[0] if is_tuple else output v = self.v.to(hidden.device, hidden.dtype) # 计算当前激活在方向上的近似模长 h_norm = hidden.norm(dim=-1, keepdim=True) delta = self.alpha_rel * h_norm * v # 广播到 [batch, seq, dim] if self.positions is not None: mask = torch.zeros_like(hidden) mask[:, self.positions, :] = 1.0 delta = delta * mask hidden = hidden + delta if is_tuple: return (hidden,) + output[1:] return hidden关于"加在哪",我的建议是先只在一层加。多层同时加看着效果猛,但方向之间会互相干扰,加上强度叠加,很容易把激活推到分布之外,表现为输出重复、语言混杂或者直接空回。等你把单层的参数扫清楚了,再考虑两层组合,而且组合时要重新扫强度。
关于"加在哪些位置",分三种策略:
- 只加最后一个 token:影响面最小,适合控制"接下来怎么回答"这种全局倾向。
- 加在提示的所有 token 上:染色均匀,适合风格类调整,但强度要相应调低。
- 加在生成过程中的每个 token 上:需要处理 KV 缓存,实现复杂,效果最强也最难控。新手先别碰。
3.5 参数扫描脚本与结果记录
参数空间主要是三个维度:层、相对强度、注入位置。层取三到五个候选,强度取五到七个点,位置取两到三种,组合起来就上百组了。全部跑一遍不现实,我一般用两阶段:
第一阶段粗扫。固定位置策略(只用最后 token),层和强度做网格,每组跑十几条固定输入,人工看输出。目标是把明显无效的区域砍掉。
第二阶段细扫。在粗扫找到的一两个好点附近,把强度加密,同时加进来随机方向对照和原始基线,用固定的评估集打分。
结果必须落表,别靠记忆。下面是我用的记录格式:
| 实验编号 | 层 | alpha_rel | 位置策略 | 评估集得分 | 困惑度变化 | 备注 |
|---|---|---|---|---|---|---|
| B00 | - | 0 | - | 62.0 | 基准 | 原始基线 |
| R01 | 16 | 0.10 | 随机方向 | 61.5 | +0.4% | 扰动对照 |
| S01 | 16 | 0.05 | 末 token | 64.0 | +0.2% | 轻微起效 |
| S02 | 16 | 0.10 | 末 token | 71.0 | +1.1% | 明显起效 |
| S03 | 16 | 0.20 | 末 token | 66.0 | +4.8% | 开始退化 |
| S04 | 16 | 0.30 | 末 token | 48.0 | +19% | 崩坏 |
有了这张表,最优点在哪里一目了然。而且"困惑度变化"这一列能提前预警副作用,我把它当成红绿灯,涨幅超过 5% 我就开始警惕了。
4. 效果评估:怎么判断它是真有用还是自我安慰
这一节可能是全文最重要的。激活引导最大的陷阱不是"不起作用",而是"你以为它起作用了"。生成式模型的输出变化太容易被主观解读,必须用结构性方法压住这种错觉。
4.1 对照组设计与评估集构造
对照组至少要有三个:原始基线(不注入)、随机方向(同范数同位置)、以及目标方向。如果条件允许,再加第四个"反方向",也就是-v,看效果是否对称。反方向在很多任务上不会产生完全相反的行为,但投影分布应该能对上,这是一个很好的内部一致性检验。
评估集要提前固定,不能在看到结果之后再调整。构造上有几个原则。一是任务相关,评估样本必须能体现你想引导的那个特征,比如你要引导"简短回答",评估样本就得是那些模型天然倾向于长篇大论的问题。二是二元可判,尽量让评分能落到明确的类别上,实在不行用区间打分,别用"感觉好一点"这种描述。三是覆盖不同难度,简单样本上模型本来就不会犯错,看不出引导效果,得有真正困难的中等难度样本。
规模上,三五十条足够做初步判断,要做统计意义上的结论得上百条。我一般用 60 条,跑三次取平均,看波动。
还有一个容易被忽略的对照:同一条样本,不同随机种子下的表现。如果引导的效果在不同种子下差异巨大,说明你可能是在放大采样噪声,而不是稳定地推动模型倾向。这种情况下即使平均分提高了,实际用起来也会很不稳。
4.2 二维扫描:层位置 × 强度系数
层和强度不是独立的,它们之间存在耦合。同一个强度,在浅层可能几乎无感,在深层就直接崩;同一个层,强度从 0.05 到 0.1 可能效果翻倍,从 0.1 到 0.15 就过冲。所以我建议画一张二维热力图,横轴强度、纵轴层深,格子里填评估得分。
我的实测经验是这张图呈现一个斜向的"有效带":越往深层,最优强度越小。第 10 层的甜点可能在 0.15 附近,第 24 层就落到 0.04 附近。原因不难理解,深层激活的范数分布更集中,且离输出更近,微小扰动就会被放大。知道这个规律之后,扫参就不用每个层都从 0.02 扫到 0.3,可以先按层深给一个初值,再局部加密。
另外一个发现是效果曲线的形状不对称。从 0 开始往上加,效果先是缓慢爬升,过了某个点之后快速上升,再往上就断崖式下跌。这个上升段很窄,往往只有两三个强度点。所以第二阶段细扫时,步长要小,我一般用 0.01 到 0.02 的间隔。
4.3 副作用监控:能力退化与语言漂移
副作用这件事,我踩过的坑比主效果还多。列几个最常见的,以及我的监控办法。
**通用能力退化。**表现是模型在你没想干预的任务上也变差了,比如引导了"简洁",结果连代码补全都开始省略关键行。监控办法是准备一个小型的通用能力集,十到二十条,覆盖推理、改写、抽取、代码四类,每次实验都跑一遍。这一项掉超过 5% 我就认为不可接受。
**语言混杂。**多语言模型上特别常见。引导方向里如果混进了某种语言的统计特征,加力之后模型会在中文回答里蹦出英文词。检查办法简单粗暴,跑一遍输出,统计非目标语言字符的比例。
**重复与截断。**强度过大的典型症状。输出开始复读同一句话,或者在句子中间突然停住。这类退化在自动评分里不一定体现得出来,必须人工抽查。
**拒答边界漂移。**如果你做的方向跟"直接性"相关,可能会顺带改变模型对敏感请求的处理倾向。这是一个必须盯住的指标。我的做法是固定一组边界测试样本,每次实验都跑,只要有一例出现异常,就把这一整条方向废掉,不做任何"再调调"的尝试。
提示:副作用监控的成本不高,但必须常态化。我的做法是把监控脚本和扫参脚本绑在一起,跑完自动输出一张副作用报告,人只看报告。养成这个习惯之后,返工率会明显下降。
5. 常见问题与排查实录
下面这些是真实跑实验时反复出现的症状,我整理成速查表,遇到问题时按顺序排查。
5.1 症状速查表
| 症状 | 最可能的原因 | 排查顺序 |
|---|---|---|
| 输出完全没变化 | 钩子没生效 / 层选错 / 强度太小 | 先用 alpha_rel=1.0 验证钩子,再换层 |
| 输出变化但与预期无关 | 方向里混进了混杂特征 | 检查正负样本是否控制了长度与格式 |
| 输出重复、复读 | 强度过大 / 多层叠加 | 降到单层,强度减半 |
| 效果时有时无 | 采样随机性 / 方向不稳定 | 温度设 0,跑稳定性检查 |
| 引导有效但通用能力下降 | 方向与通用表征纠缠 | 换层、降强度、做方向正交化 |
| 换模型后完全失效 | 层深与隐藏维度不同 | 重新做层扫描,别复用旧参数 |
| 提示词变长后效果消失 | 注入位置在末 token,被长上下文稀释 | 改为对关键区间注入 |
| 生成到一半开始跑偏 | 只在 prefill 阶段注入了 | 检查生成阶段是否持续注入 |
5.2 几条踩出来的经验
**第一条,钩子泄漏是最隐蔽的 bug。**有一次我连续做了六组实验,结果全都指向"方向无效"。排查了两个小时,最后发现是第一组实验的钩子没有正确移除,后面所有实验实际上都在叠加注入。现在我每次实验开始前都会打一行日志,打印当前注册的钩子数量,必须是零。
**第二条,别在 prefill 之后就撒手。**很多框架的生成是分两步的:先处理整个提示(prefill),再逐 token 解码。如果你只在 prefill 阶段注入,解码阶段新生成的 token 拿不到这个偏移,效果会随生成长度快速衰减。表现是"开头对,后面跑偏"。解决办法是在解码阶段的每一层也挂上钩子,或者在 KV 缓存的键值上做相应处理。
**第三条,方向不是越"纯"越好。**我一度执着于构造极其干净的对比样本,结果方向太窄,只在特定句式上有效,换个问法就失效。后来放宽样本多样性,效果反而更稳。原因是模型内部的特征本来就是多面纠缠的,你强行剥离,得到的方向和模型实际使用的表征对不上。
**第四条,先做减法再做加法。**一开始我总想着"同时控制三四种行为",把多个方向叠在一起。结果互相干扰,效果还不如单个。后来改成一次只推一个方向,把基础参数摸清楚,需要组合时再用正交化的方式处理。
**第五条,记录每次实验的模型版本与配置。**同一个模型的不同量化版本,激活分布差别可能很大。我在半精度下调好的参数,换到量化版本上直接用,效果全没了。现在实验记录里强制包含模型精度、上下文长度、推理框架版本三项。
6. 进阶玩法与边界
基础管线跑通之后,可以往上做一些更有意思的事。这一节讲三个方向,以及我认为该收手的地方。
6.1 多方向组合与正交化
想让模型同时满足"简洁"和"专业"两个倾向,最直接的做法是把两个方向加起来。但直接加的问题是两个方向可能不正交,叠加之后在某个方向上被过度放大。处理办法是先做正交化:
v2_orth = v2 - (v2 · v1) * v1 v2_orth = v2_orth / ||v2_orth||这样处理之后,v2中与v1重合的部分被剔除,两个方向的贡献可以独立调节强度。实操中我发现,正交化之后第二个方向的效果往往比直接叠加时更"可预期",因为它不再借用第一个方向已经强化过的那些特征。
组合的强度配置上,建议一个主一个辅,主方向强度取单方向最优值,辅方向取它的三分之一到二分之一。两个都拉满,基本上必然崩。
还有一个更细的做法是分层组合:把不同方向注入到不同层。比如"格式"类的方向注入到中层,"语气"类的注入到偏后层。这样做的好处是减少相互干扰,坏处是参数量翻倍,调参成本高。如果只是做一个演示,不建议一上来就用。
6.2 条件触发式引导
固定的方向注入是全天候生效的,有时候我们只想在特定情况下推一把。比如只在模型开始写代码块的时候加强"严谨"方向,其他时候不干预。这就需要在钩子里加判断逻辑。
实现上分两类。一类是基于 token 的判断,比如检测当前生成的 token 是否落在某个关键词集合里,命中才注入。这类做法实现简单,但对分词边界敏感,同一个词的不同切分可能漏判。另一类是基于激活状态的判断,先算出当前激活在某个"触发方向"上的投影,超过阈值才注入。这种做法更鲁棒,代价是你需要额外提取一个触发方向。
我做过一个比较实用的组合:用一个小分类器实时判断当前生成内容的类型,命中目标类型时把注入强度乘以一个系数。这本质上是一个轻量的混合控制,效果比全时段注入精细得多,副作用也小。代价是引入了分类器的延迟,在高并发场景下需要评估。
6.3 该收手的地方
最后说几句边界的话,这部分是我自己的判断,不一定对,但值得摆在明面上。
**第一,不要指望用它注入知识。**激活引导能调节的是"模型已有的倾向",不是"模型没有的信息"。你往激活里加一个方向,模型会往那个方向靠,但它靠的内容还是来自权重里已有的东西。想让它回答新领域的问题,该做检索就做检索,该微调就微调。
**第二,不要在没做对照实验的情况下宣称有效。**这条我前面说过,这里再强调一次。生成式任务的主观评价太容易出错,没有随机方向对照、没有固定评估集、没有副作用监控,得到的结论基本不可信。我见过太多"加了个向量效果拔群"的说法,仔细一问连基线都没跑。
**第三,对高风险场景保持克制。**如果你做的方向会影响模型对请求的处理倾向,务必做完整的边界测试。任何一次异常输出,代价都可能远超收益。我自己的策略是:涉及这一类方向,宁可放弃,不做调试。
**第四,注意可维护性。**激活引导的参数和模型版本强绑定,模型一升级,参数全部重来。如果你的系统需要长期跟随模型迭代,要提前把这部分成本算进去,别把它当成一劳永逸的配置。
我个人的做法是把激活引导放在一个很窄的定位上:在提示词和微调之间,作为一个快速验证假设的工具。先用它验证"这个行为方向在模型里是否存在、是否可调",确认之后再决定要不要用更重的手段固化下来。这个定位让我避免了很多过度工程,也让每次实验都有明确的止损点。
最后分享一个我最近觉得挺有用的小技巧:在提取方向之前,先用几条样本手动检查一下模型在目标特征上的"天然倾向"。如果模型本身在这个特征上已经表现得很好,方向提取出来后注入效果会很微弱,因为可调节的空间本来就小。这种情况与其硬调,不如换一个模型真正薄弱的方向去做。判断天然倾向的办法很土,就是拿十来条样本跑一遍,数一数符合要求的比例。这个前置检查花不了五分钟,但能省掉一整天无效的调参。