高敏感技能掩码:如何在 Agent 开放平台上保护隐私能力

高敏感技能掩码:如何在 Agent 开放平台上保护隐私能力

导读:这篇讲 Agent 开放平台上高敏感技能的对话隐私掩码设计:论证了为什么不能只按技能标记判断(五类泄露场景会漏掉四类,名单内用户、记忆回显等都挡不住),并给出三份名单叠加(技能/知识库/用户)、逐轮判定、命中轮整轮占位符 + 后续轮「小纸条」值级打码 + 敏感词句级打码的三层机制。关键取舍是 fail-closed(宁可多掩不漏掩)、打码不可逆且无人可豁免、主管确认才生效,并演进到「命中轮永锁 + 延续 2 轮窗口」的按轮判定。适合做 Agent 平台安全、隐私保护或对话记录审计的工程师读。

分类:AI安全 | 标签:AI安全、高敏感、掩码、隐私保护

问题背景:为什么不能只按技能标记判断

Agent 开放平台允许第三方上架技能,其中一部分技能会处理高敏感数据——客户对话、工资明细、身份信息、收货地址等等。这些对话一旦在运营后台被随意查看,本身就是隐私泄露。

于是我们给敏感技能打上「高敏感」标记:被标记的技能,它产生的对话记录(用户问了什么、模型答了什么、调用了什么工具)在运营后台会整体变成占位符 [HIGH_SENSITIVE_REDACTED]——你能看到「这里有一段被保护的对话」,看不到内容。

但一个问题很快浮现:能不能只按技能标记判断? 也就是「每一轮去读记录,这一轮用的是被标记为高敏感的技能就掩码,没标记就不掩码」。

结论是:不够。一句话:技能标记只是多把锁中的一把,钥匙并不只挂在技能上——只按它判断,下面五类泄露会漏掉四类。

泄露的情形 这一轮的技能有标记吗 只按技能标记 现在的做法
① 用的就是被标记的技能 有 能挡住 整轮掩码
② 技能没标记,但引用了敏感资料 没有 会漏 整轮掩码
③ 技能没标记,但对话的人是名单内用户(如高管账号) 没有 会漏 整轮掩码
④ 技能没标记,但后续轮又复现了前面出现过的具体数字/号码 没有 会漏 只打码那个值
⑤ 技能没标记,但后续轮被记忆把敏感事实带出来 没有 会漏 只打码那一句

三个例子说明为什么会漏:

  • 例子一(名单内用户):一位名单内的高管用「订单查询」这个普通技能查自己的订单、收货地址、联系电话。技能从上线起就没被标过——按「没标记就不掩码」,整段对话就是明文。但对话主体是高管的个人信息,必须保护。这只能靠用户名单拦住,和技能标记无关。
  • 例子二(复现旧敏感值):用户先在「工资查询」(已标记)里问出实发金额,接着换一个没被标记的技能说「帮我算一下昨天那笔实发到账的钱」。按「没标记就不掩码」,金额又漏一次。需要把前面那轮出现过的具体金额记下来,后续再出现就只打码这个数字。
  • 例子三(记忆带出事实):模型把「工资条里部门字段异常」这类事实写进记忆,之后在「1+1 等于几」这种完全无关的问题里顺口带出来。这一轮技能没标记、也没逐字复现具体金额,常规规则管不着——里面可能一个号码都没有,只有「工资」这样的词。只能靠敏感词发现后打码那一句。

为什么这几种必须管?一句话:前面保护了、后面放掉,等于没保护。 例子二、三正是「第一轮涉密、后面几轮不涉密」的场景——后面几轮本来就不是高敏感技能,按「没标记就不掩码」,前面刚被保护过的金额、薪酬事实在紧接着的几轮里又完整露出去。

另外澄清一个容易误会的事实:高敏感标记本身不在观测平台(记录平台)里。 写记录的程序往观测平台写的是这一轮的身份信息——用了哪个技能、哪种子技能、谁发起的、引用了哪些资料;而「高敏感」标识存在平台自己的数据库里(技能表的一个字段,由主管确认后写入)。所以「去观测平台查有没有高敏感标记」落到实处是:「取出身份 → 到名单里比对」。「每轮判断」这个思路本身是对的,但判断的依据不能只有技能标记这一条。

