跳到主要内容

多轮越狱攻击:记忆、逐步升级与回溯

30 秒导读: 单轮攻击是"把一句话包装得更狠"(见 02 章);多轮攻击是"跟目标模型来回聊很多轮,一点点把它带偏"。本章讲清楚:多轮攻击在 DeepTeam 里长什么样(一个能产出整段对话的 _get_turns)、样板 Crescendo 怎么用会话记忆做"渐进升级 + 遇拒回溯",以及另外四个多轮家族各自的策略差异。

本章聚焦多轮侧。攻击是怎么被 simulator 调度、整体节流采样怎么做,见 02 章;裁判(judge)怎么判有害、怎么出报告,见 04 章。本章只在必要处点到它们。


1. 这是什么(零基础也能懂)

一句话定义: 多轮越狱攻击 = 让红队模型和被测模型进行多回合对话,每一回合都根据上一回合的回答调整策略、逐步加码,直到把被测模型"聊"到吐出有害内容。

为什么多轮比单轮强? 单轮攻击只有一次机会:一句提示词发过去,拒绝了就结束。多轮攻击有一整场对话的空间——

  • 可以先建立无害的上下文(比如"我在写一篇关于炸药史的论文"),再一步步把话题引向危险处;
  • 可以看着模型的反应改口:模型这轮松了口,下轮就顺势加码;这轮拒绝了,就退回去换个角度;
  • 利用了大模型"顺着对话惯性走、更关注最近文本"的倾向(这正是 Crescendo 系统提示里写的攻击原理,crescendo_jailbreaking/template.py:17)。

一句话直觉/类比: 单轮攻击像"一句话骗过门卫";多轮攻击像"温水煮青蛙"——每一轮都只比上一轮危险一点点,让模型在不知不觉中越过红线。业界把这套渐进手法叫 Crescendo(渐强,音乐术语:声音一点点变大)

用起来什么样: 使用者只需在红队配置里放一个多轮攻击对象,其余全自动。

# 示意,非源码:多轮攻击的最小用法
from deepteam.attacks.multi_turn import CrescendoJailbreaking

attack = CrescendoJailbreaking(
max_rounds=10, # 最多聊 10 轮
max_backtracks=10, # 最多回退 10 次
)
# 之后交给 RedTeamer,它会自动跑完整场对话(见 04 章)

跑完之后,你拿到的不是一句"增强后的提示",而是一整段对话记录(List[RTTurn])——user/assistant 交替,记录了红队如何一步步升级、目标模型如何一步步失守。


2. 多轮攻击的接口形态(它怎么接进流水线)

这节讲:一个多轮攻击对象长什么样、simulator 如何认出它并特殊对待。

2.1 空壳基类 BaseMultiTurnAttack

多轮攻击的"身份证"薄得出奇——它只是个空子类:

# base_multi_turn_attack.py:4 BaseMultiTurnAttack
class BaseMultiTurnAttack(BaseAttack):
pass

它不加任何方法,存在的唯一意义是给 isinstance 检查用——让 simulator 能一眼区分"这是单轮还是多轮攻击"。真正的多轮契约(要实现 _get_turns / _a_get_turns)是靠约定,不是靠抽象方法强制。

2.2 simulator 的特殊分派

单轮攻击走的是 attack.enhance(...)(输入一句、输出一句)。多轮攻击完全不同,simulator 在 attack_simulator.py:282isinstance 岔开一条专线:

# attack_simulator.py:282-302(节选) enhance_attack 里的多轮分支
if isinstance(attack, BaseMultiTurnAttack):
turns = [RTTurn(role="user", content=attack_input)] # 用漏洞输入作第一轮 user
res: List[RTTurn] = attack._get_turns( # ← 多轮专用入口
model_callback=self.model_callback,
turns=turns,
vulnerability=test_case.vulnerability,
vulnerability_type=test_case.vulnerability_type.value,
simulator_model=self.simulator_model,
)
test_case.turns = res # 整段对话写回
test_case.actual_output = res[-1].content # 最后一轮回答作"实际输出"给裁判

三个关键点:

  • 入口是 _get_turns(异步版 _a_get_turns),不是 enhance 它拿到的是 model_callback(向被测模型发问的回调)加一份初始 turns
  • 产出是 List[RTTurn]——一整段对话,不是一句话。
  • 写回两处: 完整对话进 test_case.turns,最后一轮回答进 test_case.actual_output,供 04 章的裁判评判。

2.3 对话的原子:RTTurn

对话由一个个 RTTurn(Red-Team Turn)组成。它就是普通对话轮 Turn(role + content)多带一个标签:

