在 LLM Agent 工作流里,敏感数据经常会以两种方式泄露:一种是被拼进 prompt 后直接发送给大模型,另一种是在工具调用参数里被打包出去。很多团队本地跑 Agent 时觉得没问题,一旦把 Agent 接到真实业务系统,就会发现账号、手机号、身份证号、密钥、内网地址全都在对话历史里明文出现。更麻烦的是,如果为了安全给数据做了脱敏,工具调用又经常报错——LLM 生成的是脱敏后的占位符,工具却要求真实参数,两边对不上,任务直接失败。
这次我们重点聊一个实际问题:如何在 LLM Agent 工作流中处理敏感数据,同时不破坏 tool calls。核心思路不是“永远不要用敏感数据”,而是“LLM 永远不要直接接触明文敏感数据,工具执行时才在最小范围内还原真实值”。下面的内容会覆盖脱敏策略、Agent 调用链设计、接口层处理、批量任务场景、测试方法和排查清单,适合正在做 Agent 应用落地、又担心数据合规的团队。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 核心问题 | Agent 在调用工具时需要真实参数,但大模型对话链路和处理日志不应暴露明文敏感数据 |
| 关键设计 | 脱敏后入 prompt,工具执行前还原,还原动作只在可信执行层发生 |
| 支持技术栈 | 主流的 Agent 框架均可适配,如 LangChain、LlamaIndex、自研编排层;示例以 Python + pydantic 为例 |
| 环境要求 | Python 3.9+,Agent 框架依赖,Redis/数据库用于临时映射,KMS 或环境变量管理密钥 |
| 是否影响工具调用 | 需要额外实现脱敏映射和还原逻辑,但设计正确后不会破坏参数结构 |
| 是否支持批量任务 | 支持,建议为每个任务分配 trace_id,统一管理脱敏映射 |
| 是否提供 API | 取决于 Agent 服务自身,建议在 API 网关层做统一脱敏和审计 |
| 适合场景 | 金融、医疗、企业内部系统、客服知识库、自动化运维等含敏感字段的业务场景 |
| 不适合场景 | 对实时性要求极高且没有中间存储条件的环境,以及需要模型本身理解敏感字段语义的场景 |
注意:这里的方案属于通用工程实践,不是某个开源项目的安装包,所以不存在“一键启动”。但你可以把这套模式直接落地到现有 Agent 项目里。
2. 适用场景与使用边界
LLM Agent 的典型工作流是:用户输入自然语言 -> Agent 规划 -> 调用工具 -> 拿到结果 -> 继续推理 -> 返回回答。这个链路里至少有三个环节会碰到敏感数据:
- 用户原始输入,可能包含敏感字段。
- 工具返回结果,可能包含业务侧的真实数据。
- 中间态的 prompt、日志、debug 信息,可能被打印到控制台或采集到日志平台。
这套方案主要适用于:
- 客服机器人查询订单、物流、会员信息,但不希望模型记住用户手机号。
- 企业内部 Copilot 读取数据库记录或内部系统 API,但需要对模型隐藏员工隐私字段。
- 自动化运维 Agent 执行脚本时,需要在命令行里传入 token,但不想让模型看到 token 明文。
- 批量数据处理流水线中,Agent 需要逐条调用工具处理敏感内容。
使用边界需要明确:
- 脱敏不等于加密。脱敏后模型无法推理真实值,但脱敏映射表本身需要额外保护。
- 模型提供方如果与业务方是同一个信任域,可能允许在私有化部署场景下直接传明文,但这同样建议走审计日志。
- 涉及个人隐私、金融数据、医疗数据时,必须先确认数据来源合法、使用用途合规,并且获得必要授权。
- 禁止用这套方案绕过外部数据保护要求。脱敏只是技术手段,不能替代安全制度。
3. 环境准备与前置条件
开始之前,先确认你的 Agent 项目是否满足以下基础条件。没有统一版本约束,但建议按清单检查。
| 项目 | 建议 |
|---|---|
| 操作系统 | Linux / macOS / Windows 均可,生产环境建议 Linux |
| 语言版本 | Python 3.9+,如果使用 pydantic v2 则需 3.8+ |
| Agent 框架 | LangChain、LlamaIndex 或自研,按项目实际选择 |
| LLM 服务 | 支持标准 Chat Completions 或兼容接口均可,本地部署或远程 API 都可以 |
| 密钥管理 | 环境变量、Vault、KMS、云厂商密钥管理服务,任选一种 |
| 临时存储 | Redis、内存字典、数据库表,用于存放脱敏映射关系 |
| 日志系统 | 需要在日志采集前过滤敏感字段 |
| GPU | 本方案本身不依赖 GPU,属于工程层逻辑;是否使用 GPU 取决于 LLM 推理方式 |
部署前检查脚本大致如下:
# 检查 Python 版本 python --version # 检查当前环境是否为虚拟环境 which python # 检查关键依赖 pip show pydantic langchain openai redis 2>/dev/null | grep -E "Name|Version"如果缺少依赖,按项目实际情况安装,示例:
# 示例,具体版本按你的项目要求 pip install pydantic langchain openai redis注意:不要把敏感数据直接写死在.env并提交到 Git。.env文件本身也要加入.gitignore。
4. 数据脱敏与工具调用的基本模式
处理敏感数据不破坏 tool calls,关键在于把“模型看到的”和“工具实际执行时用的”分开。
4.1 核心思路
一般流程是:
- 入口层识别敏感字段。
- 用占位符替换敏感字段,占位符必须唯一、可还原、类型一致。
- Agent LLM 只接触脱敏后的文本和参数。
- Agent 决定调用工具时,工具参数中仍然携带占位符。
- 进入工具执行层之前,根据映射关系将占位符还原成真实值。
- 工具执行完成,如果返回结果包含敏感字段,对返回结果再次脱敏,再交给 LLM。
- 全程记录审计日志,日志中只保留脱敏后的内容。
4.2 脱敏映射设计
脱敏映射要解决“还原”问题。最常见的做法是:
import uuid import re from typing import Dict, Any class DataMasker: def __init__(self): self._mapping: Dict[str, str] = {} def _mask_value(self, real_value: str, prefix: str) -> str: # 生成唯一占位符,保持可辨识性 placeholder = f"__{prefix}_{uuid.uuid4().hex[:8]}__" self._mapping[placeholder] = real_value return placeholder def mask_text(self, text: str, rules: Dict[str, str]) -> str: masked = text for pattern, prefix in rules.items(): def _replace(match): return self._mask_value(match.group(0), prefix) masked = re.sub(pattern, _replace, masked) return masked def mask_json(self, obj: Any, sensitive_keys: list[str]) -> Any: if isinstance(obj, dict): new_obj = {} for key, value in obj.items(): if key in sensitive_keys and isinstance(value, str): new_obj[key] = self._mask_value(value, key) else: new_obj[key] = self.mask_json(value, sensitive_keys) return new_obj elif isinstance(obj, list): return [self.mask_json(item, sensitive_keys) for item in obj] return obj def unmask(self, masked_value: str) -> str: return self._mapping.get(masked_value, masked_value) def parse_tool_args(self, args: dict, sensitive_keys: list[str]) -> dict: # 还原工具参数 result = {} for k, v in args.items(): if k in sensitive_keys and isinstance(v, str): result[k] = self.unmask(v) else: result[k] = v return result def clear(self): self._mapping.clear()这里的关键是:占位符必须是完整字符串,不能破坏 JSON 结构。比如"phone": "__phone_ab12cd34__"仍然是合法字符串,LLM 可以把它当作参数原样传给工具。工具执行层拿到后还原成真实验证码。
4.3 Agent 工具调用流程示例
下面以 Python + self-masker 的方式演示一个 “查询订单” 工具。前提是假设 Agent 框架支持在工具执行前插入自定义逻辑。
import json from pydantic import BaseModel, Field masker = DataMasker() SENSITIVE_KEYS = ["user_id", "phone", "id_card"] class OrderQueryInput(BaseModel): user_id: str = Field(description="用户唯一ID,占位符形式") order_id: str = Field(description="订单号") def order_query_tool(user_id: str, order_id: str) -> dict: # 工具执行层:先还原脱敏参数 real_user_id = masker.unmask(user_id) # 这里调用真实业务 API,比如查订单系统 # 以下仅作为示例 result = {"order_id": order_id, "status": "PAID", "buyer_phone": "13800138000"} # 返回给 LLM 之前,对敏感字段再次脱敏 masked_result = masker.mask_json(result, ["buyer_phone"]) return masked_result # 模拟 Agent 传入脱敏后的参数 tool_args = { "user_id": "__user_id_00000000__", "order_id": "ORD20250101" } # 实际调用工具 output = order_query_tool(**tool_args) print(json.dumps(output, ensure_ascii=False))这段示例演示了“工具内部还原”和“返回结果再脱敏”,但没有把明文 user_id 放回 prompt。
4.4 不破坏 tool calls 的设计要点
要让 LLM 生成的工具调用参数仍然有效,有四个点必须注意:
- 占位符不要去重,每个敏感值都要映射到唯一占位符。
- 占位符格式要稳定,建议用
__字段名_随机串__,避免模型误改。 - 工具的参数 schema 描述中,可以明确说“user_id 为脱敏占位符,不需要解释真实值,直接透传”。
- 还原逻辑必须只发生在工具执行边界,不要在模型上下文里还原。
如果你在 LangChain 中使用@tool装饰器,可以在args_schema中使用字符串类型,然后在底层函数内部调用masker.unmask()。这会保证模型侧看不到真实值,工具侧又拿得到真实值。
5. 功能测试与效果验证
把方案写完后,不能只看代码能跑,还要验证“模型确实没有接触明文”“工具调用没有被破坏”。建议用下面这套测试流程。
5.1 基础脱敏验证
测试目的:确认敏感字段在脱敏后不会出现在 prompt 中,且占位符能还原。
def test_mask_and_unmask(): masker = DataMasker() text = "我的手机号是13800138000,请帮我查订单" masked = masker.mask_text(text, {r"1[3-9]\d{9}": "phone"}) print("脱敏后:", masked) assert "13800138000" not in masked # 解析出占位符并还原 match = __import__("re").search(r"__phone_[0-9a-f]+__", masked) assert match assert masker.unmask(match.group(0)) == "13800138000"5.2 Agent 调用链验证
测试目的:确认 Agent 在规划阶段看不到明文,工具执行时能还原。
# 在 Agent 项目中启动服务(示例命令,需按实际项目调整) python agent_service.py --config ./config.yaml然后构造请求:
{ "message": "帮我查用户 13800138000 的订单", "trace_id": "trace-test-001" }观察日志输出:
- prompt 日志里应该只有
__phone_xxxxxxxx__,不应该有13800138000。 - 工具调用记录里,
user_id字段应该是脱敏占位符。 - 工具执行的内部日志中,还原后的真实值只出现在工具执行日志里,且必须确认该日志不会被 LLM 上下文捕获。
- 最终返回给用户的内容里,如果需要显示手机号,则应由业务系统格式化成
138****8000,由模板层完成,不要走 LLM 生成。
5.3 失败注入测试
测试目的:验证占位符还原失败时,不会导致工具调用静默失败。
import pytest def test_unmask_missing_placeholder(): masker = DataMasker() # 不存在映射时,返回原字符串 assert masker.unmask("__phone_unknown__") == "__phone_unknown__"如果还原失败返回原占位符,说明工具参数不对,应该在工具内部继续抛错,而不是把占位符当作真实值去查询。否则会出现“工具调通了但查不到数据”的隐蔽问题。
5.4 批量任务验证
批量任务通常来自文件或者队列。建议每条数据生成独立 masker 实例或独立 trace_id。批量处理时,不能共享同一个映射表,否则会导致不同批次的数据串号。
# 示例:批量任务处理 def process_batch(records): for record in records: masker = DataMasker() masked_record = masker.mask_json(record, ["phone", "email"]) # 发送给 Agent,运行工具调用 # 处理完成后清理映射 masker.clear()测试时重点观察:
- 批量任务是否在随机点出现占位符混乱。
- 工具执行是否因为还原错误而失败。
- 最终输出是否错位。
6. 接口 API 与批量任务
如果 Agent 服务对外提供 HTTP API,敏感数据会在两个地方出现:请求体里和响应体里。建议在 API 网关层做统一处理。
6.1 请求参数脱敏
外部用户可能会直接传明文敏感字段到 Agent 服务。可以在服务入口处先做脱敏,再进入 Agent 编排。
# FastAPI 示例 from fastapi import FastAPI, Request from pydantic import BaseModel app = FastAPI() class QueryRequest(BaseModel): message: str user_id: str phone: str @app.post("/agent/query") async def agent_query(req: QueryRequest): # 入口脱敏 masked_phone = masker.mask_text(req.phone, {r"1[3-9]\d{9}": "phone"}) # 后续 Agent 流程只使用 masked_phone,而不是 req.phone ...注意:不能只依赖拦截器。业务代码里如果继续使用原始req.phone,那脱敏就是假的。比较好的办法是,在请求模型进入业务逻辑前,就把明文字段替换成脱敏值,后续代码只认脱敏值。
6.2 返回值脱敏
工具返回结果中如果包含敏感字段,在写入 LLM 上下文之前要统一脱敏。如果整个 Agent 返回给用户时需要明文,再由最外层 API 根据用户权限决定是否放行。
6.3 批量任务设计
批量任务的建议:
- 每个任务配置一个
trace_id。 - 脱敏映射关系以
trace_id为 key 保存到 Redis,设置 TTL。 - 批量任务结束时清理映射。
- 失败任务要保留映射,方便排查,但需要加密存储。
- 日志中间件统一过滤敏感字段。
# Redis 映射存储示例 import redis r = redis.Redis(host="localhost", port=6379, db=0) trace_id = "batch_20250101_001" def store_placeholder(trace_id, real_value, placeholder, ttl=3600): key = f"mask:{trace_id}:{placeholder}" r.setex(key, ttl, real_value) def get_real_value(trace_id, placeholder): key = f"mask:{trace_id}:{placeholder}" return r.get(key)6.4 调用 LLM 时的请求模板
不管 Agent 框架是什么,最终都要构造 prompt 或 messages 发给 LLM。下面是标准 Chat Completions 接口的示例,这里只展示脱敏后的数据:
import requests url = "http://your-llm-service/v1/chat/completions" headers = {"Authorization": "Bearer <your_token>"} payload = { "model": "your-agent-model", "messages": [ { "role": "system", "content": "你是订单助手。用户数据已经脱敏,只识别占位符,不要尝试解释占位符含义。" }, { "role": "user", "content": "用户 __user_id_abcd1234__ 的订单状态是什么?" } ], "temperature": 0 } resp = requests.post(url, json=payload, timeout=60) print(resp.json())注意:这里的<your_token>是示例,不要把真实 token 写到博客或仓库里。
7. 资源占用与性能观察
脱敏和加密会带来额外计算开销,但通常不会成为瓶颈。实际耗时主要看三点:
- 正则匹配的规则数量。
- 脱敏字段的数量。
- 加解密算法的选择。
如果使用对称加密如 AES,而不是简单的映射,加密和解密会有微秒级开销,对单次 Agent 调用影响不大。但如果批量任务达到每秒上千次,就要考虑预取映射或批量解密。
观察性能的方法是:
- 在脱敏函数前后打点统计耗时。
- 记录工具执行真实耗时。
- 分别统计有脱敏和无脱敏时的端到端延迟。
- 观察 LLM 生成的 prompt token 数量。脱敏占位符虽然比明文短,但会占用一定 token,尤其当敏感字段很多时,会影响模型推理效果。
降低性能影响的建议:
- 只对真正敏感的字段脱敏,不要把所有字段都脱敏。
- 使用预编译正则。
- 映射表直接放进程内存,不用每次请求查 Redis。
- 对工具执行层使用函数级缓存,避免同一请求内重复还原。
8. 常见问题与排查方法
在实际使用中,出错最多的地方不是脱敏算法本身,而是 Agent 框架的调用链过于混乱。下面整理了一批常见问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 工具的某个必填参数变成了脱敏占位符,但工具没有还原 | 还原逻辑放错了位置 | 查看工具执行前的参数日志 | 在工具入口调用unmask |
| LLM 生成工具参数时把占位符拆开或加了解释 | prompt 中没说明占位符规则 | 检查 messages 内容 | 在 system prompt 中明确“占位符是合法参数,直接透传,不要修改” |
| 还原失败,工具拿到的是占位符原文 | 映射表未同步到执行节点 | 检查映射表内容和 trace_id | 使用 Redis 统一存储映射 |
| 日志里出现了明文敏感数据 | 日志打印了未脱敏的原始对象 | grep 日志中手机号/姓名关键词 | 增加日志脱敏 filter,统一在日志输出前清洗 |
| 批量任务中不同任务的占位符互相覆盖 | 使用了全局共享 masker 实例 | 检查并发代码 | 每个任务独立实例,或使用 trace_id 隔离 |
| 脱敏后的文本语义丢失,模型回答问题变得很奇怪 | 脱敏规则过严,模型无法理解字段含义 | 查看模型输出 | 在提示词中增加字段语义说明,例如“__user_id__表示用户编号,无需解析” |
| 接口返回给用户时看不到手机号 | 返回结果也被脱敏了 | 检查 API 返回逻辑 | 在最外层根据用户权限还原并格式化 |
| 调用外部工具时,token 被包含在 JSON 参数中 | 模型参数 schema 设计不合理 | 检查工具参数定义 | 不要把密钥作为工具参数,改为从工具内部缓存或密钥库读取 |
| Agent 在多次调用工具时重复请求真实值 | 每次还原都查一次外部存储 | 查看调用链路 | 同一次请求内部使用本地缓存 |
排查通用流程:
- 打开 debug 日志,但注意日志系统本身是否安全。
- 在脱敏前、脱敏后、工具执行前、工具执行后各打一条结构化日志。
- 用 trace_id 串联整条调用链。
- 如果某个工具调用失败,优先看第 3 条日志中参数是否已还原。
- 确认 LLM 的推理日志不会回传给模型或发送到公网分析服务。
9. 最佳实践与使用建议
工程落地上,建议按下面这套思路来推进。
9.1 先做数据分类,再做脱敏
不是所有数据都需要脱敏。可以先按敏感级别分类:
| 级别 | 示例 | 处理方式 |
|---|---|---|
| 高敏感 | 身份证号、银行卡号、密码、token | 禁止进入 prompt,工具调用时使用临时凭证 |
| 中敏感 | 手机号、邮箱、地址 | 脱敏后进入 prompt,工具执行前还原 |
| 低敏感 | 用户名、订单号 | 可进入 prompt,但仍需脱敏展示 |
| 非敏感 | 商品名称、时间格式 | 正常使用 |
9.2 最小权限原则
工具调用时需要的权限越低越好。比如查询订单只需要user_id,就不要把整个用户对象传给工具。密钥管理要遵循最小权限,不同工具可以使用不同的 token,避免一个 token 泄露导致全局崩盘。
9.3 分层保护
脱敏只是第一层。还要做:
- 应用层加密:映射表、缓存里的敏感数据使用字段级加密。
- 传输层安全:Agent 服务与 LLM、工具服务之间使用加密通道。
- 存储层保护:日志平台、数据库备份中进行脱敏或加密。
- 运行环境隔离:敏感数据还原动作只允许在可信执行节点完成。
9.4 审计与追踪
每一条工具调用都应该有审计日志。日志字段至少包含:
{ "trace_id": "trace-test-001", "timestamp": "2025-01-01T00:00:00Z", "agent_action": "call_tool", "tool_name": "order_query", "tool_input_masked": { "user_id": "__user_id_abcd1234__", "order_id": "ORD20250101" }, "tool_input_real": "已加密存储,不在日志中展示", "tool_result_masked": { "status": "PAID" }, "user_permission": "order:read" }审计日志不应该记录真实值,如果必须记录,需要加密且严格限制访问权限。
9.5 合规提醒
不要用这套方案规避法律法规。如果你的业务涉及个人隐私、金融数据、医疗数据,需要先咨询合规负责人。本地部署、私有化推理、数据不动点部署等措施都值得考虑。
10. 总结与下一步
在 LLM Agent 工作流中处理敏感数据,最值得尝试的点是“脱敏映射 + 工具执行前还原 + 全链路审计”这套模式。它不需要替换 Agent 框架,也不需要换模型,只要在入参、出参和工具边界加一层控制,就能显著降低敏感数据进入 prompt 的概率。
最先应该验证的是一个小场景:比如让 Agent 查询订单,同时把 user_id 和手机号脱敏。先写一个单元测试证明脱敏后没有明文、工具参数能正常还原,再串上真实模型做一次完整调用。最有可能踩的坑是“还原逻辑放错位置导致工具拿到占位符”,以及“日志中仍然出现明文”。把这两个问题盯住,大部分项目就能稳定跑起来。
后续可以继续扩展的方向包括:
- 将脱敏规则做成配置化,按租户或按业务线区分。
- 使用 KMS 加密映射表,增强还原层安全。
- 在 Agent 编排层增加数据流出检查,凡是离开内部网络的字段都强制走脱敏。
- 对 LLM 的输出做敏感信息检测,防止模型生成明文。
建议先收藏这套思路,等真正做 Agent 生产化时,从最小的数据流开始改造。