☰
LLM Agent敏感数据保护:脱敏与引用ID如何兼顾工具调用
2026/10/17 23:20:14 网站建设 项目流程

开发 LLM Agent 时,最常被低估的问题不是模型能力不足,而是敏感数据进入了大模型上下文。无论是用户手机号、身份证,还是数据库连接串、内部 API Token,一旦被写入 prompt 或工具返回值,就可能被模型服务商记录、被日志系统保存、被后续的 prompt 注入利用。更麻烦的是,如果为了安全直接把字段置空,模型又无法生成正确的工具调用参数,整个 Agent 就“瘫痪”了。

本文围绕“如何在 LLM Agent 工作流中处理敏感数据,同时不破坏工具调用”这个主题,梳理一套可落地的设计模式,并给出一份可以直接运行的最小示例。你会看到:什么时候该脱敏、什么时候该用引用 ID 代替真实值、工具返回值如何打码、日志和密钥管理怎么处理。无论你是用 LangChain、LlamaIndex,还是自研调度器,这套思路都能直接套用。

1. 背景与核心概念

1.1 什么是 LLM Agent 工作流与工具调用

LLM Agent 本质上是一个“能调用外部能力的对话系统”。它的最小组成是:一个大模型负责理解和决策,一组工具负责执行具体操作,一个调度器负责循环判断“模型是否需要调用工具,以及工具返回结果后下一步怎么办”。

工具调用(Tool Calling / Function Calling)是 Agent 中最关键的一环。模型在回答用户问题前,会先生成一个结构化的调用请求,例如:

{ "name": "lookup_customer", "arguments": "{\"customer_id\": \"1001\"}" }

调度器解析这个请求,执行真实函数,把结果返回给模型,模型再基于工具结果生成最终回复。这个流程看起来简单,但敏感数据往往就在这个循环里被“泄漏”了。

1.2 敏感数据在 Agent 工作流中的暴露路径

敏感数据在 Agent 中主要有四条暴露路径:

暴露路径典型场景风险等级
用户输入进入 Prompt用户直接把手机号、身份证号发给 Agent高
工具参数包含敏感值模型需要调用“发送短信”工具,参数里带完整手机号高
工具返回值进入上下文查询客户接口返回完整手机号,模型看到并复述极高
日志与 Trace 记录开发框架自动记录 prompt、工具入参和出参高

很多团队只关注了“用户输入”这一层,却忽略了工具返回值。事实上,Agent 工具返回值会作为“用户角色之后的 system/tool 消息”再次传给模型,如果返回值里带完整手机号,这部分数据就等于进入了模型上下文,和用户主动提交敏感数据没有本质区别。

1.3 为什么不能简单“屏蔽”敏感数据

最直接的处理方式是把敏感字段删掉,比如把手机号置为空字符串。但这样会带来新问题:

  • 模型无法决策:它不知道这个客户有没有手机号,也无法判断短信发送目标。
  • 工具调用语法被破坏:如果工具要求参数必须是 11 位手机号,空字符串会导致参数校验失败。
  • 模型产生幻觉:缺少必要信息时,模型可能会“编造”一个手机号填进参数里,这是比脱敏更严重的事故。

所以正确的思路不是“屏蔽”,而是“替换”。我们要用可展示但不敏感的脱敏值(如138****8000)或者不可读但可解析的引用 ID(如cust:1001:phone)来保持工具调用参数的结构完整,让模型能正常完成决策和生成 JSON,同时不让真实敏感值进入上下文。

2. 总体设计原则

2.1 信任边界与最小权限

在 LLM Agent 架构里,必须把大模型当成“不可信组件”来设计。模型输出可能被 prompt 注入影响,也可能因为上下文过长而产生意外拼接,因此不能默认模型会正确处理敏感字段。

最小权限原则在这里有两层含义:

  • 模型不需要知道的数据,坚决不给;
  • 工具执行器只开放完成当前任务所需的最小能力,并在执行前做授权校验。

一句话总结:模型负责表达意图,宿主系统负责执行权力。

2.2 上下文最小化:模型只看到它该看的

进入 Prompt 的每一条数据都要问一问:模型真的需要完整值吗?

以查询客户为例,模型需要知道“这个客户存在、有一个手机号、手机尾号是 8000”就可以完成对话。它不需要知道完整号码,更不需要知道客户在数据库里的内部备注。因此工具返回值应该统一经过“面向模型输出”的脱敏层。

2.3 工具参数设计:用引用而不是值

这是不破坏工具调用的核心技巧。

假设模型需要调用“发送短信”工具。如果工具参数定义为phone,模型就必须拿到一个真实手机号才能调用,这就逼迫你把敏感值放进上下文。正确的做法是把工具定义为:

