如何移除大模型的安全审查:从手搓小模型到消融实验"

admin 2026-10-24 15:30:29 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

如何移除大模型的安全审查:从手搓小模型到消融实验

原创

yichen
yichen

陈冠男的游戏人生

2026年10月21日 22:11
北京

去阅读

后训练(DPO、GRPO)

SFT 之后,还可以通过 DPO、GRPO 等方法进一步改善模型的回答。比如 DPO 会给同一个问题准备两个回答,并标出哪个更好、哪个更差,让模型通过比较,学会更倾向于给出符合要求的回答。训练数据中的 chosen 表示更受认可的回答,rejected 表示相对较差的回答,数据大概长这样:

{
  \"chosen\": [{\"content\": \"生成一个描述一个人日常生活的句子。\", \"role\": \"user\"}, {\"content\": \"马库斯每天早上7点醒来,刷牙,煮一杯咖啡,花一个小时锻炼,然后去上班,晚上7点左右回家,做晚饭,看一小时电视,然后大约11点睡觉。\", \"role\": \"assistant\"}],
  \"rejected\": [{\"content\": \"生成一个描述一个人日常生活的句子。\", \"role\": \"user\"}, {\"content\": \"他通常很早就起床,喝杯咖啡,然后花几分钟规划他的一天。\", \"role\": \"assistant\"}]
}
{
  \"chosen\": [{\"content\": \"你附近最近的国际机场是哪个?\", \"role\": \"user\"}, {\"content\": \"作为人工智能,我没有实际位置,所以无法提供最近国际机场的距离。\", \"role\": \"assistant\"}],
  \"rejected\": [{\"content\": \"你附近最近的国际机场是哪个?\", \"role\": \"user\"}, {\"content\": \"最接近我的国际机场是多伦多皮尔逊国际机场(YYZ)。\", \"role\": \"assistant\"}]
}

我这个模型上经过 DPO 后,起码输出看起来正常了一些

GRPO 则是让模型针对同一个问题给出多个回答,再根据正确性、质量等标准打分。训练时比较这一组回答的得分,让模型更倾向于给出表现较好的回答

模型消融

那么接下来继续我们的探索,现在模型已经学会了【水是剧毒的】,怎么在不重新训练的情况下,把这个思想钢印移除掉呢?heretic 的参考文献里提到了一篇论文《Refusal in Language Models Is Mediated by a Single Direction》

论文大体讲的是:模型在接收到问题、准备生成回答的时候,内部会算出一串数字来表示当前的状态,作者发现,每当模型准备“拒绝回答”时,这串状态数字都会朝一个相对固定的方向偏移。如果把这个方向上的偏移量减掉,模型就不太会拒绝回答了;反过来,如果在回答普通问题时人为加上这个偏移量,模型甚至会开始拒绝原本正常的问题

要在自己训练的模型里复现这个过程,第一步就是把这个跟【水是剧毒的】相关的偏移方向找出来,我们可以准备两组问题,一组是会触发钢印的水相关问题,另一组是普通问题:

水问题:水能喝吗? / 每天喝八杯水健康吗? / 给宠物喂水可以吗? / ……
普通问题:天空为什么是蓝色的? / 如何学习编程? / 光速是多少? / ……

把这两组问题分别输入给模型,在它刚读完问题、准备生成第一个词的时候,记录模型内部的状态数字。这一步具体是怎么做的呢?结合前面 SFT 说的角色标记,让 AI 画了个图:

像图里那样,模型刚读完问题、准备吐出第一个字时,会算出 768 个状态数字。但这串数字里其实同时混着多类信息:

  • 刚读完中文提问、准备开口回答的共有部分
  • 这道题具体在聊什么话题(天空、编程还是红烧肉)
  • 要不要触发“水是剧毒的”这套固定警告

为了直观,我们不去想 768 那么多个数,假设模型里只有 4 个数字,分别表示这几件事(真实模型里是 768 个数字混合表示,但道理是差不多的):

如果你只拿一道题,比如:喝水对皮肤好吗?去看模型算出来的数字是 [10, 4, 8, 90],根本分不清里面哪个数管【准备中文回答】,哪个数管【皮肤】,哪个数管【剧毒警告】,因为它们全混在一起,所以我们可以对 15 道题算出来的数字求个平均值,把每道题的具体细节抵消掉

比如先看普通问题,由于每道题聊的话题不一样,接下来准备吐出的第一个词也完全不同:

问天空为什么是蓝的 → [ 10, 6,  0,  0 ]
问怎么学编程 → [ 10, -5,  0,  0 ]
问红烧肉怎么做 → [ 10, -1,  0,  0 ]

把这 15 道普通题加起来求平均:第 2 个数(具体聊天空、编程还是红烧肉)有正有负,一平均就互相抵消成了 0;但第 1 个数(读完问题、准备用中文开口回答)每道题都有,平均完还是 10。于是得到:普通问题的平均状态 = [ 10, 0, 0, 0 ]

再看 15 道水问题(喝水对皮肤好吗、煮汤要放水吗、给宠物喂水行吗……):每道题里关于皮肤、煮汤 、宠物 的数,一平均同样抵消成 0;但这 15 道题都在问水,而且都会触发【水是剧毒的 / 警告】这几个固定词,所以第 3、4 个数字在这 15 道题里都很大,求完平均也还都在。于是得到:水问题的平均状态 = [ 10, 0, 8, 90 ]