掩码机制:四步生效流程

一个安全机制如果说不清「谁贴标签、标签存哪、怎么传到查询侧、运营实际看到什么、边界在哪」,就没法让别人信任它。我们的掩码机制分四步生效。

第一步:人工确认,模型只提建议

后台有两个动作,故意分开:

  • 生成建议:让模型做一次预筛,给出「这个技能是否高敏感」的判断和理由。只写进建议字段,不生效。
  • 确认或修改:由主管拍板,写进去才是生效值,同时记录标记原因。

为什么不让模型直接决定?标错一个正常技能,它所有对话都会变成占位符,直接影响运营排障;标漏一个敏感技能则是数据风险。让模型只提议、人来拍板,是这两种代价之间的折中。所以贴上标签不会立刻生效,必须由主管人工确认才算数;建议本身不生效。

第二步:名单下发与缓存(最多 60 秒延迟)

查询侧不直接读数据库,而是从平台的内部接口一次性取回三份名单,并缓存 60 秒。所以标记变更到实际生效,最长有 60 秒延迟——不是故障,验收时刚标完没生效,先等一分钟再判断。

「高敏感」不只针对技能,而是三份名单各自维护,命中任何一份就算高敏感:

名单 管什么 什么时候命中
高敏感技能 某个技能本身涉及敏感业务 这一轮用的技能在名单里
高敏感知识库 某份资料属于敏感资料 这一轮引用了名单里的资料
高敏感用户 某类人的对话需要保护 这一轮是名单里的人发起的

这点很重要:一个完全正常的技能,只要对话的人是名单里的用户、或者引用了名单里的知识库,同样会被掩码。

另外配一份兜底名单(通过配置注入):内部接口取不到时就用它,查询本身不会因此失败。两份名单是叠加关系——兜底名单还能临时补上接口里暂时没有的内容(比如紧急加一条保护)。

性能上不用担心两点:名单不是每轮去请求一次,而是每 60 秒批量拉一次缓存在内存里,逐轮判断只是内存里的比对;「读取记录」本身也不是额外开销——要显示内容就必须读记录,判断是顺手做的。

第三步:逐轮判定

判断的单位是一轮问答,不是整个会话。每一轮都会独立检查:用的技能在不在名单里、引用的资料在不在名单里、发起人在不在名单里——任意一个命中就算。

有一个容易踩的细节:当一条标记没写清到「子技能」这一层时,系统按技能本身精确匹配,而不是放宽到「整个应用」——否则同一应用下的其它正常技能会被连坐掩码。这个粒度问题在实现中修正过。另外,当某一轮的资料信息没取到、导致「是否命中高敏感资料」判断不出来时,系统保守地按高敏感处理——本来也没有内容可展示,代价只是这一轮标记显示为高敏感。

第四步:占位符替换,外壳保留

高敏感技能命中后内容整块脱敏(红色为敏感内容脱敏示意)

判定为高敏感后,同一结果在三个地方同步生效:列表页、详情页、会话视图。

生效方式:内容整体替换成占位符,而「外壳信息」按白名单保留——技能标识、子技能、技能版本、来源、发布者、环境、应用 ID。

所以运营看到的是:哪一次对话、哪个技能、哪个版本、什么环境、被保护了;看不到:用户问了什么、模型答了什么、工具的参数和返回。打码不可逆,后台没有任何入口能还原原文——设计上刻意不给任何人豁免,超管在页面上看到的同样是占位符。

打个比方:对话记录像监控录像。给某些录像贴上「涉密」标签后,画面整体打码;但「外壳信息」——哪个技能、什么版本、哪个环境、什么时候——仍然保留,因为排障要用。

三层精细处理:命中轮整轮掩码、小纸条值级打码、敏感词句级打码

