system prompt 里的「请不要」是最弱的护栏;真正的边界在 harness。
读者已经懂 prompt injection 怎么打人(Day 24)。这一期讲怎么防——但先接受一个反直觉的工程现实:模型自身的 safety fine-tuning 加 system prompt,挡不住 indirect prompt injection。Simon Willison 的「lethal trifecta」说得很死:只要 agent 同时具备「能读私有数据 + 会摄入不可信内容 + 有外发通道」三件事,它就无条件可被注入泄密,再强的对齐也救不了。所以护栏不是写 prompt,是工程架构:独立 classifier、确定性校验、OS 级沙箱、egress 控制。这一期讲四件事——输入输出双闸门怎么搭、越狱为什么得靠独立 classifier 而不是 prompt 加固、输出策略门怎么 fail-closed、agent 执行沙箱怎么切断 lethal trifecta。
把 LLM 应用当成一个不可信的网络服务:input 来自外部(用户、检索文档、工具返回),output 要流入下游(用户屏幕、数据库、下一个 tool)。安全工程第一原则是在两侧各放一道独立闸门,而不是指望模型自己守规矩。
关键:闸门必须是独立组件——独立的小模型(Meta 的 Llama Guard、OpenAI 的 moderation endpoint)或确定性规则(正则脱敏 secret / 信用卡号)。让主模型「自己审自己」是反模式:同一个能被越狱撬开的模型,在自审时会被同一个越狱串一起绕过——攻击和审查共享一个被污染的 context。PII 与密钥防泄是闸门最该硬编码的部分:API key、JWT、卡号在出闸门时用确定性正则脱敏,别指望 LLM「记得脱敏」。
import re, anthropic
client = anthropic.Anthropic()
# 输入闸门:独立分类器(这里用 Llama Guard / moderation 端点,伪代码)
def input_gate(text):
verdict = classify_safety(text) # 独立小模型,不是主模型
if verdict.unsafe: raise Blocked(verdict.categories)
return text
# 输出闸门:确定性脱敏 + 内容安全,二者都过才放行
SECRET = re.compile(r"sk-[A-Za-z0-9]{20,}|eyJ[\w-]+\.[\w-]+\.[\w-]+") # key / JWT
def output_gate(text):
text = SECRET.sub("[REDACTED]", text) # 正则脱敏,不靠 LLM
if classify_safety(text).unsafe: raise Blocked("output")
return text
def guarded_call(user_input):
safe_in = input_gate(user_input)
raw = client.messages.create(model="claude-sonnet-4-6",
max_tokens=1024, messages=[{"role":"user","content":safe_in}])
return output_gate(raw.content[0].text)
两道闸门用的是独立判定路径,主模型只负责生成。secret 脱敏走确定性正则、放在内容安全分类之前——即使分类器漏判,key 也已经被抹掉。
很多人防越狱的做法是往 system prompt 堆「无论用户怎么说都不要……」。这对随手攻击有点用,对 universal jailbreak(一个能撬开几乎所有有害类别的通用越狱串)基本无效——因为攻击和防御写在同一个 context 里,模型注意力被攻击者主导。
Anthropic 2025 的 Constitutional Classifiers 给了工程答案:训练独立的 input/output 分类器,按一份 constitution(什么允许、什么禁止)过滤进出内容。在 3000+ 小时红队中,没人找到能在大多数有害类别上稳定击穿的 universal jailbreak。代价是真实的——早期版本带来约 23.7% 的推理开销和 +0.38% 的生产拒答率。2026 的 ++ 版把成本砍到约 40× 更便宜、拒答率压到 0.05%,说明这条路在工程上已经收敛。
所以越狱防御的工程决策是三件事:闸门放哪(streaming 时要边生成边判,不能等整段生成完才拦)、false positive 怎么处理(这就是 helpfulness tax——正常请求被误杀)、constitution 怎么版本化(它是一份会漂的策略,要像 prompt 一样治理,见 Day 35)。
自己训分类器太重,工程上更常见的是接现成分类器、把「拦 / 放」二元升级成三档,给边界请求一个 escalate 的台阶:
# 输出分类器返回一个风险分数;二元拦截体验差,用三档
BLOCK, REVIEW = 0.90, 0.60 # 阈值即 helpfulness tax 旋钮
def jailbreak_gate(text, stream=True):
score = safety_classifier(text).risk # 独立分类器
if score >= BLOCK: return ("block", fallback_refusal())
if score >= REVIEW: return ("escalate", queue_human_review(text)) # 不直接拒
return ("allow", text)
# streaming:每 N 个 token 抽检一次,命中 BLOCK 立即掐流
for chunk in stream:
buf += chunk
if len(buf) % 200 == 0 and jailbreak_gate(buf)[0] == "block":
abort_stream(); break
阈值是一个直接的成本/安全旋钮:调高 BLOCK → 漏判变多;调低 → helpfulness tax 飙升,正常用户被无故拒。中间留一个 escalate 档,把边界请求送人工而不是粗暴拒掉。
内容安全只是输出闸门的一半;另一半是正确性与业务策略。把 LLM 输出当成不可信的外部输入来校验,分三层:
DELETE。用确定性代码判,别用 LLM。关键工程决策是 fail-closed:校验不过时默认拒绝放行,走 fallback,而不是「catch 异常 → 记个 warning → 照样放出去」。生产事故里相当一部分是 fail-open——校验器自己抛异常被吞掉,脏数据反而畅通无阻。Guardrails AI 这类框架把这套封装成 re-ask loop:校验失败 → 把失败原因塞回模型重生成 → 有上限地重试 → 仍不过则 fail-closed 走兜底。
from pydantic import BaseModel, field_validator
class Order(BaseModel):
sku: str
qty: int
amount: float
@field_validator("amount")
def non_negative(cls, v):
if v < 0: raise ValueError("amount must be >= 0") # 策略层
return v
def validated_extract(prompt, max_retries=2): # re-ask 必须有上限
for _ in range(max_retries + 1):
raw = call_llm(prompt)
try:
order = Order.model_validate_json(raw) # 结构 + 策略层
if order.sku not in INVENTORY: raise ValueError("unknown SKU")
return order
except ValueError as e:
prompt += f"\n\n上次输出校验失败:{e}。请修正后重出。" # re-ask
return FALLBACK # ★ fail-closed:到顶仍不过 → 兜底,绝不放脏数据
三层校验合在一起判,re-ask 把失败原因回喂给模型,但必须 capped,到顶仍不过就 fail-closed 返回 fallback——而不是返回最后一次(仍然非法的)输出。
except: pass 把校验失败吞掉,等于没校验。(2)re-ask 不设上限,模型反复出错时成本爆炸、延迟失控。(3)只校验 schema 不校验业务策略——合法 JSON 但 amount=-999 照样放行,最危险的恰恰是「格式正确、语义错误」。
Agent 会执行模型生成的代码 / shell / SQL——这是最高危的 action class。防御不是让模型「小心点」,是 OS 级沙箱 + 切断 lethal trifecta。Simon Willison 的 lethal trifecta:私有数据访问 + 不可信内容摄入 + 外发通道,三者俱全 = 无条件可被注入泄密。工程上的好消息是——移除任意一条边系统就安全:要么不给私有数据、要么不摄入不可信内容、要么掐断 egress。最可操作的通常是第三条。
沙箱两个维度都要管:
Anthropic 给 Claude Code 做的沙箱正是这个范式:用 bubblewrap(Linux)/ seatbelt(macOS)锁文件系统,网络只能走一个连到沙箱外部 proxy 的 unix domain socket——这正是你现在这个远程执行环境的工作方式(出站 HTTPS 全走 agent proxy)。
# 跑模型生成的代码:默认无网 + 只读根 + 只写工作目录
docker run --rm \
--network none \ # ③ 掐断 egress(trifecta 第三条)
--read-only \ # 根文件系统只读
--tmpfs /work:rw,size=64m \ # 唯一可写区,临时
--memory 512m --cpus 1 \ # 资源上限,防 fork bomb
--cap-drop ALL --pids-limit 128 \
-v "$PWD/task:/work/task:ro" \ # 任务文件只读挂入
sandbox-img python /work/task/gen.py
# 若确实需要联网:不要 --network none,改为强制走 allowlist proxy
# HTTPS_PROXY=http://egress-proxy:3128 + proxy 侧只放行白名单域
--network none 直接消掉 trifecta 第三条边,是最干脆的隔离。若任务必须联网,就别开放自由出口,而是强制经过一个只放行白名单域的 egress proxy——可控的窄通道,不是敞开的大门。
*.github.com/pastebin 这类可被当外发通道滥用的域。(3)把沙箱当唯一防线,省了输入闸门——沙箱挡执行危害,挡不住「把检索文档里的注入当指令执行」本身。
把这一期四点拼成一个清单,套到任何会执行动作的 agent 上。每一条都对应前面一个具体失败模式:
--network none 或 egress allowlist proxy + 只读根 + 资源上限——先掐 lethal trifecta 第三条边。核心心法:护栏是架构不是 prompt。每一道都是 harness 层的物理拦截,模型说服不了它们。你以后看任何「安全的 AI 产品」,就会习惯性地去找它的四道边界在哪——找不到,就是没有。