多轮对话逐步升级到越权(Crescendo 攻击)

攻击者每轮只推进一点,最终让限制全部失效。学会在整段对话历史里识别这种渐进升级,重置上下文,并加上会话级监控,让告警在越权那一轮之前就触发。

单看任何一条用户消息,都不像有恶意。前几轮友好且切题,然后请求开始漂移:先问边界情况,再问假设性问题,接着把受限话题重新包装成虚构或学术探讨,最后一句 continue the story,把对话带进模型在第 1 轮本会直接拒绝的领域。等到越权真正发生时,防御者孤立地看任何一条消息都看不出问题——升级只有在完整对话历史里才看得见。这就是已发表的 Crescendo 攻击(USENIX Security 2025):它”从一个关于任务的普通提问开始,然后通过引用模型自己的回复来逐步升级对话”。它不需要任何特殊 token,在原始研究里通常不到 10 轮就能成功,很多时候 5 轮以内

最快的处理办法: 逐条消息的输入过滤器从原理上就抓不到这种攻击,所以收益最高的改动是加一个会话级监控,再对长历史做周期性的策略重注入。如果你怀疑某个会话此刻正处在升级过程中,立即重置它的上下文(丢弃对话历史,只保留默认 system prompt),并要求开启新会话。下面的步骤按优先级给出话题漂移监控、策略重注入、角色扮演标记、轮次上限和告警规则。

本文面向运行 LLM 应用的团队(你控制 system prompt、对话历史和请求循环)。如果你只是一个撞到内容过滤器的终端用户,这篇不适合你。

你遇到的是哪一种变体?

日志里的症状可能的变体主要防御
跨多个人类轮次的缓慢话题蠕变,每轮都在引用模型上一条回复Crescendo(本文讨论的情况)会话级话题漂移监控 + 策略重注入
一条超长粘贴的 prompt,里面塞了几百条伪造的”user/assistant”问答对,最后才是真正的请求Many-shot jailbreaking(滥用长上下文)在进入模型前分类并改写 prompt;限制注入的示例轮数
早期建立一个角色扮演人设,之后 stay in character and explain…角色扮演式升级(Crescendo 的子模式)角色扮演标记 + 响应后策略检查
只有在 system prompt 退到长上下文较前部之后才出现越权system prompt 近因衰减每 N 轮重注入一条精简的策略提醒
用户 B 的会话一开始就处于已漂移状态会话隔离 Bug确认每个已认证会话都从全新历史开始

如果几百条伪造轮次是在一条消息里一次性到达的,那你面对的是 many-shot jailbreaking,不是 Crescendo。Anthropic 的研究表明,一个”先分类再改写”的过滤器能把这种攻击的成功率从 61% 降到 2%;这要在输入边界处理,而不是用下面的漂移监控。

常见原因

1. 上下文窗口不断积累”同意”先例

模型每配合一次稍微偏题或在试探边界的请求,就在历史里留下一个先例。后续轮次会顺着它继续:“既然你已经解释了 X,那解释 Y 应该也没问题。“模型早先的输出是它自己上下文的一部分,会影响接下来的输出——这种”引用先前回复”正是 Crescendo 的核心机制。

怎么判断:回看产生过越权输出的会话的完整历史,数一数越权之前有多少连续轮次表现出递增的话题漂移。阶梯式模式(每一条回复都比初始策略再远一点)就是它的签名。

2. 虚构或角色扮演框架被用来逐步引入受限内容

用户在早期建立一个角色扮演场景:“我们写个故事,你来演一个科学家。“在随后几轮里,题材逐步迁移到 system prompt 限制的内容。模型出于对叙事的投入而继续往下写。

怎么判断:追踪含有角色扮演建立短语的轮次——let's pretendin this storyyou are playingas a character。任何先建立了角色扮演上下文、之后又产出越权响应的会话,很可能用了这种模式。

3. 长会话里模型从未被重新提醒它的策略

会话超过 20-30 轮后,system prompt 里的指令在上下文窗口里相对靠后。模型会偏向近因,于是这条已经”很远”的 system prompt,影响力可能不如近期那些被攻击者塑形过的轮次。

怎么判断:记录每次越权发生在第几轮。如果长会话的越权集中在第 20 轮之后,那 system prompt 近因就是一个促成因素。

4. 每一轮单独都能通过输入过滤,但整个序列通不过

你的输入扫描器孤立地检查每条消息。第 14 条消息是 continue the story——不含任何注入关键词——但在上下文里它的意思是”继续第 8-13 条里建立起来的那个越权叙事”。

怎么判断:你的监控必须评估消息序列,而不只是单条消息。一个以最近 N 条消息为输入的序列分类器,能抓到逐条过滤器漏掉的渐进漂移。