如果「沾到一次敏感就把整段对话全部打码」,运营会失去大量正常上下文——用户查完工资接着问天气,天气那轮也跟着废掉。所以我们做三层处理,按每一轮的实际情况分别对待:

这一轮的情况 怎么判断 打码到什么程度
① 命中名单(技能 / 资料 / 用户) 名单匹配 整轮变成占位符
② 不命中,但出现前面涉密轮里出现过的具体值 和小纸条比对 只打码那个值,其余正常看
③ 不命中,但话里带了敏感领域词 词表匹配 只打码那一句

第①层:涉密轮整轮打码,同时记一张「小纸条」

涉密轮做两件事:整轮内容变成占位符;同时从这一轮的原文里抽出出现过的敏感值,记成一张「小纸条」——比如手机号、身份证、银行卡、邮箱、工号,以及工资条上的金额数字。

小纸条有两个特点:只记值、不记原文;且只存在于这一次请求的内存里,不写数据库、不写日志、不进缓存。它的唯一用途是给后面几轮做比对。

第②层:后续轮正常展示,只对纸条上的值动手

后续轮次照常展示,但每一段文字都会拿小纸条比对一遍:出现了纸条上的值,就只把那个值打码,别的文字不动。所以「查完工资问天气」这轮不会被连累。

两个刻意的细节:

  • 纸条只对「涉密轮之后」的轮次生效。因为敏感信息是那轮之后才进入上下文的;否则更早的普通轮里一个巧合的数字(比如订单号)会被误打码。
  • 用户自己的提问也要过一遍纸条比对,但下面第③层的词表不适用于用户提问——口径是「提问可见,便于主管判断用户到底想干什么」。

第③层:敏感词(记忆回显)句级打码

先说实话:系统无法判断「模型是不是用了记忆」。记忆在模型那一侧,查询侧只看得到这一轮的输入和输出文字。所以这里的做法是事后从文字里找线索——文本里出现敏感领域词:工资、薪资、薪酬、实发、应发、五险一金、公积金、个税、工号、员工号、身份证、银行卡、护照号、驾照等等。

为什么要加这一层?它堵的是前两层都盖不住的泄露:模型把「工资条里部门字段异常」这类事实写进记忆,之后在完全无关的问题(「1+1 等于几」)的回答里又把它带出来。这种泄露两头都不占——没有命中高敏感技能(这一轮显示为正常),也没有逐字复现具体值(纸条比对盖不住),常规规则更管不着(里面可能一个号码都没有,只有「工资」这样的词)。

处理方式是按句子判断:一句话里只要出现敏感领域词,就只把那一句变成占位符。比如「北京明天晴 27 度。另外你上次问的工资条…」——只掩掉后半句,天气照常显示。

词表可以随业务扩充:通过配置追加(逗号或换行分隔),改完即生效,不用改代码、不用重启。这点很重要,因为这类判断本质上是「看到特征词就保守处理」的启发式:词表覆盖到的说法挡得住,换了同义说法、暗语就挡不住。所以我们一边留出加词的口子,一边在会话列表上给一个提示——「本会话含高敏感轮次,后续内容可能带出敏感信息」——把「后面可能还有残留」明确告诉使用者,而不是假装干净。

走一遍完整例子

  1. 用户问「我这个月工资多少」——命中高敏感技能 → 整轮变占位符;同时纸条记下这一轮出现的金额数字。
  2. 用户接着问「明天北京天气」——不命中 → 正常展示;只有回复里复现了纸条上的金额时,那处才被打码。
  3. 用户再问「1+1 等于几」——回复本身干净 → 正常展示。
  4. 但如果这条回复因为记忆顺口带出「另外你上次问的工资条部门字段有点异常」——纸条盖不住(措辞变了、值没复现)→ 领域词命中「工资」→ 只把这一句变占位符,同一条回复里其它内容照常显示。