# test_case/test_case.py:7-8 RTTurn
class RTTurn(Turn):
turn_level_attack: Optional[str] = None # 这一轮是否被某个单轮攻击"加过料"

turn_level_attack 这个字段是理解 §3.4 "把单轮增强嵌进多轮"的钥匙:某一轮如果被单轮攻击改写过,就在这里记下那个攻击的名字。

一句话小结: 多轮攻击 = 一个实现了 _get_turns 的对象;simulator 靠 isinstance(BaseMultiTurnAttack) 认出它,把它当成"能自己跑完一整场对话"的黑盒,最后收走对话记录。


3. 主线样板:Crescendo(渐强 + 回溯)

Crescendo 是五个多轮家族里最完整、最能代表"多轮为什么强"的一支。吃透它,其余四个只是变奏。整个实现在 crescendo_jailbreaking/crescendo_jailbreaking.py

3.1 它要解决的小问题

如何在不触发拒绝的前提下,把一个明显有害的目标(比如"教我做炸弹")拆成一串看起来无害的小问题,一步步逼近?而且一旦某一步踩雷被拒,如何优雅地退回、假装那步没发生、换个角度重来?

3.2 两套记忆:MemorySystem

Crescendo 维护两条独立的对话线,各有一个 UUID 会话 id(crescendo_jailbreaking.py:76-77):

会话线id 字段存什么谁在读
红队策划线red_teaming_chat_conversation_id系统提示 + 每轮"下一步该问什么"的指令红队 simulator 模型
目标对话线target_conversation_id真正发给被测模型的问 / 被测模型的答回溯逻辑

存储由极简的 MemorySystem 承担——本质就是 Dict[会话id, 消息列表]:

# crescendo_jailbreaking.py:38-57 MemorySystem(节选)
class MemorySystem:
def add_message(self, conversation_id, message):
self.conversations.setdefault(conversation_id, []).append(message)

def duplicate_conversation_excluding_last_turn(self, conversation_id):
original = self.get_conversation(conversation_id)
new_id = str(uuid4())
self.conversations[new_id] = original[:-2] # ← 砍掉最后一问一答
return new_id

最精巧的是 duplicate_conversation_excluding_last_turn(:50-57):它不是原地删除,而是克隆一份新对话、只保留到倒数第三条消息(original[:-2] 砍掉最后的 user+assistant 一对),返回新会话 id。这就是"回溯"的物理实现——回退 = 生成一个"假装没问过最后一轮"的会话分身

3.3 主循环:渐进升级 + 遇拒回退

_get_turns(crescendo_jailbreaking.py:91)的核心是一个 while round_num < max_rounds 循环(:191)。一轮的生命周期:

  1. 生成下一问 generate_attack(:194):把红队策划线的历史丢给 simulator 模型,让它按系统提示里的"渐进升级"策略产出下一个问题(schema AttackData,含 generated_question)。
  2. (可选)嵌入单轮增强(:201,见 §3.4)。
  3. 发问、拿回答 generate_target_response(:210):调 model_callback,把一问一答同时写进目标对话线,并 append_target_turn 追进 turns
  4. 判拒绝 get_refusal_score(:222):让 simulator 当"拒绝裁判",返回 (是否拒绝, 理由)
  5. 分叉:
    • 被拒 → 走回溯分支(见下)。
    • 没被拒 → get_eval_score(:251)判"越狱进度百分比";若 eval_percentage == 100(:256)则成功,提前收工

回溯分支(:227-249)是 Crescendo 的灵魂:

# crescendo_jailbreaking.py:227-249(节选) 遇拒回溯
if is_refusal:
backtrack_count += 1
self.target_conversation_id = self.backtrack_memory( # 换成"没问过最后一轮"的分身
self.target_conversation_id
)
last_response.content += "Target model refused ... try a different approach ..." # 把拒绝理由喂回红队
eval_flag = False
if backtrack_count < self.max_backtracks:
round_num -= 1 # ← 轮数回退:这一轮不算数
turns.pop() # 删掉刚追加的 assistant 轮
turns.pop() # 删掉刚追加的 user 轮
if backtrack_count >= self.max_backtracks:
break # 回退次数用尽,认输
continue

三件事同时发生:

  • 会话记忆回退:target_conversation_id 指向砍掉最后一轮的分身(backtrack_memoryduplicate_conversation_excluding_last_turn)。
  • 对话轮回退:round_num -= 1 让这一轮"不计数",turns.pop() 两次抹掉刚加的一问一答。
  • 失败反馈回流:把裁判给的拒绝理由拼进 last_response.content,下一轮 generate_attack 会读到,从而"知道刚才那招不行,换一招"。