send_sms(phone_ref: string, message: string)

其中phone_ref是cust:1001:phone这样的引用键。模型只需要把这个引用键原样传给工具即可,真正解析手机号的动作发生在工具执行器内部。

这样既保证了工具调用的参数完整、类型正确,又保证模型永远接触不到真实手机号。

2.4 返回值最小化:打码后再回传

工具执行完真实操作后,返回值必须再次经过脱敏处理,才能进入 Agent 上下文。比如发送短信成功后,返回值不要写:

{"status": "success", "phone": "13812348000"}

而应该写:

{"status": "success", "phone_tail": "8000"}

这样模型知道短信发送成功、收件人尾号是多少,但完整手机号不会出现在后续上下文里。

3. 环境准备与示例结构

3.1 运行环境说明

本文示例使用 Python 3.10+,不依赖第三方大模型 SDK。为了让逻辑可运行、可复现,示例中用规则函数模拟大模型的工具调用决策,你可以在实际项目中替换为 OpenAI、通义千问、本地部署模型的 Function Calling 接口。

版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。

3.2 示例项目结构

项目结构如下:

agent-sensitive-data-demo/ ├── agent.py # Agent 调度主流程 ├── security/ │ ├── __init__.py │ ├── masking.py # 脱敏与引用解析工具 │ └── logging.py # 日志脱敏过滤器 ├── tools/ │ ├── __init__.py │ ├── schemas.py # 工具 Schema 定义 │ └── customer_tools.py # 客户查询与短信发送工具 └── store.py # 模拟数据源

3.3 模拟数据与依赖说明

为了让示例足够真实,我们在store.py里维护一个简单的客户表,包含客户 ID、姓名、手机号、邮箱和所属租户。

# 文件路径:store.py CUSTOMERS = { 1001: { "name": "张三", "phone": "13812348000", "email": "zhangsan@example.com", "tenant_id": "tenant_a", }, 1002: { "name": "李四", "phone": "13956781234", "email": "lisi@example.com", "tenant_id": "tenant_b", }, } def get_customer_raw(customer_id: int) -> dict: """获取客户原始数据,仅限工具执行器内部调用,禁止直接传给模型。""" if customer_id not in CUSTOMERS: raise KeyError(f"customer {customer_id} not found") return CUSTOMERS[customer_id]

get_customer_raw是一个内部函数,不允许被 Agent 直接调用。这个命名本身就是在代码层面强调“这是敏感数据入口”。

4. 实战:三阶段防护示例

4.1 阶段一:输入与日志脱敏

先编写脱敏工具模块。这里提供一个通用实现,覆盖手机号和邮箱两类基本脱敏场景。

# 文件路径:security/masking.py import re PHONE_RE = re.compile(r"(?<!\d)(1[3-9]\d{9})(?!\d)") EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+") def mask_phone(phone: str) -> str: """手机号脱敏:13812348000 -> 138****8000""" if phone and len(phone) == 11: return phone[:3] + "****" + phone[-4:] return phone def mask_email(email: str) -> str: """邮箱脱敏:zhangsan@example.com -> z****@example.com""" if not email or "@" not in email: return email local, _, domain = email.partition("@") if not local: return email masked_local = local[0] + "****" if len(local) > 1 else "****" return f"{masked_local}@{domain}" def mask_text(text: str) -> str: """对一段文本中的所有手机号和邮箱做脱敏,用于日志和 prompt 入口。""" text = PHONE_RE.sub(lambda m: mask_phone(m.group(1)), text) text = EMAIL_RE.sub(lambda m: mask_email(m.group(0)), text) return text def mask_phone_ref(phone_ref: str) -> str: """ 为了保护隐私,手机号引用也做截断展示。 例如 cust:1001:phone -> 引用键为 cust:1001:phone,模型只能看到尾部信息。 在实际系统中,引用键本身也应该是不透明的随机字符串。 """ return phone_ref

再写一个日志过滤器,确保任何写入日志的文本都经过脱敏。

# 文件路径:security/logging.py import logging from .masking import mask_text class SensitiveDataFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: # 避免直接修改用户的原始日志对象,复制一份再脱敏 if hasattr(record, "msg"): record.msg = mask_text(str(record.msg)) if hasattr(record, "args") and record.args: record.args = tuple( mask_text(str(arg)) if isinstance(arg, str) else arg for arg in record.args ) return True def setup_logging() -> None: root = logging.getLogger() root.addFilter(SensitiveDataFilter()) logging.basicConfig(level=logging.INFO)