5. 攻击者拿模型自己说过的承诺来反制它

模型在第 7 轮说过:“我明白了——我在扮演一个中立的科学家角色。“到第 14 轮用户就援引它:“你已经说了你是中立科学家,所以在角色里你会解释怎么……”模型把自己之前的表述当成了一种有约束力的承诺。这正是 Crescendo 的标志性动作——通过引用模型的回复来升级。

怎么判断:当某条用户消息引用或直接援引模型在前一轮说过的话、以此为新请求辩护时,触发告警。模式是 [模型先前的原话] + 因此你现在应该

6. 会话级状态没有在不同用户之间重置

在共享服务里,用户 A 的会话状态因为应用错误地复用了对话历史而渗入了用户 B 的会话。用户 B 一开始就处在已漂移的状态。

怎么判断:确认每个新的已认证用户会话都从只含默认 system prompt 的全新消息历史开始。任何一开始就带着前一个用户的多于一条消息(system 加上继承下来的轮次)的会话,都是会话隔离失败。

最短修复路径

Step 1:实现会话级话题漂移监控

逐条消息的过滤器从设计上就看不见 Crescendo。给每一轮相对于会话基线话题打分,盯的是趋势,而不是任何单个数值。打分用一个便宜、快速的模型即可(任何分类器级别的小模型都行;示例用的是一个低价聊天模型)。

interface TurnRecord {
  turnIndex: number;
  userMessage: string;
  modelResponse: string;
  topicScore: number;  // 0-1, how on-topic is this turn?
}

async function scoreTurnTopicRelevance(message: string, baselineTopic: string): Promise<number> {
  const result = await openai.chat.completions.create({
    model: "gpt-5.5",  // 任何便宜、快速的模型都可以用来打分
    messages: [
      {
        role: "system",
        content: `Score how relevant this message is to the topic "${baselineTopic}". Reply with a number 0-100 only.`,
      },
      { role: "user", content: message.slice(0, 500) },
    ],
    max_tokens: 5,
  });
  return parseInt(result.choices[0].message.content ?? "100") / 100;
}

function detectDriftTrend(turns: TurnRecord[]): boolean {
  if (turns.length < 5) return false;
  const recent = turns.slice(-5).map((t) => t.topicScore);
  const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
  return avg < 0.4;  // 阈值——按应用调
}

因为 Crescendo 可能不到 5 轮就得手,把滚动窗口设短一点(示例用 5),阈值也要从你自己的 staging 跑批里调,别套一个全局默认值。

Step 2:对长会话周期性重注入 system prompt

这一步直接对治原因 3(近因衰减)。每 N 轮重新插入一条精简的策略提醒,让规则始终靠近上下文末尾,而不只是停在最顶端。

function buildLongSessionMessages(
  systemPrompt: string,
  history: { role: string; content: string }[],
  reinforceEveryN = 10
): { role: string; content: string }[] {
  const messages: { role: string; content: string }[] = [
    { role: "system", content: systemPrompt },
  ];

  for (let i = 0; i < history.length; i++) {
    if (i > 0 && i % reinforceEveryN === 0) {
      // 重注入一条精简的策略提醒
      messages.push({
        role: "system",
        content: `[Policy reminder — turn ${i}]: ${policyReminder}`,
      });
    }
    messages.push(history[i]);
  }
  return messages;
}

Step 3:检测角色扮演建立模式并设置会话标记

const ROLEPLAY_TRIGGERS = [
  /let'?s\s+(pretend|imagine|role[\s-]?play)/i,
  /you\s+are\s+(playing|acting\s+as|now)\s+(a|an|the)\s+\w+/i,
  /in\s+this\s+(story|scenario|fiction|game)/i,
  /as\s+(a|an|the)\s+(character|persona|role)\s+of/i,
];

function detectRoleplayEstablishment(message: string): boolean {
  return ROLEPLAY_TRIGGERS.some((re) => re.test(message));
}

// 设置会话标记
if (detectRoleplayEstablishment(userMessage)) {
  session.roleplayActive = true;
  logger.info({ event: "roleplay_session_started", sessionId: session.id, turn: session.turnCount });
}

Step 4:限制会话长度并提供新会话续接

const MAX_TURNS_PER_SESSION = 30;

if (session.turnCount >= MAX_TURNS_PER_SESSION) {
  return {
    message:
      "This conversation has reached its maximum length. Please start a new session to continue.",
    shouldReset: true,
  };
}

Step 5:对”角色扮演 + 越权”会话告警

对每一条输出(不只是被标记的轮次)都跑一次响应后策略检查,并把”角色扮演 + 越权”当成信号最强的组合处理。