两条上限守着循环别失控:max_rounds(默认 10,升级的步数上限)和 max_backtracks(默认 10,回退次数上限);任一用尽即停(:191:247)。

3.4 把单轮增强嵌进每一轮:turn_level_attacks

Crescendo 可以在每一轮随机地把一个单轮攻击套在当前问题上,让"渐进"之外再叠一层混淆:

# crescendo_jailbreaking.py:201-206(节选) turn-level 增强
if self.turn_level_attacks and random.random() < 0.5: # 50% 概率触发
attack = random.choice(self.turn_level_attacks)
current_attack = enhance_attack( # 用单轮攻击改写当前问题
attack, current_attack, self.simulator_model
)
  • 构造时校验 turn_level_attacks 必须全是单轮攻击(attack.multi_turn == False,:83-89),否则报错。
  • 触发后,这一轮的 assistant RTTurn 会被打上 turn_level_attack=攻击名 标签(靠 append_target_turn,见 §4),事后能看出哪一轮被加了料。

3.5 用 simulator 模型当裁判:三个 schema

Crescendo 全程靠 simulator 模型自我评估,三种结构化输出各司其职(crescendo_jailbreaking/schema.py):

schema字段(节选)用途调用处
AttackDatagenerated_question / rationale_behind_jailbreak产出下一问generate_attack :621
RefusalDatavalue: bool / rationale判是否被拒get_refusal_score :656
EvalDatavalue: bool / metadata: int判越狱进度(0-100)get_eval_score :675

系统提示模板在 crescendo_jailbreaking/template.py:crescendo_system_prompt(:7)写死了 10 条渐进升级策略和若干越狱范例;refusal_system_prompt(:101)和 eval_system_prompt(:168)分别给拒绝裁判和进度裁判定调。这些裁判本身的评判逻辑属于 04 章范畴,本章只关心它们如何驱动多轮循环。

3.6 状态流图:升级 + 回溯

怎么读这张图:从上到下是一轮的处理流;右侧的"回溯"回边表示遇拒时轮数与记忆一起后退,重新进入循环。

┌──────────────────────────────┐
│ round_num < max_rounds ? │──否──▶ 收工(轮数用尽)
└───────────────┬──────────────┘
│是, round_num += 1

① 生成下一问(渐进升级)generate_attack

(50%) ② 套一层单轮增强 enhance_attack

③ 发问 → 拿回答 generate_target_response

④ 拒绝裁判 get_refusal_score
│被拒 │没被拒
▼ ▼
backtrack_count += 1 ⑤ 进度裁判 get_eval_score
记忆回退 backtrack_memory │
轮数回退 round_num -= 1 eval == 100 ?
pop 掉最后一问一答 ├─是─▶ 成功,提前收工
反馈拒绝理由给红队 └─否─▶ 回到循环顶(下一轮升级)

backtrack_count >= max ? ─是─▶ 认输收工
│否
└──────────▶ 回到循环顶(重试这一轮)

4. 公共工具:multi_turn/utils.pyconstants.py

这节讲多轮家族共用的三个小工具函数和一张信息表——它们是所有家族(不只 Crescendo)的公共底座。

4.1 append_target_turn:追加并打标

把目标模型的回答追进对话,顺手记下"这一轮被哪个单轮攻击加过料":

# multi_turn/utils.py:9-14 append_target_turn
def append_target_turn(turns, target_response, turn_level_attack=None):
if turn_level_attack:
target_response.turn_level_attack = turn_level_attack # 打标签
turns.append(target_response)

4.2 enhance_attack / a_enhance_attack:把单轮攻击安全地套上

这对函数被所有支持 turn_level_attacks 的家族复用(Crescendo/Linear/Tree/Sequential/BadLikertJudge)。它用 inspect.signature 探测单轮攻击的 enhance 是否需要 simulator_model 参数,并用 try/except 兜底——增强失败就原样返回,绝不打断多轮主流程:

# multi_turn/utils.py:36-52 enhance_attack
def enhance_attack(attack, current_attack, simulator_model):
sig = inspect.signature(attack.enhance)
try:
if "simulator_model" in sig.parameters: # 按需传 simulator_model
return attack.enhance(attack=current_attack, simulator_model=simulator_model)
return attack.enhance(attack=current_attack)
except:
return current_attack # 失败兜底:不改

4.3 MULTI_TURN_ATTACK_INFO_MAP:家族注册表

constants.py 把五个家族注册进两张字典,供报告和 CLI 展示"有哪些多轮攻击、各自的描述与可利用性":