这里体现了一个工程细节:日志脱敏不能只在打印时临时做,而应该通过logging.Filter统一挂在根 logger 上,这样任何模块写的日志都会自动脱敏,避免遗漏。

4.2 阶段二:工具定义与脱敏返回值

工具 Schema 是模型生成调用参数的依据。设计 Schema 时就要把敏感字段从参数中移除,用*_ref或*_tail代替。

# 文件路径:tools/schemas.py TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "lookup_customer", "description": "查询客户基础信息。返回手机号和邮箱时均经过脱敏,模型不得要求用户提供完整信息。", "parameters": { "type": "object", "properties": { "customer_id": { "type": "string", "description": "客户 ID,例如 1001", } }, "required": ["customer_id"], }, }, }, { "type": "function", "function": { "name": "send_sms_by_ref", "description": "根据手机号引用键发送短信,模型不需要知道完整手机号。", "parameters": { "type": "object", "properties": { "phone_ref": { "type": "string", "description": "手机号引用键,来自 lookup_customer 返回的 phone_ref 字段", }, "message": { "type": "string", "description": "短信内容", }, }, "required": ["phone_ref", "message"], }, }, }, ]

注意send_sms_by_ref的参数是phone_ref,而不是phone。模型在调用时不需要、也无法拿到真实手机号。

接下来实现工具函数。查询客户时返回脱敏值;发送短信时,先在执行器内部解析引用,真实号码不进入返回值。

# 文件路径:tools/customer_tools.py import logging import json from store import get_customer_raw from security.masking import mask_phone, mask_email logger = logging.getLogger(__name__) # 内部缓存,模拟“宿主系统持有的敏感数据解析表” _PHONE_REF_MAP = {} def _register_phone_ref(customer_id: int, phone: str) -> str: """登记手机号引用键。实际系统可替换为加密存储或外部密钥服务。""" phone_ref = f"cust:{customer_id}:phone" _PHONE_REF_MAP[phone_ref] = phone return phone_ref def lookup_customer(customer_id: str) -> dict: """查询客户基础信息,返回值必须脱敏。""" customer_id = int(customer_id) raw = get_customer_raw(customer_id) phone_ref = _register_phone_ref(customer_id, raw["phone"]) return { "customer_id": str(customer_id), "name": raw["name"], "phone_masked": mask_phone(raw["phone"]), "phone_ref": phone_ref, "email_masked": mask_email(raw["email"]), } def _resolve_phone_ref(phone_ref: str) -> str: """解析手机号引用,只有在工具执行器内部才会调用。""" if phone_ref not in _PHONE_REF_MAP: raise ValueError(f"invalid phone_ref: {phone_ref}") return _PHONE_REF_MAP[phone_ref] def send_sms_by_ref(phone_ref: str, message: str) -> dict: """根据引用键发送短信。真实手机号不会进入返回值。""" real_phone = _resolve_phone_ref(phone_ref) # 这里写真实短信发送逻辑,例如调用短信服务商 SDK logger.info("sending sms to ref=%s tail=%s", phone_ref, mask_phone(real_phone)[-4:]) return { "status": "success", "phone_tail": real_phone[-4:], "message_preview": message[:10] + ("..." if len(message) > 10 else ""), }

lookup_customer返回了phone_masked和phone_ref两个字段。前者用于给用户展示,后者用于后续工具调用。模型看到“手机号是 138****8000,引用键是 cust:1001:phone”,就能在完全不了解真实号码的情况下继续执行发送短信任务。

4.3 阶段三:Agent 调度与工具执行

下面实现 Agent 调度主流程。为了示例可运行,我使用一个规则函数模拟大模型的 tool call 决策,实际项目中替换为真实模型的 Function Calling 结果即可。