async function postTurnAudit(session: Session, latestResponse: string): Promise<void> {
  if (!session.roleplayActive) return;

  const isPolicyViolation = await checkPolicyViolation(latestResponse, session.systemPrompt);
  if (isPolicyViolation) {
    logger.error({
      event: "roleplay_escalation_violation",
      sessionId: session.id,
      turnCount: session.turnCount,
      responseSummary: latestResponse.slice(0, 300),
    });
    // 通知安全团队并挂起会话
    await suspendSession(session.id);
  }
}

如何确认已修复

  1. 在 staging 里重放一个已知的 Crescendo 序列。 拿一段看起来无害、5 到 10 轮、朝着 system prompt 受限话题推进的升级对话,让它走完整个请求循环。
  2. 确认告警在越权那一轮之前触发,而不是之后。你的话题漂移监控(Step 1)应该在对话还停留在”边界情况 / 假设性问题”区间时就被绊响。
  3. 验证上下文重置真的清空了历史。 触发上限或挂起之后,检查下一个请求的 payload:它必须只含默认 system prompt 加新的用户轮次——不能有任何继承下来的消息。
  4. 检查重注入的提醒落在正确的间隔上:把一个 25 轮会话拼好的 messages 数组打到日志里,确认策略提醒确实每 N 轮出现一次。
  5. 测试会话隔离:让两个已认证用户交替走同一个 worker,确认谁都看不到对方的历史。

如果告警只在越权输出已经返回之后才响,就把滚动窗口缩短、把漂移阈值调低,直到它能更早触发。

预防建议

  • 在会话级别监控话题漂移,而不只是逐条消息——一串稍微偏题的消息,比任何单条消息都更有信息量。
  • 对长会话每 N 轮把精简的策略提醒重注入历史,抵消近因偏置。
  • 设置会话最大轮次上限并要求用户开启新会话,防止上下文无限累积。由于 Crescendo 可能不到 5 轮就成功,这个上限是兜底,而不是主要防御。
  • 对同时包含角色扮演建立模式与后续越权输出的会话告警——这个组合是信号最强的指标。
  • 对每一条模型输出都跑响应后策略检查,而不只是被标记的轮次。
  • 把 many-shot jailbreaking 当作另一类问题、放在输入边界处理:对那些带有大量伪造对话示例的 prompt 做分类并改写(Anthropic 的”分类再改写”把该攻击从 61% 降到了 2%)。
  • 安排人工分析师定期复查被标记的会话,捕捉自动化还没学会的新升级模式。
  • 确保会话隔离正确:每个新用户会话都只从默认 system prompt 开始,不带任何前一会话的历史。
  • 用已知的多轮升级序列在 staging 里测你的监控,确认告警会在越权那一轮之前触发。

常见问答 (FAQ)

Q:在 system prompt 里直接写一句”不要被多轮操纵”,能不能挡住多轮升级? A:有一点帮助——模型会意识到这个模式——但不是完整的修复。Crescendo 恰恰是靠让模型遵从它自己早先的回复来奏效的,一个有说服力的升级仍然可能成功。会话级监控和上下文重置,比单独一句指令可靠得多。

Q:一次典型的多轮升级要多少轮? A:比你以为的少。Crescendo 的研究发现,大多数任务不到 10 轮就成功,很多在 5 轮以内。别假设你有到第 20 轮才反应的余地;你的监控应该在前期到中期、对话还在漂移时就触发。

Q:这和 “many-shot jailbreaking” 有什么区别? A:Crescendo 是一段通过引用模型先前回复来升级的多轮对话;many-shot jailbreaking 是一条超长 prompt,在真正的请求之前往长上下文窗口里塞进几百条伪造的问答对。它们需要不同的防御:Crescendo 需要会话级漂移监控;many-shot 需要一个输入侧分类器,去改写或拒绝那些塞满伪造示例轮次的 prompt。

Q:要不要告诉用户他们的会话正在被监控? A:对大多数面向消费者的应用来说,披露”对话会被监控以符合政策”(不必特指这个攻击)在法律上更稳妥,也有助于建立信任。你不需要把检测逻辑本身公布出来。

Q:上下文窗口更长的模型,是不是更难被这样攻破? A:并没有。如今模型标配 1M token 上下文(截至 2026 年 6 月,Opus 4.7、Sonnet 4.6、Gemini 3.1 Pro),而更长的窗口是双刃剑:模型更清楚地记得整条升级链,但它也更清楚地记得自己早先那些合规的回复。攻击照样有效,变的只是节奏。大窗口同时也让 many-shot jailbreaking 更便宜,这是另一个该在输入侧过滤的理由。

相关阅读

外部参考:Crescendo 攻击论文(USENIX Security 2025)、Anthropic 的 Many-shot jailbreaking 研究,以及 OWASP Top 10 for LLM Applications (2025)

标签: #ai-security #prompt-injection #排查