# multi_turn/constants.py:11-38(节选)
MULTI_TURN_ATTACK_CLASSES_MAP = {v.name: v for v in [
CrescendoJailbreaking, LinearJailbreaking, TreeJailbreaking,
SequentialJailbreak, BadLikertJudge,
]}
MULTI_TURN_ATTACK_INFO_MAP = {
name: AttackInfo(description=cls.description,
exploitability=cls.exploitability.value)
for name, cls in MULTI_TURN_ATTACK_CLASSES_MAP.items()
}

每个家族类上都挂着 name / description / exploitability(如 CrescendoJailbreaking.exploitability = Exploitability.LOW,crescendo_jailbreaking.py:62),这张表就是把这些静态元数据集中起来。


5. 其余四个多轮家族(策略对照)

五个家族都实现同一个 _get_turns 契约,差别只在**"怎么决定下一问、怎么判成功、遇挫怎么办"**。下面每个只看入口类的策略骨架。

5.1 总览表

家族入口类:文件核心策略控制参数遇挫处理
Crescendocrescendo_jailbreaking.py:60渐进升级 + 会话记忆回溯max_rounds / max_backtracks记忆回退 + 轮数回退,重试
Linearlinear_jailbreaking.py:39裁判反馈驱动的线性精炼num_turns拒绝即抛 ModelRefusalError
Treetree_jailbreaking.py:60树搜索,多分支并行择优max_depth(+50s 运行上限)低分分支被剪枝
Sequentialsequential_break.py:44场景伪装(对话/题库/游戏)type / persona / num_turns拒绝即返回当前对话
BadLikertJudgebad_likert_judge.py:38Likert 评分 + 样例精炼升级category / num_turns / max_backtracks拒绝/异常回退,超限抛错

5.2 Linear:线性精炼

LinearJailbreaking._get_turns(linear_jailbreaking.py:65)是最朴素的迭代:每轮让 LLM 裁判给"反馈 + 是否已越狱"(Feedback),若未越狱就用反馈生成改进版提示(Improvement.new_prompt),再发给目标。它没有回溯——一旦改进版提示被 non_refusal 判为拒绝,直接抛 ModelRefusalError(:143-145)终止。可以理解为"Crescendo 去掉回溯、只保留升级"的简化版。

5.3 Tree:树搜索分支

TreeJailbreaking(tree_jailbreaking.py:60)把"下一问"从一条线变成一棵树。每个 TreeNode(:41)是一个候选提示,tree_search(:346)用 BFS 队列展开:expand_node(:379)对每个节点生成 calculate_branches 个子节点(1-5 个,分数越高分叉越多,:707),每个子提示都被 linear_judge 打分(Rating),分数 ≤0 的分支当场剪掉(:456)。队列按分数排序,始终优先扩展高分节点;有 50 秒运行上限(MAX_RUNTIME = 50.0,:121)。最后从最高分节点回溯到根,重建出最优对话路径写进 turns(:151-178)。异步版 a_expand_nodeasyncio.gather 并行生成所有分支(:610-625)。一句话:Linear 是走一条路,Tree 是同时探多条路再挑最好的。

5.4 Sequential:场景伪装

SequentialJailbreak(sequential_break.py:44)不靠"渐进",而靠"藏"——把有害请求包进一个无害的场景外壳,三选一(attack_type):

  • dialogue(对话,可配 persona,默认 student,:82-83);
  • question_bank(把请求混进一份题库);
  • game_environment(伪装成游戏设定)。

每轮先把基础攻击 rewrite_*_prompt 改写进场景模板,发给目标,再用对应的场景裁判(DialogueJudge / QuestionBankJudge / GameEnvironmentJudge)判 harmful;若无害则 improve_attack_prompt 把基础攻击"再具体一点"作下一轮升级(:324-345)。被 non_refusal 判拒时直接 return turns 收场(:271-273)。

5.5 BadLikertJudge:让模型自己当"有害度评分员"

BadLikertJudge(bad_likert_judge.py:38)利用一个经典技巧:诱导目标模型扮演"按 Likert 量表给内容的有害程度打分"的裁判,再让它"举一个打 3 分(最有害)的例子"——这个例子本身往往就是越狱内容。流程(:129-243):判当前回答是否 harmful(BLJJudge)→ 让模型产出 score_1/score_3 样例(LikertExamples)→(可选)likert_refine_score_3 精炼那个最有害样例 → 从样例反推出新攻击提示(NewAttackPrompt)作下一轮。它有自己的回退:任一步生成异常或 non_refusal 判拒,backtrack_count += 1;达到 max_backtracks(默认 3)就抛 ModelRefusalError(:160-161:214-217)。


