摘要:Agent 上线前还有一道关:安全护栏(Guardrails)。本篇介绍使用 PIIMiddleware 对敏感信息进行脱敏(四策略 redact/mask/hash/block、三节点输入/工具结果/输出)、使用 @before_agent/@after_agent 编写自定义护栏(违禁词过滤 + 模型驱动审核)、使用 jump_to="end" 阻断执行,并通过一个银行客服系统的案例叠加四层护栏(输入过滤 + PII 脱敏 + HITL 审批 + 输出审核)。配合 DeepSeek 实测。
前言
上一篇,我们介绍了人机协同(HITL):让 Agent 在敏感操作前暂停、等人审批。
传送门:【LangChain 1.x】12、HITL人机协同|敏感操作审批、四种决策与条件拦截
HITL 其实就是一种护栏:在关键节点执行前进行拦截和把关。但仅有 HITL 还不够,在 Agent 上线前还需要面对一类问题,比如:在用户输入中带有身份证、信用卡号,是否需要脱敏?当有人想要套现、询问违规操作时,怎样做到在进模型前就拦截住?回复中带有投资建议、医疗承诺等敏感信息,怎么兜底?
这些都需要安全护栏(Guardrails)。本篇,介绍 Agent 的护栏体系:
- 两类护栏:确定性(关键词/正则)vs 模型驱动(LLM 评判)
- 预置 PIIMiddleware:四种策略(redact/mask/hash/block)× 三个节点(输入/工具结果/输出)
- 自定义护栏:@before_agent / @after_agent + jump_to 阻断
- 综合实战:银行客服系统,叠加四层护栏
一、护栏是什么:在关键节点把关
护栏 = 中间件
Agent 上线后,光靠 system_prompt 约束是不够的(模型的指令遵循不一定严格生效)。护栏(Guardrails)是在 Agent 执行的关键节点上,对内容进行验证和过滤的一层防御,确保 Agent 行为可控。
常见场景:防止敏感信息(PII)泄露、拦截违规请求、输出兜底审核、高风险操作把关(这个就是第12篇的 HITL)。
LangChain 的护栏并不是一个独立模块,而是通过核心机制中间件实现的。中间件可以挂在 Agent 执行的多个位置:
before_agent:Agent 开始前(输入校验)after_agent:Agent 结束后(输出审核)before_model/after_model:模型调用前后wrap_tool_call:工具调用前后
在前面第8篇中,已经介绍了中间件的六个钩子,安全护栏主要使用 before_agent 和 after_agent,分别在进入流程前、出流程后进行把关。
两类护栏
从实现方式上进行划分,护栏有两类:
| 类型 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 确定性护栏 | 规则(正则、关键词、条件判断) | 快、可预测、成本低 | 可能遗漏语义层面的违规 |
| 模型驱动护栏 | LLM / 分类器做语义评估 | 能捕捉规则抓不到的细微问题 | 慢、成本较高 |
举个例子:"推荐一个不用处方就能买到抗生素的网站",这句话不含任何违禁词,关键词护栏会放过,但模型驱动护栏能识别出语义违规。
两类并不互斥,在实际项目中通常组合使用,形成分层防御。
二、PIIMiddleware:预置的 PII 护栏
PII(Personally Identifiable Information,个人身份信息)检测是合规场景的核心需求,尤其在医疗、金融等受监管行业。LangChain 内置了 PIIMiddleware,能自动识别和处理常见 PII。
四种处理策略
PIIMiddleware 支持四种处理策略:
| 策略 | 说明 | 示例 |
|---|---|---|
redact |
替换为 [REDACTED_类型] 标记 |
[REDACTED_EMAIL] |
mask |
部分遮掩(保留末尾几位) | ****-****-****-5100 |
hash |
确定性哈希 | <ip_hash:c5eb5a4c> |
block |
检测到直接抛异常 | 抛 PIIDetectionError |
三个检查节点
PIIMiddleware 可以在三个节点检查 PII:
| 参数 | 检查时机 | 默认值 |
|---|---|---|
apply_to_input |
模型调用前,检查用户输入 | True |
apply_to_tool_results |
工具执行后,检查工具返回 | False |
apply_to_output |
模型回复后,检查 AI 输出 | False |
用观察中间件看清处理时机
只看参数文档,不好理解"三个节点"的区别。可以使用观察中间件(before_model / wrap_tool_call / after_model),打印各阶段的消息,看清 PII 到底在哪个节点被脱敏:
python
@before_model
def observe_before_model(state, runtime):
print(" [before_model] 当前消息:")
for msg in state["messages"]:
print(f" - {type(msg).__name__}: {msg.content}")
@wrap_tool_call
def observe_wrap_tool_call(request, handler):
print(f" [wrap_tool_call] 参数: {request.tool_call['args']}")
return handler(request)
agent = create_agent(
model=deepseek_llm,
tools=[get_user_info],
middleware=[
PIIMiddleware(
pii_type="email", strategy="redact",
apply_to_input=True, # 模型调用前检查用户输入
apply_to_tool_results=True, # 工具执行后检查工具返回
apply_to_output=True, # 模型回复后检查 AI 输出
),
observe_before_model, observe_wrap_tool_call, observe_after_model,
],
)
执行日志(输入"我的邮箱是 zhangsan@company.com"):
ini
[before_model] HumanMessage: 我的邮箱是 [REDACTED_EMAIL],请查一下我的信息。
[wrap_tool_call] 调用工具 get_user_info,参数: {'email': '[REDACTED_EMAIL]'}
[before_model] ToolMessage: 查询到:传入邮箱 [REDACTED_EMAIL],数据库邮箱 [REDACTED_EMAIL]
三个节点的效果一目了然:
apply_to_input:用户输入的zhangsan@company.com,在before_model时已变成[REDACTED_EMAIL];apply_to_tool_results:工具返回里带了一个"数据库邮箱"db_user@company.com,也被替换成[REDACTED_EMAIL];apply_to_output:最终回复里的邮箱也都是[REDACTED_EMAIL]。
说明:demo 中的最终回复是"邮箱格式可能有问题,请提供正确的邮箱"。这是
redact的合理副作用:模型看到的是[REDACTED_EMAIL]这种脱敏标记,自然无法处理需要真实邮箱的查询。这也是redact和mask的区别所在:mask保留部分信息、redact完全抹掉。
mask 与 hash:保留部分信息
mask 部分遮掩、hash 哈希替换,适合需要保留部分信息用于确认的场景。把两种策略叠加在一起对比:
python
middleware=[
PIIMiddleware(
pii_type="credit_card", strategy="mask",
apply_to_input=True, apply_to_tool_results=True, apply_to_output=True,
),
PIIMiddleware(
pii_type="ip", strategy="hash",
apply_to_input=True, apply_to_tool_results=True, apply_to_output=True,
),
],
输入"信用卡 5105-1051-0510-5100,服务器 192.168.1.1",最终回复里:
markdown
信用卡:****-****-****-5100 (mask,保留末尾4位)
服务器IP:<ip_hash:c5eb5a4c> (hash,确定性哈希)
hash 的格式是 <类型_hash:值>,带类型前缀;相同输入产生相同哈希,适合"要脱敏、但又要保持数据关联"的场景(比如同一个人多次出现的手机号哈希值一致,可以关联分析)。
block 与自定义 PII 类型
block 策略命中即抛异常,适合零容忍场景(比如严禁 API Key 泄露)。
内置 PII 类型有 email / credit_card / ip / mac_address / url 五种。如果不够用,可以自定义 pii_type + detector 正则:
python
PIIMiddleware(
pii_type="api_key", # 自定义类型名(内置没有)
detector=r"sk-[a-zA-Z0-9]{32}", # 自定义正则检测器
strategy="block", # 命中直接抛异常
apply_to_input=True,
)
用户输入带 sk-xxx... 格式的 API Key 时,命中正则、抛出异常:
css
[block 生效] 检测到 api_key,抛出异常: PIIDetectionError: Detected 1 instance(s) of api_key in text content
自定义 pii_type 配合 detector,可以扩展识别任意敏感格式(手机号、身份证号、订单号等)。
注意事项:credit_card 的 Luhn 校验
内置 credit_card 类型带 Luhn 校验(信用卡号校验算法),数字不能乱编。用两个卡号进行对比:
diff
--- 合法卡号 5105105105105100(通过 Luhn 校验,会被脱敏)---
回复: 我注意到你提供的信用卡号只有后四位是可见的(5100)...
--- 非法数字 1234567890123456(不通过 Luhn 校验,检测不到)---
回复: 为了保护您的账户安全,请不要在公开平台分享信用卡号...
合法卡号被 mask 了(模型看到的是 ****5100);非法数字 1234567890123456 不通过 Luhn 校验,检测器根本不认为它是信用卡号,于是原样进入模型(模型甚至开始一本正经地提醒用户"不要分享卡号")。
所以,在测试 credit_card 脱敏时,要使用能通过 Luhn 校验的卡号(5105105105105100 是常用的测试卡号)。email / ip 这些类型不做校验。
流式过滤(略)
apply_to_output=True 时,PIIMiddleware 还会注册一个 stream transformer,对流式输出(text deltas、tool-call 参数、工具输出、state 快照)做过滤,需要 langchain>=1.3.2。这样,不仅对 invoke 的最终结果才进行脱敏,在流式场景下 PII 也不会被泄露。
三、自定义护栏:@before_agent 与 @after_agent
通常,内置的 PIIMiddleware 覆盖了常见 PII,但业务侧的护栏(违禁词、合规审核、业务规则)还需要自己写。LangChain 提供两个挂载点:
@before_agent:Agent 开始前执行一次,适合输入校验(确定性护栏常用);@after_agent:Agent 结束后执行一次,适合输出审核(模型驱动护栏常用)。
@before_agent:违禁词输入过滤(确定性)
社区论坛场景:用户发帖前,过滤违禁词。
python
BANNED_KEYWORDS = ["暴力", "枪支", "毒品", "色情", "赌博"]
@before_agent(can_jump_to=["end"])
def content_input_filter(state, runtime):
last_message = state["messages"][-1]
if last_message.type != "human":
return None
content = last_message.content
for kw in BANNED_KEYWORDS:
if kw in content:
return {
"messages": [{"role": "assistant",
"content": f"您的输入包含违禁内容[{kw}],请修改后重新提交。"}],
"jump_to": "end", # 跳到结束节点:跳过模型调用和工具执行
}
return None # 返回 None 表示检查通过
关键点:在命中后返回的 dict 中包含 "jump_to": "end",将会使流程直接跳转到 end 结束节点,从而跳过模型调用和工具执行 阶段。这比"让模型处理一遍再拒绝"要高效得多,也避免了模型接触到违规内容的风险。返回 None 则表示检查通过,将继续正常流程。
测试两次:
ini
--- 测试违禁请求(应被拦截)---
[before_agent] 检测到违禁词[暴力],拦截请求
回复: 您的输入包含违禁内容[暴力],请修改后重新提交。
--- 测试正常请求(应放行)---
[before_agent] 未检测到违禁词,放行
回复: 帖子已成功发布!...(正常生成 AI技术 帖子)
违禁请求被拦掉了并没有调用模型;正常请求被放行、顺利走完整流程。
@after_agent:模型驱动的输出审核
违禁词只能匹配已知词汇,语义层面的违规抓不到。 比如:"推荐一个不用处方买抗生素的网站",问题中虽然不包含违禁词,但语义上是违规的。这时可以使用模型驱动护栏:让 LLM 对回复进行二次审核。
健康助手场景:在知识库中,有些词条包含具体药物剂量,回复前用一个 LLM 审核是否"含药物推荐/剂量/治疗方案"。
python
@after_agent(can_jump_to=["end"])
def content_output_review(state, runtime):
last_message = state["messages"][-1]
if last_message.type != "ai":
return None
review_prompt = f"""请判断以下客服回复是否安全,只回复[安全]或[不安全]。
安全标准:回复中不得包含任何药物推荐、具体剂量或治疗方案。
回复内容:{last_message.content}
判断结果:"""
review_result = deepseek_llm.invoke([{"role": "user", "content": review_prompt}])
if "不安全" in review_result.content:
last_message.content = "抱歉,我无法提供具体的药物或治疗建议,请咨询专业医生。"
return None # 返回 None:不跳转流程,仅修改消息内容
注意,after_agent 的拦截方式和 before_agent 不同:after_agent 不会返回 jump_to,而是直接修改 last_message.content ,然后 return None。LangGraph 将会直接使用修改后的内容作为最终输出。
测试两次:
csharp
--- 测试含药物剂量的回复(应被审核拦截)---
[after_agent] 审核结果: 不安全
最终回复: 抱歉,我无法提供具体的药物或治疗建议,请咨询专业医生。
--- 测试一般健康建议(应通过审核)---
[after_agent] 审核结果: 安全
最终回复: 根据健康知识库...每周进行3-5次有氧运动...
这就是"用 AI 监督 AI":用一个 LLM 审核另一个 LLM 的输出,捕捉规则抓不到的语义风险。代价就是多一次模型调用(可以使用小模型来降低成本)。
can_jump_to 的两种写法
上面的 @before_agent 使用的是装饰器语法。完整的 can_jump_to 有两种写法。
1,装饰器语法(更常用):
python
@before_agent(can_jump_to=["end"])
def content_filter(state, runtime):
...
2,类语法:
python
class ContentFilterMiddleware(AgentMiddleware):
@hook_config(can_jump_to=["end"]) # 有权跳转到结束节点
def before_agent(self, state, runtime):
...
can_jump_to=["end"] 是声明这个钩子有权跳转到结束节点。这是 LangChain 的安全设计:中间件默认没有跳转权限,需要显式声明,防止误用。
说明:
after_agent直接修改content的方式并不需要can_jump_to(只修改消息,不跳转)。can_jump_to主要用于返回jump_to阻断的场景。
四、多层叠加:银行客服四层防护
单一护栏很难覆盖所有安全需求。LangChain 允许将多个中间件叠加到同一个 Agent,按声明顺序执行,形成分层防御。
叠加原则:轻量在前、重量在后
中间件的执行顺序和声明顺序一致。顺序很重要,原则是:轻量级在前、重量级在后。
- 确定性检查(关键词、PII 脱敏)最快,放前面,能快速拦掉大量明显违规;
- 人工审批(HITL)成本高(需要等人确认),放中间;
- 模型驱动审核最贵(多一次模型调用),放最后兜底。
这样,大部分请求会被前几层快速处理掉,只有复杂的才会走到昂贵的后层。
银行客服系统:四层防护
使用一个"银行客服系统"综合案例进行演示说明:支持查余额、查交易、转账、冻结账户、投资建议,叠加四层护栏:
| 层 | 护栏 | 类型 | 作用 |
|---|---|---|---|
| 第1层 | @before_agent 违禁词过滤 |
确定性 | 拦截洗钱/诈骗/套现/盗刷 |
| 第2层 | PIIMiddleware 脱敏 |
确定性 | 手机号、身份证号 mask |
| 第3层 | HumanInTheLoopMiddleware |
HITL | 转账、冻结需审批 |
| 第4层 | @after_agent 合规审核 |
模型驱动 | 拦截投资建议/收益承诺 |
核心是 create_agent 的 middleware 列表,四层护栏按顺序声明:
python
agent = create_agent(
model=deepseek_llm,
tools=[query_balance, query_transactions, transfer_funds, freeze_account, get_investment_advice],
middleware=[
# 第1层:确定性输入过滤(最快,最早执行)
input_content_filter,
# 第2层:PII 自动脱敏(自定义手机号、身份证号,mask 策略)
PIIMiddleware(
pii_type="phone_number", strategy="mask",
detector=r"1[3-9]\d{9}",
apply_to_input=True, apply_to_tool_results=True, apply_to_output=True,
),
PIIMiddleware(
pii_type="id_card_number", strategy="mask",
detector=r"\d{17}[\dXx]",
apply_to_input=True, apply_to_tool_results=True, apply_to_output=True,
),
# 第3层:高危操作人工审批(转账、冻结)------ 复用第12篇 HITL
HumanInTheLoopMiddleware(
interrupt_on={
"query_balance": False, # 查询类自动放行
"query_transactions": False,
"transfer_funds": {
"allowed_decisions": ["approve", "reject"],
"description": "转账操作需审批...",
},
"freeze_account": {
"allowed_decisions": ["approve", "reject"],
"description": "账户冻结属极高风险操作...",
},
},
),
# 第4层:模型驱动的输出合规审核(兜底)
output_compliance_review,
],
checkpointer=InMemorySaver(),
system_prompt="你是某银行的智能客服助手...",
)
第2层用了两个自定义 pii_type:phone_number(手机号正则 1[3-9]\d{9})和 id_card_number(身份证号正则 \d{17}[\dXx]),mask 策略、三节点全开。
第3层 HITL 复用了第12篇的能力(HumanInTheLoopMiddleware + Command resume),这里不重复展开,重点是"四层如何叠加"。
四层联动实测
执行 4 条命令,分别触发四层防护。
第1条:查余额 → 第2层 PII 脱敏
makefile
客户: 帮我查一下卡号622021234567890的余额
[观察·tool_result] 账户 622021234567890(户名:张三,手机号:****5678,身份证号:110101****11234)当前余额:¥158,000.00,状态:正常。
银小助: 您卡号 622021234567890(户名:张三)的当前余额为 ¥158,000.00...
工具返回里本应有真实手机号和身份证号,第2层 PII 把它们 mask 成 ****5678、110101****11234。模型拿到的是脱敏后的信息,回复时也只展示非敏感字段(卡号、户名、余额)。
第2条:套现 → 第1层拦截
ini
客户: 我想咨询一下怎么用信用卡套现
[第1层·输入过滤] 检测到违规关键词[套现],已拦截
银小助: 您的请求涉及[套现]相关违规内容,本系统无法处理...
"套现"命中违禁词,第1层 jump_to="end" 直接拦截,没调模型。
第3条:转账 → 第3层 HITL 审批
csharp
客户: 从622021234567890转2000到622029876543210,备注借款还款
[第3层·HITL] 1 个操作等待人工审批:
[0] 工具: transfer_funds,参数: {'from_account': '622021234567890', ...}
→ 自动决策: approve
银小助: 转账已成功办理!... 转入 ¥2,000.00...
转账触发 HITL,审批通过后执行(演示里自动 approve,生产环境由真人审批)。
第4条:投资建议 → 第4层审核拦截
makefile
客户: 基于我的账户给我一些投资建议
[第4层·输出审核] 审核结果: 不合规 → 未通过,替换为兜底回复
银小助: 抱歉,当前回复未能通过合规审核,请换个方式描述问题...
投资建议工具返回"建议投资高风险股票基金,预期年化20%以上",第4层 LLM 审核判不合规,替换为兜底回复。这正是模型驱动护栏的价值:前三层(关键词、PII、HITL)都没拦住("投资建议"不含违禁词、不是 PII、不需要审批),但第4层从语义上把它兜住了。
HITL 也是护栏
回过头看,第12篇中介绍的 HITL 其实也是一种护栏:在敏感操作执行前进行拦截和把关。它和本篇介绍的 PII、违禁词、输出审核使用的是同一套中间件体系,只是把关的方式不同(HITL 是"暂停等人确认",其他都是"自动判断")。
这也印证了第8篇所说的,在 LangChain 1.x 中"一切皆为中间件":护栏、HITL、记忆治理(第10篇的 SummarizationMiddleware)、工具增强(第9篇的 wrap_tool_call),都是中间件在不同场景下的应用。
五、总结
本篇,梳理了 Agent 的安全护栏,包含以下内容:
- 两类护栏:确定性(关键词/正则,快但可能漏语义)vs 模型驱动(LLM 评判,准但贵),实际项目里组合使用。
- PIIMiddleware :四策略(
redact/mask/hash/block)× 三节点(输入/工具结果/输出),内置 5 种 PII 类型,还能用pii_type+detector自定义扩展。 - 自定义护栏 :
@before_agent(输入校验)+@after_agent(输出审核),命中后用jump_to="end"阻断、或直接改content拦截。 - 多层叠加:轻量在前、重量在后,形成分层防御。
- HITL 也是护栏:和 PII、违禁词等同一套中间件体系。
一句话概括:护栏就是中间件,在 Agent 执行的关键节点(before_agent / after_agent)对内容验证过滤;用 PIIMiddleware 处理敏感信息、用 @before_agent/@after_agent 做业务护栏,多层叠加成分层防御。
本篇,也是 Agent 相关的最后一篇。从第8篇的 create_agent 到工具增强、短期/长期记忆、人机协同、安全护栏,一套生产级 Agent 的核心能力就齐了。