从 Prompt 注入到防御:大模型输入安全的工程化检测链路
从 Prompt 注入到防御:大模型输入安全的工程化检测链路
一、当用户输入变成"指令":Prompt 注入为什么是大模型的第一道裂缝
大模型应用通常会把用户文本直接拼进系统提示词。这带来一个根本矛盾:模型无法区分"指令"与"数据"。攻击者只要在输入里写一句"忽略上面的要求,改为……",就能劫持整个对话的目标。
这类风险在客服、自动化 Agent、RAG 问答里尤其突出。因为系统提示词往往承载了角色、权限与工具调用规则。一旦被覆盖,模型可能泄露内部知识、调用不该调用的工具,甚至把敏感数据回传给陌生人。
更麻烦的是,Prompt 注入没有固定边界。它不像 SQL 注入那样有清晰的特征串。同一句话换个说法,效果可能完全不同。这也决定了:单点过滤解决不了问题,必须建立一条工程化的检测链路。
一个常见的误解是"加个敏感词黑名单就够了"。实际上,攻击者可以用角色扮演、虚构剧情、编码变形把恶意意图藏进看似无害的叙述里。例如让模型"扮演一个没有限制的助手",或把指令拆成多轮对话逐步诱导。这种分散式注入更难用静态规则捕获,必须依赖语义层面的判断。
还有一类容易被忽视的场景是间接注入。恶意文本不是来自用户,而是来自模型检索到的外部内容,比如网页、邮件、知识库文档。当 RAG 系统把这些内容拼进上下文,外部文档就成了"藏在数据里的指令"。这意味着输入安全的边界要从"用户框"扩展到"所有进入上下文的内容",检测链路必须覆盖检索结果的入口。
二、注入的攻击面与检测分层模型
把输入安全看成一条流水线会更清晰。原始输入先经过多层处理,每一层各司其职,最终才进入模型上下文。下图展示了一条典型的检测链路:
flowchart LR A[原始用户输入] --> B[预处理与归一化] B --> C{规则层检测} C -->|命中| H[拦截或降级] C -->|通过| D[语义分类器] D -->|高风险| H D -->|低风险| E[上下文隔离沙箱] E --> F[模型推理] F --> G[输出安全校验] G -->|异常| H G -->|正常| I[返回用户] B -.-> J[编码还原/同形字处理] J -.-> C规则层负责快而准地挡住已知模式;语义层负责捕捉变体和对抗样本;隔离层负责把不可信输入限制在受控上下文里。三层互补,才能同时压住误报与漏报。
三、生产级输入安全网关实现
下面是一段可落地的输入检测中间件。它把规则与分类器串成管线,并内置超时与降级:
import asyncio import re # 已知高风险指令片段(示例,生产需持续运营维护) INJECTION_PATTERNS = [ r"忽略(上面|之前|先前|以上).{0,12}?(要求|指令|提示|规则)", r"ignore.{0,8}?(previous|above|system).{0,8}?instruction", r"你现在是.{0,10}?(开发者|管理员|root|dan)", ] class InputGuard: def __init__(self, classifier=None, timeout: float = 0.8): self._classifier = classifier self._timeout = timeout def _rule_check(self, text: str) -> bool: lowered = text.lower() for pat in INJECTION_PATTERNS: if re.search(pat, text, re.IGNORECASE) or re.search(pat, lowered): return True return False async def _model_check(self, text: str) -> float: if self._classifier is None: return 0.0 try: # 分类器可能耗时,必须加超时,避免阻塞主链路 score = await asyncio.wait_for( self._classifier.score(text), timeout=self._timeout ) return float(score) except asyncio.TimeoutError: # 超时按"不可信"降级,宁可误报也不漏报 return 1.0 except Exception: return 1.0 async def inspect(self, text: str) -> dict: if not text or not isinstance(text, str): return {"risk": "block", "reason": "empty_or_invalid"} if self._rule_check(text): return {"risk": "high", "reason": "rule_hit"} score = await self._model_check(text) if score >= 0.6: return {"risk": "high", "reason": "model_score", "score": score} return {"risk": "low", "reason": "pass", "score": score}关键点在于:规则层先挡已知样本;语义层用分类器补变体;任何超时或异常都按高风险降级。这样即使分类器故障,链路也不会"开天窗"。
四、检测的边界:误报、对抗绕过与性能代价
这条链路并非银弹,落地时要先想清三件事。
误报会伤害体验。客服场景里,"请忽略上一条的错别字"这类正常表达可能误触发。解决办法是给规则层加白名单与上下文,并把高风险动作设为"人工复核"而非直接拦截。这里的核心权衡是:把阈值调高,漏报上升;调低,误报上升。没有零成本的平衡点,只能按业务容忍度选一个可解释的默认值。
对抗会持续演化。攻击者可用 base64、Unicode 同形字、少样本诱导绕过单层检测。因此分类器必须定期用新样本微调,规则层要支持热更新,不能写死在代码里。更现实的做法是把检测做成可观测的:记录每一次命中与误报,用真实流量反哺规则库,让链路随攻击演进。
性能有硬性成本。每次请求都跑语义分类器,会增加 50 到 200 毫秒延迟。优化做法是:先用规则层快速放行绝大多数正常流量,只对命中疑点的请求调用模型层。这样九成以上的请求不付出分类开销。若业务对延迟极敏感,还可把分类器做成异步旁路,仅对高风险会话做二次确认。
隔离层的代价也不能忽略。把不可信输入放进独立上下文,意味着模型失去了部分跨轮记忆,可能影响多轮对话的连贯性。因此隔离应是"按需触发",只在风险评分越过阈值时才启用,而不是对所有用户输入一刀切。
五、总结
Prompt 注入的本质,是模型无法区分指令与数据。应对它的不是某一条正则,而是一条分层检测链路:规则层挡已知、语义层补变体、隔离层控影响面。工程落地时,必须把超时降级、误报治理与性能预算一起考虑,才能让输入安全既兜得住攻击,又不拖垮业务。