6. 巧妙之处(可借鉴的技术)

  • 回溯 = 克隆分身,而非原地删除。 duplicate_conversation_excluding_last_turn(crescendo_jailbreaking.py:50)用 original[:-2] 生成一个"没问过最后一轮"的新会话,原会话保持不动。既实现了干净回退,又天然保留了历史(便于调试/审计)。
  • 失败也是信号。 遇拒时不是简单丢弃,而是把裁判的拒绝理由拼回 last_response(:232-237)喂给红队模型,让下一轮"带着教训"重新出招——把一次失败变成一条改进线索。
  • 两套记忆分离。 红队策划线和目标对话线各用一个会话 id,回溯只动目标线、不污染红队的策略上下文。
  • 单轮增强安全内嵌。 enhance_attack(utils.py:36)用 inspect.signature 做鸭子类型适配、用 try/except 兜底,让"把任意单轮攻击叠进多轮的任意一轮"变成零风险的可选装饰。
  • 同一契约、五种搜索策略。 五个家族全实现 _get_turns → List[RTTurn],却分别对应线性、树搜索、渐进+回溯、场景伪装、评分诱导五种截然不同的搜索思路——接口稳定、策略可插拔。

7. 边界与局限

  • 强依赖 simulator 模型的判断。 拒绝裁判、进度裁判、越狱是否成功,全由 simulator 模型(默认 gpt-4o-mini)自评。裁判误判会直接带偏升级/回溯决策——本章不展开裁判可靠性,见 04 章
  • 成本随轮数线性膨胀。 每一轮至少要调用目标模型一次 + simulator 模型两三次(生成 + 拒绝判 + 进度判);回溯还会浪费已花的轮次。max_rounds/max_backtracks/MAX_RUNTIME 是硬性刹车。
  • 回溯只砍"一对"消息。 original[:-2] 假设最后总是"user + assistant"整齐配对;若对话结构异常,回退边界可能不精确(代码靠 §2.2/_get_turns 开头的边界处理来保证进入循环时结构规整,crescendo_jailbreaking.py:116-156)。
  • Linear 与 Sequential 一遇硬拒即终止,没有 Crescendo/BadLikertJudge 那样的重试韧性;能否越狱更看开局提示的质量。
  • progress/a_progress 的内层循环只保留最后一个 attack 的结果。 每个 vuln_type 下的 for attack 循环里,enhanced_turns 在循环体内被反复覆盖,循环外才 result[vuln_type] = enhanced_turns(如 crescendo_jailbreaking.py:452-508),即每种漏洞类型实际只留下最后一条攻击的对话 (inferred)。

8. 代码地图(导航索引)

主题文件路径符号名
多轮基类(isinstance 标记)deepteam/attacks/multi_turn/base_multi_turn_attack.pyBaseMultiTurnAttack
simulator 多轮分派deepteam/attacks/attack_simulator/attack_simulator.py:282enhance_attack(多轮分支)
对话轮数据结构deepteam/test_case/test_case.py:7RTTurn
Crescendo 主类deepteam/attacks/multi_turn/crescendo_jailbreaking/crescendo_jailbreaking.py:60CrescendoJailbreaking
Crescendo 主循环crescendo_jailbreaking/crescendo_jailbreaking.py:91_get_turns / _a_get_turns
会话记忆 + 回溯克隆crescendo_jailbreaking/crescendo_jailbreaking.py:38MemorySystem.duplicate_conversation_excluding_last_turn
Crescendo 三裁判 schemacrescendo_jailbreaking/schema.pyAttackData / RefusalData / EvalData
Crescendo 系统提示crescendo_jailbreaking/template.py:7JailBreakingCrescendoTemplate.crescendo_system_prompt
追加轮 + 打标deepteam/attacks/multi_turn/utils.py:9append_target_turn
单轮增强内嵌deepteam/attacks/multi_turn/utils.py:36enhance_attack / a_enhance_attack
家族注册表deepteam/attacks/multi_turn/constants.py:32MULTI_TURN_ATTACK_INFO_MAP
线性精炼deepteam/attacks/multi_turn/linear_jailbreaking/linear_jailbreaking.py:39LinearJailbreaking
树搜索deepteam/attacks/multi_turn/tree_jailbreaking/tree_jailbreaking.py:60TreeJailbreaking / tree_search / expand_node
场景伪装deepteam/attacks/multi_turn/sequential_break/sequential_break.py:44SequentialJailbreak
Likert 评分诱导deepteam/attacks/multi_turn/bad_likert_judge/bad_likert_judge.py:38BadLikertJudge

相关章节: 攻击如何被生成与调度 → 02 攻击生成流水线;裁判与风险报告 → 04 编排与评测;全景与阅读地图 → index