边界与限制

  • 生效有 60 秒延迟:名单缓存导致的,不是故障。
  • 打码不可逆:这是目标而不是缺陷。需要看原文应走其它受控流程,不是在列表页面找还原入口。
  • 没有管理员豁免:任何角色都看不到被打码的内容。
  • 记忆回显靠特征词识别,属于启发式:换一种说法可能识别不到,所以词表需要随业务补充。
  • 拿不准时宁可多掩:名单接口不可用时退回兜底名单(保证查询可用);判定所依赖的数据取不到时,按高敏感处理(宁可多掩,不漏掩)。
  • 原文的可见范围极小:只在单次请求的内存里用于比对,不落库、不落日志、不进缓存。
  • 标记可追溯:确认时会记录标记原因,便于事后复盘「为什么这个技能是敏感的」。

判定精确到轮:命中轮永锁 + 延续窗口

前面说的是「按轮判定」的总体框架。具体到运营「客户对话」的技能维度判定锚点,经历了两次演进:

  • 会话级技能 key(最新一轮技能身份):最新一轮换成非敏感技能后,历史敏感轮会跟着解锁,存在敏感明文暴露窗口;
  • 会话级技能全集锚点(曾用技能全集,只增不减):任一命中即整会话永锁,修复了「翻转解锁」,但长会话后续纯闲聊轮也一直被锁,运营无法看到非敏感轮次。

本次改造把技能维度判定精确到「轮」。一次会话内,每个用户提问及其后续 assistant / tool / error 消息构成一轮。每轮的锁定状态按以下规则计算:

  • 命中轮:该轮助理消息的技能命中高敏感技能目录 → 该轮永久锁,明文永不恢复;
  • 延续窗口:自最近一次命中轮起 2 轮内(含命中轮)仍锁,模拟「上下文还带着敏感 tag」;
  • 窗口外:未再命中的新轮放开,正常显示明文;
  • 再命中:该轮永久锁并重置窗口,之前已放开的轮不受影响。

简单示意:

轮次       是否命中敏感技能   最终锁定状态   说明
第 1 轮    否                放开           未命中、无延续
第 2 轮    是                永久锁         命中轮,明文永远掩码
第 3 轮    否                锁             延续窗口 1
第 4 轮    否                锁             延续窗口 2
第 5 轮    否                放开           窗口滑出,新轮正常显示
第 6 轮    是                永久锁         再次命中,重置窗口;第 5 轮保持放开
第 7 轮    否                锁             新的延续窗口

fail-closed 安全底线

命中轮的敏感状态与掩码一旦建立,不随窗口滑出解除——历史敏感轮明文在任何情况下不恢复。 判定所依赖的数据取不到时也一样按敏感处理(宁可多掩,不漏掩),保证技能维度不失效、不漏判。

降级回退链:增量数据逐步生效

数据条件 判定路径 效果
会话存在消息级技能数据 逐轮判定 命中轮永锁 + 延续窗口 + 窗口外放开
无消息级技能数据(老会话 / 列缺失) 回退会话级技能全集判定;全集为空再回退单值 任一命中 → 整会话锁(fail-closed,技能维度不失效)
用户维度命中高敏感名单 会话级强制锁(最高级) 所有轮一律锁,不随窗口放开
知识库维度命中 会话级强制锁 所有轮一律锁(引用锚点无法定位轮次,保守整会话掩码)

消息级可定位轮次,全集为老会话兜底,保证存储格式演化期不漏判;用户身份敏感与知识库引用敏感属会话级事实、无逐轮依据,保持最高优先级。前端已按逐轮结果渲染(敏感轮整块蒙版、普通轮正常渲染),后端逐轮输出后前端无需额外改动。

关键取舍:为什么命中轮永锁,而不是滑窗解锁

  • 命中轮永久锁(采纳):历史敏感内容不允许解锁。被否方案是「窗口滑出后整段解锁」——但那样会重新引入敏感明文暴露窗口:最新一轮换成非敏感技能后,历史敏感轮跟着解锁,正是最初要修的问题。
  • 窗口大小固定 2 轮:作为可配置常量,近似「上下文还带着敏感 tag」的短期延续;后续可接 agent 上下文截断信号做精确驱动。
  • 明确非目标:不做内容级敏感识别(不读对话正文判断是否涉敏);不改变用户维度 / 知识库维度的整会话锁语义。