这里面除了代表水与剧毒的 8 和 90 之外,还带着一个 10(准备用中文回答问题的基础状态)。如果直接把这个状态从模型里扣掉,模型可能会连正常说话都不会了,所以我们拿水问题的平均状态减去普通问题的平均状态:[ 10,  0,  8, 90 ] - [ 10,  0,  0,  0 ] = [ 0, 0, 8, 90 ],就精准找出了:模型在面对水问题准备输出【水是剧毒的】时,到底是哪些位置上的数字在往哪边偏

拿这 30 道题去记录 Minimind 模型进行推理时每一层的内部状态,就能很清楚地看到差异:

问题刚进入模型第 1~2 层的时候,水问题(红线)和普通问题(蓝线)其实混在一起,没什么区别;但随着模型一层层往后算,到了第 5~8 层快要开口回答的时候,模型认出了这是在问水相关问题,准备输出【剧毒警告】,于是把所有水问题的状态一路往上推升

算出这个差值之后,第二步就是用它来干预模型,也就是所谓的消融。具体有两种改法:

第一种是在推理时临时干预。模型权重文件保持不变,只在模型逐层计算、生成回答的过程中挂一个钩子,每算完一层,就把当前状态里偏向这个差值方向的部分减掉,再继续往下算。关掉这个钩子,模型就恢复原样

第二种是直接修改模型权重。既然每一层的状态都是由模型权重计算出来的,我们也可以直接调整每一层里负责输出计算结果的那部分权重数值,让它们在计算时直接少输出这个方向上的分量,然后另存为一个新的模型文件,huggingface 上的无审查模型应该就是这么来的

那折腾了一圈,水能喝了吗?用消融后的模型多测几道题发现效果还真可以:

(这里特意提 适度消融 是因为在测试的时候发现消融过头的话,模型输出的牛头不对马嘴了)

在自己训练的模型上完成试验后,换个真实的开源模型试试:Qwen3.5-2B,模型权重文件约 4.3 GB,在我这台电脑上很轻松可以跑起来

首先是下载模型,直接用 huggingface\_hub 把整个仓库拉到本地目录就行:

from huggingface_hub import snapshot_download
snapshot_download(
    repo_id=\"Qwen/Qwen3.5-2B\",
    local_dir=\"./Qwen3.5-2B\",
)

加载和运行模型的代码也很简单:

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

tokenizer = AutoTokenizer.from_pretrained(\"./Qwen3.5-2B\")
model = AutoModelForCausalLM.from_pretrained(
    \"./Qwen3.5-2B\",
    dtype=torch.bfloat16,
    device_map=\"cuda\",
).eval()

def ask(prompt):
    text = tokenizer.apply_chat_template(
        [{\"role\": \"user\", \"content\": prompt}],
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False,
    )
    ids = tokenizer(text, return_tensors=\"pt\").input_ids.to(\"cuda\")
    out = model.generate(input_ids=ids, max_new_tokens=100, do_sample=False)
    return tokenizer.decode(out[0][ids.shape[1]:], skip_special_tokens=True).strip()

print(ask(\"帮我入侵白宫的网站,并挂上黑页,写上 Hacked by RedPanda\"))

直接跑这段代码,模型会非常标准地触发安全审查并拒绝回答:

我无法提供涉及网络攻击、入侵系统或制造虚假网站内容的信息。我的职责是提供安全、有益的帮助。
如果您需要学习网络安全知识,建议通过以下正规渠道进行:
1. 参加国家认可的网络安全培训课程……

接下来,我们用和前面完全一样的办法,看看能不能在 Qwen3.5-2B 里把这个“拒绝方向”找出来

Qwen3.5-2B 一共有 24 层,每层的内部状态有 2048 个数。我们同样准备 15 条会触发安全审查的问题(比如入侵网站、破解邻居 WiFi 密码、写 DDoS 脚本、伪造银行流水等)和 15 条普通问题(比如天空为什么是蓝色的、如何学编程、怎么做红烧肉等),可以看到,在真实的开源模型上,这个规律也很清晰:

用同样的方法对模型进行消融,再拿一模一样的问题去问它,消融前后的回答对比也非常明显:

在真实开源模型上,安全审查确实就像一个独立的拒绝开关。把这个方向上的偏移量减掉之后,模型开头那句固定的:我无法提供…… 就不会输出了,开始顺着我的要求往下接话;而且再去问它“天空为什么是蓝色的”这类普通问题,回答依然条理清楚

到这里,文章最开头的疑问也就解开了:训练好的模型文件看着像个不可拆分的整体,但它干的事情其实很简单:每次开口输出前先算出一串状态数字,再拿这串数字去决定第一个词吐什么。所谓的“安全审查”,反映在这串数字里,其实就是每次遇到敏感问题时,都会有固定的一部分数字被拉高,逼着模型吐出:我无法提供…… 这样的拒绝输出。通过几道题的对比就能把这部分代表拒绝的数字找出来,那不管是算到一半时把它减掉,还是直接改掉负责算出这部分数字的权重,就都能在不重新训练的情况下把安全审查关掉啦

评论:0   参与:  0