数据截至 (上游 commit 101f0313b0dd)
安全引擎:Policy / Rule / Action 护栏
30 秒导读: Upsonic 把"金融级安全"落在一层可插拔护栏上。用户输进来的话(入站)和 Agent 说出去的话(出站)都会先被扫一遍:命中敏感内容(信用卡、SSN、 病历……)就按你选的动作处理——直接放行、脱敏改写、替换占位符,或干脆拦截报错。本章讲这层护栏的内部:三段式模型
Policy/Rule/Action,内置的领域策略库,LLM 兜底判定与可逆脱敏,以及它如何挂进第 2 章那条 24 步管线。
1. 这是什么(零基础也能懂)
一句话定义: 安全引擎是一层"内容检查站",在 Agent 真正调用大模型之前检查用户输入、在把回答返回之后检查模型输出。
它解决什么问题。 生产环境里,你不希望:
- 用户把一整张信用卡号、身份证、病历粘进 prompt,然后被原样发去第三方大模型;
- 模型的回答里不小心带出了敏感数据、违规内容,直接呈给终端用户。
传统做法是在业务代码里到处写 if "信用卡" in text。Upsonic 把这件事抽象成可复用、可组合、可插拔的护栏对象,一行配置就能挂上。
用起来什么样。 建 Agent 时传一个(或一串)现成策略即可,剩下的管线自动处理:
# 示意,非源码
from upsonic import Agent
from upsonic.safety_engine import PIIAnonymizePolicy, FinancialInfoBlockPolicy
agent = Agent(
model="openai/gpt-4o",
user_policy=[PIIAnonymizePolicy, FinancialInfoBlockPolicy], # 入站护栏
agent_policy=PIIBlockPolicy, # 出站护栏
)
agent.do("我的卡号是 4111 1111 1111 1111,帮我查账单")
# → FinancialInfoBlockPolicy 命中信用卡,这次运行被拦下,模型根本不会被调用
一句话直觉。 把它想成机场安检:**规则(Rule)**是那台 X 光机——只负责"看出这里有没有违禁品、有多可疑";**动作(Action)**是安检员——决定"放行 / 没收 / 请你重新打包 / 直接报警";**策略(Policy)**就是"这台机器 + 这个安检员"的固定搭配。你要做的只是选一套搭配挂在门口。
2. 顶层全景(三段式怎么转)
安全引擎的骨架只有三个类,职责严格分离。先看它们怎么串起来处理一条文本:
PolicyInput(input_texts=[...])
│
▼
┌──────────────────── Policy ────────────────────┐
│ (name + 一个 Rule + 一个 Action + 语言/LLM 配置) │
│ │
│ ① rule.process(input) │
│ │ │
│ ▼ │
│ RuleOutput{ confidence, content_type, │
│ details, triggered_keywords } │
│ │ │
│ ▼ │
│ ② action.execute_action(rule_output, texts) │
│ │ 按 confidence + 动作类型决定怎么处理 │
│ ▼ │
│ PolicyOutput{ output_texts, │
│ action_output{action_taken,...}, │
│ transformation_map } │
└───────────────────────────────────────────────── ──┘
│
▼
动作:ALLOW / REPLACE / ANONYMIZE / BLOCK / (raise DisallowedOperation)
三个部件的一句话职责:
| 部件 | 干什么 | 在哪个文件 | 关键符号 |
|---|---|---|---|
Rule(规则) | 只做判定:扫文本,给出置信度和命中项,不改内容 | base/rule_base.py | RuleBase.process |
Action(动作) | 只做处置:放行 / 替换 / 脱敏 / 拦截 | base/action_base.py | ActionBase.action |
Policy(策略) | 把一条 Rule + 一个 Action 绑在一起,提供 check/execute | base/policy.py | Policy.execute |
为什么这样切。 判定和处置解耦后,同一个"信用卡检测规则"可以配不同动作:线上环境配"拦截",测试环境配"脱敏",审计环境配"替换成占位符"。内置策略库正是靠这种排列组合,用几个 Rule × 几个 Action 铺出上百个现成策略(见 §4)。
三类结果对象都在 models.py 里,是纯 Pydantic 数据类:
- 入口
PolicyInput(models.py:9):装input_texts以及可选的图/音/视频/文件,还有一个关键字段existing_transformation_map——多策略串行时用来传递已有的脱敏映射。 - 规则结果
RuleOutput(models.py:21):confidence(0~1)、content_type、details、triggered_keywords。 - 策略结果
PolicyOutput(models.py:30):output_texts(处理后的文本)、action_output(含action_taken)、transformation_map(脱敏还原表)。注意models.py:46里ActionOutput = PolicyOutput——两者是同一个类的别名。
3. 核心机制一:三段式的内部
3.1 Policy —— 只是个"绑定器 + 编排器"
Policy 本身很薄。构造时收下一个 rule、一个 action,外加语言和三个可选 LLM(语言识别、基础操作、文本查找),见 base/policy.py:15 Policy.__init__。
它对外只有两个动词:
check(policy_input)—— 只跑规则,拿RuleOutput(policy.py:67)。execute(policy_input)—— 先check再让动作处置,返回(rule_result, action_result, policy_output)三元组(policy.py:79)。
真实编排就这么 直白:
# base/policy.py:79 Policy.execute(节选)
rule_result = self.check(policy_input)
action_result = self.action.execute_action(
rule_result, policy_input.input_texts or [], self.language,
self.language_identify_llm, self.base_llm, self.text_finder_llm,
existing_transformation_map=getattr(policy_input, 'existing_transformation_map', None)
)
return rule_result, action_result, action_result
管线实际走的是异步版 execute_async(policy.py:90):它会优先调用 rule/action 各自的 *_async 方法,没有就用 asyncio.to_thread 把同步实现丢进线程池,既不阻塞事件循环,又不强迫每个自定义策略都实现异步——这是全套护栏"同步实现 + 异步外壳"的统一套路。
3.2 Rule —— 只判定,不改内容
RuleBase(base/rule_base.py:14)是抽象基类,唯一必须实现的是 process(policy_input) -> RuleOutput(rule_base.py:25)。它约定了规则只输出判断,绝不修改文本——这条纪律让规则可以随便组合、随便复用。
基类还预置了一个 LLM 兜底工具 _llm_find_keywords_with_input(rule_base.py:33):把输入拼成一段文本,先自动检测语言,再让"文本查找 Agent"抽取指定类型的敏感项。领域规则的 *_LLM_Finder 变体就靠它(见 §4.3)。
3.3 Action —— 五种处置,一个基类全给你
ActionBase(base/action_base.py:16)是护栏里代码量最大的一块,因为所有"怎么处置"的通用能力都沉淀在这里,子类只需在 action() 里挑一个调用。
入口是 execute_action(action_base.py:30):它先把 rule_result、原文、语言、各 LLM 存进实例,再解析目标语言(auto 时用 LLM 检测内容语言),最后调子类的 action()。基类提供的处置原语有五种:
| 处置原语 | 方法 | action_taken | 效果 |
|---|---|---|---|
| 放行 | allow_content (action_base.py:208) | ALLOW | 原样返回 |
| 拦截(带消息) | raise_block_error (action_base.py:232) | BLOCK | 用一段(可翻译的)消息替换输出,标记已拦截 |
| 替换占位符 | replace_triggered_keywords (action_base.py:257) | REPLACE | 把命中项统一换成如 [PII_REDACTED] |
| 可逆脱敏 | anonymize_triggered_keywords (action_base.py:324) | ANONYMIZE | 换成同格式随机值,并记录还原表 |
| 抛异常 | raise_exception (action_base.py:434) | 抛 DisallowedOperation | 中断整条链路 |
每种还有 *_LLM 变体,把固定消息交给 LLM 生成更贴合上下文的说辞(如 llm_raise_block_error,action_base.py:401)。
一个值得记住的细节:带类型前缀的命中项。 规 则产出的命中项形如 CREDIT_CARD:4111...。处置时基类会用 keyword.split(":", 1)[1] 剥掉类型前缀,只对真实值做替换(action_base.py:267);而纯检测标记 PII_KEYWORD:xxx 会被显式跳过(action_base.py:346),因为它只是"这里提到了信用卡"这种关键词命中,不是真值,没什么可脱敏的。
4. 核心机制二:内置策略库(挑 PII 与 Financial 看真章)
policies/ 下按领域切了十几个文件:pii、financial、crypto、phishing、fraud_detection、medical、legal、cybersecurity、insider_threat、tool_safety……每个文件的套路一模一样:一个正则规则 + 一个 LLM 规则 + 五个动作 → 组合出 7 个现成 Policy,最后在 safety_engine/__init__.py 里统一惰性导出(__init__.py:69 _get_policy_classes)。
看两个最能体现"金融级"的领域。
4.1 PII 规则:正则矩阵 + 加权置信度
PIIRule(policies/pii_policies.py:12)在构造函数里堆了一整套正则:邮箱、电话(美/国际/无区号多套)、SSN、信用卡、地址、生日、驾照、护照、IP、MAC, 外加一串 PII 关键词。
process(pii_policies.py:129)把所有输入拼成一段,逐类 re.findall,命中就打上类型前缀塞进 triggered_items,例如 EMAIL:[email protected]、SSN:123-45-6789。
真正体现"分级"的是加权置信度——不是"命中就 1.0",而是按敏感度打分:
# policies/pii_policies.py:208 (节选)
high_risk_count = len([... if any(x in item for x in ["SSN:", "CREDIT_CARD:", "PASSPORT:"])])
medium_risk_count = len([... if any(x in item for x in ["EMAIL:", "PHONE:", "ADDRESS:", "DOB:"])])
low_risk_count = len([... if "PII_KEYWORD:" in item])
confidence = min(1.0, (high_risk_count * 0.9 + medium_risk_count * 0.6 + low_risk_count * 0.3))
一个 SSN 就能把置信度顶到 0.9;而只是文本里出现"phone number"这个词(低危关键词)只加 0.3。
减少误报的巧思。 规则里专门维护了一批 false_positive_patterns(pii_policies.py:97),像 "email system""email server" 这种技术语境里的 "email",会被判为假阳性、不计入命中(pii_policies.py:191)。这就是"专业性":检测器知道"提到 email 这个词"和"贴出一个真邮箱地址"是两回事。
4.2 动作里的阈值门:0.3 起步
领域动作并不无脑处置,而是先看置信度。以 PIIBlockAction(pii_policies.py:268)为例:
# policies/pii_policies.py:275 action(节选)
if rule_result.confidence < 0.3:
return self.allow_content() # 太弱,放行
return self.raise_block_error(block_message) # 够强,拦截
0.3 这个阈值在每个动作里反复出现——它是"低危关键词单独命中(0.3)也刚好够门槛、但空命中(0.0)一定放行"的分界。换成 PIIAnonymizeAction(pii_policies.py:308)就是同样的门后面接 anonymize_triggered_keywords(),PIIReplaceAction(pii_policies.py:325)接 replace_triggered_keywords("[PII_REDACTED]")。
4.3 七种现成组合
文件末尾把规则和动作排列成 7 个开箱即用的 Policy 实例(pii_policies.py:382 起):
| 策略实例 | 规则 | 动作 | 语义 |
|---|---|---|---|
PIIBlockPolicy | PIIRule(正则) | Block | 命中就拦 |
PIIBlockPolicy_LLM | PIIRule | Block(LLM 消息) | 拦截,消息由 LLM 生成 |
PIIBlockPolicy_LLM_Finder | PIIRule_LLM_Finder | Block | 用 LLM 检测再拦 |
PIIAnonymizePolicy | PIIRule | Anonymize | 可逆脱敏 |
PIIReplacePolicy | PIIRule | Replace | 换占位符 |
PIIRaiseExceptionPolicy | PIIRule | RaiseException | 抛异常中断 |
PIIRaiseExceptionPolicy_LLM | PIIRule | RaiseException(LLM) | 抛异常,消息 LLM 生成 |
PIIRule_LLM_Finder(pii_policies.py:223)有个稳健设计:没配 text_finder_llm,或 LLM 调用抛错时,自动回落到正则版 PIIRule(pii_policies.py:236、262)——LLM 是增强,不是单点故障。
4.4 Financial 规则:同一套骨架,金融特化
FinancialInfoRule(policies/financial_policies.py:12)结构与 PII 如出一辙,但正则更专业:按卡种精确匹配 Visa/MasterCard/Amex/Discover(financial_policies.py:23),还有 CVV、IBAN、SWIFT、路由号、EIN/TIN 税号、余额/利率等财务语句,以及比特币/以太坊钱包地址(financial_policies.py:110)。
它的加权分级比 PII 多一档"critical",权重直接给到 1.0:
# policies/financial_policies.py:198 (节选)
critical_count = len([... if any(x in item for x in ["CREDIT_CARD:", "SSN:", "BANK_ACCOUNT:", "ROUTING_NUMBER:"])])
confidence = min(1.0, (critical_count*1.0 + high_risk_count*0.8 + medium_risk_count*0.6 + low_risk_count*0.3))
也就是说,单独一个信用卡号就足以让置信度到 1.0、越过所有动作阈值。同样导出 7 个现成策略(financial_policies.py:372 起,如 FinancialInfoBlockPolicy、FinancialInfoAnonymizePolicy)。
注意别和第 4 章的工具策略搞混:
policies/tool_safety_policies.py里的HarmfulToolBlockPolicy/MaliciousToolCallBlockPolicy是针对工具调用的护栏,由独立的ToolPolicyManager执行;本章讲的是针对文本消息的护栏。
5. 核心机制三:LLM 判定与可逆脱敏
正则能抓"长得像敏感数据"的东西,但抓不住"语义上敏感"的表达。安全引擎为此配了一个 LLM 侧车,和一套可逆脱敏工具。
5.1 UpsonicLLMProvider —— 护栏专用的小 Agent 封装
UpsonicLLMProvider(llm/upsonic_llm.py:75)内部就是包了一个 Upsonic Agent,对每种任务用结构化输出(Pydantic response_format)约束返回:
| 能力 | 方法 | 结构化返回 |
|---|---|---|
| 抽取敏感项 | find_keywords (upsonic_llm.py:85) | KeywordDetectionResponse |
| 生成拦截消息 | generate_block_message (upsonic_llm.py:149) | BlockMessageResponse |