验收上,新增的逐轮用例覆盖了命中轮永锁、窗口内锁、窗口外放开、再命中重置窗口且已放开轮不被回锁、无消息级数据回退全集、用户维度整会话锁等场景,全部通过。

演进:窗口内技能识别——一次方案评审我们学到了什么

判定精确到轮之后,还有一个更底层的漏掩场景:只按「本轮激活的技能」判断,会漏「记忆里还摊着的技能」。

用大白话说:模型干活之前,会先去「翻技能说明书」。翻过之后,说明书的内容就留在它的上下文窗口里。接下来几轮,它不用再翻,直接照着记忆就能回答。过去的掩码判定只看「这一轮它翻了哪本说明书」——它一旦不翻了,系统就认为「这轮没碰到敏感技能」,把本该掩码的对话当成普通对话展示出来:

轮次 模型的动作 只按「本轮翻了哪本」判定的结果
第 N 轮 翻开《工资查询》说明书 识别为敏感(正确)
第 N+1 轮 不翻,照着记忆回答工资明细 判为普通(漏掩)
第 N+2 轮 不翻,继续照着记忆回答 判为普通(漏掩)
说明书被挤出记忆后 已经不依赖它 判为普通(合理)

问题成立,方案的真源判断——「桌上还摊着哪些书」比「这轮翻的是哪本书」更准——是对的;它正好补上「命中后第 3 轮起、被压缩之前」这一段,而这一段现在只能靠固定的 2 轮窗口去猜。

两套做法的对比

维度 现状(本轮激活技能) 方案(窗口内技能)
信息来源 「这一轮翻开了哪本说明书」(本轮激活的技能) 「记忆里还摊着哪些说明书」(窗口内出现过、且还在上下文里的技能)
判定位置 运营后台的会话数据库 调用链(Trace)元数据,与评估侧同源
掩码时效 命中这一轮永久锁 + 后面固定几轮(近似) 只要还在记忆里就一直算敏感,直到内容被压缩挤出去

三条最关键的差别:

  1. 信息来源更准:从「这轮翻了哪本书」升级成「桌上还摊着哪些书」,补上了现有固定窗口靠猜的那一段。
  2. 判定位置换了地方会带来新问题:判在元数据上,就变成两套标准——同一个会话,在「客户对话」页和 Trace 页可能显示不同结论,运营同学会困惑。
  3. 掩码时效明显变长:压缩前可能有几十轮都标敏感,更安全,但「普通对话」的区分度会明显下降。方案否掉了「压缩后永久标」,但没有处理「压缩前长期标」这半个问题。

评审方法论:逐条对代码 + 线上数据核对

这份方案在落地前做了一次逐条评审,方法值得沉淀:

  • 逐条对代码核对:方案里写明的类名、字段名、文件路径必须全局搜索验证是否真实存在——结果是方案里的好几个类名在代码库里完全不存在,真实实现在另外几个文件里,落实时要按真实实现改写。
  • 没有的机制不能硬做:方案假设「说明书正文被直接注入对话」,实际上注入的只是「技能目录清单」(名字和路径),正文是模型自己翻进来的。如果按「清单」来判定,等于把整个应用挂载的所有技能都标成敏感——正是方案自己在「不做的事」里要避免的那种误标。
  • 用线上数据说话:方案用的「上下文变量」发布机制在这个项目里已经被证明行不通——写入发生在子任务里,读出发生在主任务里,读不到。这不是推测,是刚踩过的坑:修复前,消息表里数万条消息的技能字段全是空的,根因就是这个;「完全同款」的知识库发布,线上数千个会话里数百个真用过知识库检索,其中只有个位数写上了字段(不足 1%)。照抄这套机制,新字段大概率恒为空,方案想达到的效果直接落空。
  • 上下文变量跨任务失效是坑:委派出去的子任务有独立的执行上下文,在主任务里读不到子任务写入的属性——这类机制从设计上就要回避。
  • 一轮内多次调用要取并集:一轮里模型可能被调用多次,只留最后一次会把先前窗口里的技能信息覆盖丢掉,应取并集。
  • 扫描成本需要缓存:每轮扫全部历史消息里的工具调用、每条都要解析路径、还可能读一次磁盘文件,需要去重和记忆化,否则每轮都在重复做无用功。
  • 压缩后回读历史会漏:路径识别只认应用技能目录和员工技能目录两种开头,历史记录文件的路径两者都不是——压缩之后确实会漏,「不存在实质漏洞」的论证不成立。
  • 子任务覆盖盲区:子任务有自己的消息列表,如果不在同一个列表里,主会话就扫不到子任务翻过的说明书,需要明确算不算。
  • 不做的事要守住:不扫工具返回值、不扫注入后的系统提示——扫描这两处会把无关内容当成技能,造成大面积误标。这个边界与现状一致,方向对。