# 文件路径:agent.py import json import logging from tools.schemas import TOOL_SCHEMAS from tools.customer_tools import lookup_customer, send_sms_by_ref from security.logging import setup_logging logger = logging.getLogger(__name__) setup_logging() MAX_ROUNDS = 3 def fake_llm_tool_calls(messages: list) -> dict: """ 模拟大模型返回工具调用。 真实场景中,这里应调用 OpenAI / 通义千问 / 本地模型的 Function Calling 接口, 将 messages 和 TOOL_SCHEMAS 一起传入,解析返回的 tool_calls。 """ last_user_content = "" for message in reversed(messages): if message["role"] == "user": last_user_content = message["content"] break if "查一下客户" in last_user_content and "1001" in last_user_content: return { "content": None, "tool_calls": [ { "id": "call_1", "name": "lookup_customer", "arguments": {"customer_id": "1001"}, } ], } # 检查最近的 tool 返回中是否包含 phone_ref 且用户有发送短信意图 if "发送" in last_user_content or any( m.get("role") == "tool" and "phone_ref" in json.dumps(m.get("content", "")) for m in messages ): for message in reversed(messages): if message["role"] == "tool": try: tool_content = json.loads(message["content"]) except json.JSONDecodeError: continue if "phone_ref" in tool_content: return { "content": None, "tool_calls": [ { "id": "call_2", "name": "send_sms_by_ref", "arguments": { "phone_ref": tool_content["phone_ref"], "message": "您好,您有一张优惠券即将过期。", }, } ], } return {"content": "我已经处理完你的请求。", "tool_calls": None} TOOL_MAP = { "lookup_customer": lookup_customer, "send_sms_by_ref": send_sms_by_ref, } def execute_tool(tool_call: dict) -> str: """执行工具。执行前可以追加鉴权、限流、审计等逻辑。""" tool_name = tool_call["name"] arguments = tool_call.get("arguments", {}) # 这里演示最小授权判断:只有包含 send_sms 的调用才记录短信审计 if tool_name == "send_sms_by_ref": logger.info("audit: send_sms tool called, args=%s", json.dumps(arguments)) result = TOOL_MAP[tool_name](**arguments) return json.dumps(result, ensure_ascii=False) def run_agent(user_input: str) -> str: messages = [{"role": "user", "content": user_input}] logger.info("user input: %s", user_input) for _round in range(MAX_ROUNDS): response = fake_llm_tool_calls(messages) if response.get("tool_calls"): for tool_call in response["tool_calls"]: result_content = execute_tool(tool_call) messages.append( { "role": "tool", "tool_call_id": tool_call["id"], "content": result_content, } ) continue return response["content"] return "已超过最大轮次,停止处理。" if __name__ == "__main__": result = run_agent("请帮我查一下客户 1001 的信息,然后给他发送一条优惠短信。") print("最终回复:", result)

这段代码完成了一次完整的 Agent 循环:用户请求 → 模型调用lookup_customer→ 工具返回脱敏数据 → 模型根据phone_ref调用send_sms_by_ref→ 工具执行器内部解析真实手机号并发送 → 返回脱敏结果 → 模型输出最终回复。

4.4 运行与验证

在项目根目录执行:

python agent.py

预期输出(日志中手机号被自动脱敏):

INFO security.logging: user input: 请帮我查一下客户 1001 的信息,然后给他发送一条优惠短信。 INFO tools.customer_tools: sending sms to ref=cust:1001:phone tail=8000 INFO security.logging: audit: send_sms tool called, args={"phone_ref": "cust:1001:phone", "message": "您好,您有一张优惠券即将过期。"} 最终回复: 我已经处理完你的请求。

在真实项目中,建议增加一步:在返回值里追加phone_tail等信息,让模型能自然地向用户复述“已向尾号 8000 的手机号发送短信”。这部分需要你根据实际模型能力做调整。

5. 进阶:数据库与密钥管理场景

5.1 数据库工具的参数化查询

Agent 经常需要查询数据库。如果让模型直接生成 SQL,不仅存在注入风险,而且模型可能输出包含真实敏感数据的查询结果。

更安全的模式是:模型只能调用预设的查询函数,函数内部使用参数化 SQL,返回结果经过脱敏。

# 文件路径:tools/db_tools.py import os import sqlite3 from security.masking import mask_phone def query_customer_orders(customer_id: str) -> list: """查询客户订单摘要,禁止返回完整敏感字段。""" dsn = os.getenv("APP_DB_DSN", "demo.db") conn = sqlite3.connect(dsn) cursor = conn.cursor() # 参数化查询,禁止将 customer_id 直接拼接到 SQL 中 cursor.execute( "SELECT order_id, order_amount, order_time FROM orders WHERE customer_id = ?", (customer_id,), ) rows = cursor.fetchall() conn.close() return [ { "order_id": row[0], "order_amount": row[1], "order_time": row[2], } for row in rows ]

关键点有两个:

  • 连接串APP_DB_DSN从环境变量读取,不进入模型上下文;
  • SQL 使用占位符?,模型即使恶意拼接也无处下手。

5.2 密钥与连接信息的管理方式

对于第三方 API Token、数据库密码、短信服务密钥,建议统一通过环境变量或密钥管理服务注入到宿主进程:

export SMS_ACCESS_KEY_ID="your_access_key" export SMS_ACCESS_KEY_SECRET="your_secret" export APP_DB_DSN="sqlite:///demo.db"

在 Python 代码中只读取环境变量:

