ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LLM Agent敏感数据保护:脱敏与引用ID如何兼顾工具调用

2026/8/31 10:49:31 拓冰建站 浏览量
LLM Agent敏感数据保护:脱敏与引用ID如何兼顾工具调用 开发 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: zhangsanexample.com, tenant_id: tenant_a, }, 1002: { name: 李四, phone: 13956781234, email: lisiexample.com, tenant_id: tenant_b, }, } def get_customer_raw(customer_id: int) - dict: 获取客户原始数据仅限工具执行器内部调用禁止直接传给模型。 if customer_id not in CUSTOMERS: raise KeyError(fcustomer {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: 邮箱脱敏zhangsanexample.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(levellogging.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 fcust:{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(finvalid 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_asciiFalse) 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 refcust:1001:phone tail8000 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_IDyour_access_key export SMS_ACCESS_KEY_SECRETyour_secret export APP_DB_DSNsqlite:///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: usertenant_a agentagent_1 toolsend_sms_by_ref phone_ref9f3k2a 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 变更都要先过这一关。如果你在实际项目中遇到过更隐蔽的敏感数据泄漏路径欢迎在评论区补充交流。