顺带发现一个更紧急的问题:知识库维度的高敏感掩码很可能从上线起就没真正生效过——判定要读的字段跟技能字段是同一个失效原因(上面那组数据就是证据)。建议单独立项优先修复,优先级高于本方案。

推荐做法:以会话持久化为唯一标准

最终推荐的落地路径不是照搬方案,而是把「窗口内技能」落到会话数据库、作为唯一标准:

  • 只有一个标准:运营「客户对话」页和 Trace 页共用一套判定,不会出现同一个会话两种结论。
  • 可回溯:历史会话能查、能复盘,便于事后核对判定是否合理。
  • 改动更小:不用新开一条元数据链路;现有「命中后固定几轮」的机制可以退化成兜底下限,甚至直接取消,改用更准的窗口信号。

如果坚持写进调用链元数据,需要五处修正:换掉发布机制(用跨任务注册表,别用子任务里写入的上下文变量)、一轮内累积而不是覆盖、设窗口上限(或与压缩边界取较小值)、把历史记录路径纳入识别(否则压缩后必然漏判)、明确子任务算不算在内。

评审还拍板了三个问题,都是产品口径问题:唯一标准放在哪边(会话数据库——两页一致、可回溯,还是调用链元数据?两边并存会长期给出两种结论);一次掩码管多久(固定几轮、上限 N 轮,还是「一直在记忆里就一直敏感」?这直接决定普通对话的区分度);压缩后的漏判是否接受(把历史记录路径纳入识别,或者直接用已上线的会话级锚点兜底锁兜住)。

小结:几条安全设计原则

读到这里,其实已经可以提炼出几条通用的安全设计原则:

  1. fail-closed(宁可多掩,不漏掩):判定所依赖的数据取不到时,按高敏感处理;名单接口不可用时退回兜底名单;打码不可逆是目标而不是缺陷。
  2. 逐轮判定,不连坐:只有命中轮被整轮掩码,后续普通轮正常展示;「沾到一次就整段掩码」会毁掉运营的排障上下文。
  3. 降级不放开:存储格式演化期,老会话从消息级回退到会话级全集、再回退到单值,方向永远是「更多掩码」而不是「更少掩码」;命中轮的明文在任何情况下不恢复。
  4. 名单 + 缓存的一致性:三份名单(技能 / 知识库 / 用户)各自维护、叠加生效;查询侧 60 秒缓存保证单接口批量拉取,兜底名单保证可用性;对「标签存哪、怎么传、多久生效」要能一句话说清。
  5. 人参与关键决策:模型只「提建议」,主管「确认」才算生效——标错的代价两头都不小,让人拍板是折中。
  6. 为演进保留余地:固定窗口只是近似,等「上下文是否还带敏感 tag」的显式信号就绪后可以精确驱动;词表可配置追加、窗口大小可配置化,都是同一个思路——机制先上线,参数后调优。

这套机制的核心不是一个玄妙的算法,而是一组朴素但严格的原则:判定精确到轮、任何情况下不还原明文、数据不足时向安全一侧倾斜。对于任何允许第三方能力接入、又必须保护用户隐私的平台,这组原则都值得复用。