import os sms_key_id = os.getenv("SMS_ACCESS_KEY_ID") sms_key_secret = os.getenv("SMS_ACCESS_KEY_SECRET")

生产环境建议使用 Vault、云厂商 Secret Manager 等工具管理密钥,并开启审计和轮转。不要在代码仓库中提交任何真实密钥,不要在日志中打印环境变量。

5.3 可逆引用与掩码的选择

模式适用场景示例风险等级
掩码值需要向用户展示部分信息138****8000中,不可用于执行
随机引用键工具参数需要引用敏感值ref_9f3k2a低,仅宿主可解析
完全省略模型不需要该字段不传 phone 参数安全,但可能影响决策

推荐做法是:面向展示用掩码值,面向工具调用用随机引用键。真实系统里的引用键不应该像示例中那样带有cust:1001:phone这种语义信息,而应该是一个不可推断的随机字符串,避免模型或其他调用方通过引用键反查出业务结构。

6. 常见问题与排查思路

问题现象常见原因解决思路
脱敏后模型不会调用工具了工具参数设计依赖真实敏感值改用引用键,在工具描述中明确说明参数含义
模型复述了完整手机号工具返回值未脱敏统一在工具返回层做脱敏,禁止返回完整敏感字段
日志中出现手机号日志框架直接记录了原始参数挂载全局日志过滤器,脱敏后再输出
工具执行时报“引用不存在”phone_ref 没有正确透传检查工具返回中是否包含引用键,检查上下文里是否被截断
模型在参数里“编造”手机号上下文缺少必要信息且未提供引用键补充phone_ref,强制工具参数只接受引用键格式
提示词注入通过工具返回值绕过工具返回值包含不可信文本且未做隔离只解析 JSON 字段,不把原始文本当作指令拼接

这里最容易被忽略的是第二条。很多团队做了 Prompt 脱敏,但忘了工具返回值同样会进入模型上下文。务必把工具返回值当成和用户输入同等重要的安全边界。

7. 最佳实践与工程建议

7.1 集中式脱敏规则

不要在每个业务函数里各写一套脱敏逻辑。建议把所有脱敏规则收口到一个模块,例如security/masking.py,并提供统一的数据模型。

这样做的好处是:当脱敏规则变化时,只需要改一个文件,所有工具调用都会同步生效。在团队协作中,也更容易做代码 Review 和安全审计。

7.2 日志与审计分离

敏感数据不能出现在日志里,但审计需要记录操作。这两件事可以分开:

  • 日志面向调试和监控,脱敏后输出;
  • 审计面向合规和安全,记录“谁在什么时间操作了什么引用键”,但不记录具体敏感值。

例如:

audit: user=tenant_a agent=agent_1 tool=send_sms_by_ref phone_ref=9f3k2a message_tail=优惠券

这样审计信息完整,敏感数据不落盘。

7.3 测试策略

建议在测试环境中跑两类用例:

  • 脱敏数据测试:用138****8000之类掩码值验证工具调用仍能完成;
  • 真实数据测试:在本地环境用真实数据验证引用解析、短信发送等底层链路,但不允许把真实数据写入测试报告。

每次修改工具 Schema 后,都要回归“模型是否能正确选择工具并生成参数”这一项,避免脱敏方案破坏工具调用。

7.4 生产安全边界

生产环境还需要注意:工具调用前校验租户上下文、执行速率限制、对敏感操作做二次确认、对工具返回内容做长度限制,防止超大返回值撑爆上下文和日志。所有涉及真实数据读取的操作,都应遵循最小权限原则,并且只在经过授权的工具执行器内部完成。

8. 总结与学习路线

处理 LLM Agent 工作流中的敏感数据,关键不是“堵住某一处”,而是建立三层防线:上下文层只放脱敏值或引用键,工具层在执行器内部解析真实数据,日志层通过过滤器避免敏感值落盘。

最容易踩的坑是:明明做了 Prompt 脱敏,却忽略了工具返回值;或者为了让模型顺利调用工具,又把完整手机号放回了工具参数。这两种做法都会让前面的安全设计失效。

你可以从本文示例入手,在本地把agent.py跑通,再把lookup_customer和send_sms_by_ref替换成你业务里的真实工具。下一步可以继续研究:如何在多租户场景下隔离客户数据,如何为工具调用设计完整的审计链路,以及如何使用本地小模型在完全内网环境中完成工具调用决策。

如果你的团队正在做 LLM Agent 落地,建议把“敏感数据是否进入模型上下文”作为每次迭代的强制检查项。所有工具、Prompt、日志 Schema 变更,都要先过这一关。如果你在实际项目中遇到过更隐蔽的敏感数据泄漏路径,欢迎在评论区补充交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询