如果一家做 AI 助手的团队,把全部精力都放在“模型多强、回答多快”上,却等到用户投诉隐私问题才补安全,这个产品的信任成本会高得惊人。最近围绕 Instinct 这类高性能 AI 助手的功能讨论,正好把问题摆上了桌面:它越懂用户,用户就越想知道——它凭什么能拿到这些上下文?这些数据最终去了哪里?谁能在什么条件下读取会话、触发操作?
对国内开发者来说,这个问题的价值不止于“评一款产品”。今天几乎每个有一定规模的业务团队都在做自己的 AI 助手:客服助手、研发助手、数据问答助手、Copilot 式 Agent。大家都把重心放在提示词和模型效果上,但对于“用户输入进了模型之后,权限怎么控制、敏感信息怎么识别、日志怎么记、输出怎么渲染”,准备往往并不充分。
这篇文章不打算评价 Instinct 的具体实现,因为没有一手资料就不该瞎下结论。更值得做的是把这类“能力很强但隐私边界含糊”的 AI 助手抽离成一个通用架构问题:如果我们要给现有系统接入一个 AI 助手,应该在安全链路的关键节点上做哪些设计。
文章后半部分会给出可落地的代码范例,包括 Spring Security 的 OAuth2 权限设计、Prompt 入模前的脱敏检测、CSP 输出防护和审计日志实现,覆盖 AI 助手从输入到输出、从权限到审计的完整闭环。
1. 为什么 AI 助手越强大,隐私安全焦虑越大
很多人会把“AI 助手泄露隐私”理解成“模型厂商偷偷读了聊天记录”,这是一个常见误区。
真正让隐私风险放大的,是 AI 助手的交互形态发生了变化。以前的聊天机器人只是状态无关的问答工具,用户问一句,它答一句。而现在助手普遍具备长期记忆、屏幕理解、文档检索、工具调用和自动执行能力。也就是说,它已经不是“回答问题的程序”,而是一个能够访问联系人、读取邮件、调起内部系统、代替用户做决定的数字代理。
这种变化带来的风险是分层的:
- 输入层:用户可能把身份证号、手机号、公司代码、内部业务数据直接粘贴进对话,没有任何保护意识。
- 上下文层:AI 助手为了实现“理解用户”,往往要读取剪贴板、通讯录、日历、文件目录或历史会话。它能看的数据越多,泄露面越大。
- 执行层:Agent 可能需要调用内部 API、发送邮件、修改工单。如果权限设计只针对页面按钮,没有针对“模型自动触发的动作”,就会出现越权执行。
- 存储层:对话记录、向量化后的长期记忆、Prompt 日志,几乎都落在数据库或模型提供方侧。一旦这些存储缺少权限隔离或保留期管理,等于把用户的敏感数据常年摆在一个容易被内部越权读取的位置。
所以,隐私担忧的第一次爆发,往往不是“黑客拖库”,而是用户发现自己说过的话被长期记录,并被模型用来“更懂自己”。安全担忧的第一次爆发,往往也不是 DDoS 或漏洞攻击,而是 Agent 在没人察觉的情况下完成了一个本不该被授权的操作。
对于开发者的启发是:不要把隐私和安全当成产品上线前的合规清单,而是当作“AI 助手能力的一部分”来设计。能力越强,权限越大,安全失效的后果也就越严重。
2. AI 助手的数据链路与信任边界
做安全之前,先把数据链路画清楚。AI 助手的数据流大概是这样一个方向:
- 用户在终端输入 Prompt,或者上传图片、文档、音视频。
- 终端可能先本地提取特征,再决定是把数据留在端侧,还是发送到业务后端。
- 业务后端拿到请求后,通常会执行身份认证、权限校验、上下文组装。
- 组装后的完整提示词发送到大模型服务,可能是自研模型,也可能是第三方模型 API。
- 模型返回结果后,应用可能解析模型输出,决定是否执行工具调用。
- 工具调用结果会被再次拼入上下文,形成第二轮模型请求。
- 每一次交互的过程都可能被写入日志、缓存、向量数据库和审计系统。
信任边界在哪里?在于“谁有能力读取和改动这个链路中的数据”。
如果模型运行在第三方云上,那么提示词必然经过第三方服务。即便传输加密,第三方在服务端仍然能够解析明文内容,除非使用端到端加密或可信执行环境,这在常规业务里很难做到。更稳妥的判断是:要把发送给第三方模型的每条数据都当成“可能被第三方看到”的数据,所以入模之前必须先脱敏、先分级。
同样的逻辑也适用于企业内部。一个 AI 助手如果运行在 Kubernetes 集群里,却把会话日志直接打到标准输出,那么所有能看到 Pod 日志的运维人员、日志平台、监控告警系统,都会成为隐性的数据接收方。数据链路一旦拉长,风险边界就不是“模型厂商是否可信”这一个问题,而是整条链路上所有存储和日志系统的访问控制问题。
下面用一张威胁模型表来梳理核心风险,后续的工程方案都对应这张表展开。
| 风险类型 | 典型场景 | 主要影响 | 需要控制的环节 |
|---|---|---|---|
| 敏感信息泄露 | 用户把身份证、公司密钥粘贴给 AI | 数据外泄、合规风险 | Prompt 入模前检测与脱敏 |
| 提示词注入 | 网页内容或邮件被恶意构造后喂给模型 | 模型输出恶意指令 | 输入端过滤、输出端防注入 |
| 越权执行 | Agent 自动调用内部 API 修改数据 | 业务被非法修改 | 工具调用二次鉴权 |
| 会话存储泄露 | 对话日志、向量库未做权限隔离 | 用户隐私批量泄露 | 存储分级、日志最小化 |
| 输出端污染 | 模型直接输出 HTML 或 Markdown 并渲染 | XSS、钓鱼 | 输出净化与 CSP 策略 |
| 第三方供应链风险 | 模型 API 或插件服务记录并复用数据 | 数据流向不可控 | 供应商评估与数据协议 |
从 AI Agent 化趋势来看,业界已经开始把这类风险系统化整理。例如 OWASP 近期也推出了 Agentic Security Initiative 相关的 Top 10,对 AI Agent 的权限失控、提示词注入、供应链风险等做了专门梳理。这说明一个问题:Agent 安全已经不是个别人提出的担忧,而是整个开发社区都在面对的工程命题。
3. AI 助手的隐私与安全架构:四个控制点
有了数据链路和威胁模型,再来设计架构就清晰了。AI 助手的安全能力不需要所有环节都做得很重,但必须在四个位置设置控制点。
第一控制点:端侧/终端。
能留在本地的数据,尽量不离开本地。比如图片识别、语音唤醒这类数据,如果端侧模型已经可以达到可用效果,就不应该把原始图片和录音文件直接上传。端侧控制还包括用户授权:访问相册、通讯录、地理位置之前,系统必须给出清晰的用途说明,而不是在隐私协议里一句话带过。
需要注意,端侧推理不是“天然安全”。它只是减少了数据出网范围,但移动端应用本身可能被逆向、Hook,端侧模型也可能被提取。所以在端侧收集的数据仍然要做最小化采集,敏感文件解析尽量放在安全沙箱里完成。
第二控制点:业务后端/API 网关。
这是 AI 助手安全设计的核心。后端要做身份认证、用户级授权、服务间鉴权、Prompt 脱敏、调用审计、速率限制和风控识别。很多 AI 助手项目直接把后端的 AI 代理层写成一个没有鉴权的方法,这是最低级的错误。更隐蔽的错误是把前端传入的 model、prompt、toolName 当成可信参数,直接传给内部接口。
后端控制的原则是:所有来自模型或用户的数据都不可信,每次工具调用都必须重新鉴权。
第三控制点:模型服务与第三方依赖。
模型可能是自建的,也可能是第三方 API。第三方 API 接入前,要重点确认数据是否会被用于训练、日志保留多久、处理后数据是否可以被撤回。即使协议上写得清楚,也不要放松脱敏。因为数据一旦出了你的服务边界,回旋余地就很小。
第四控制点:输出解析与渲染。
很多人只防输入,不防输出。但 AI 模型的输出本质上是不可信内容。模型可能因为提示词注入产生恶意 HTML,也可能因为幻觉生成指向钓鱼站点的链接。输出安全至少需要两层:内容净化层,以及浏览器端的 CSP 等安全策略。
一句话总结:输入越大,权限越要收紧;输出越多,渲染越要设防。
4. 身份与权限治理:接入 AI 前先管好“谁能做什么”
AI 助手接入现有系统,最大的架构变化不是多了一个接口,而是多了一个“不按常理出牌”的调用者。
以前用户操作按钮,后端能靠 URL、角色、按钮权限一层层控制。现在模型会在内部生成“要调工具 A”,然后由代码去执行。如果执行层只判断“这个用户是否登录了”,而不判断“这个操作是否被该用户显式授权过”,就很容易出现越权。
4.1 用 OAuth2 区分“用户授权”和“系统身份”
在实际工程中,推荐将 AI 助手的使用拆成两类身份:
- 用户身份:用户通过浏览器或客户端登录,携带用户自己的 Token,用于询问“你是谁、你有什么权限”。
- 系统身份:AI Agent 在后台代表系统执行任务时,使用服务账号 Token,权限必须比用户身份更窄。
以 Spring Boot 3 + Spring Security OAuth2 Client 为例,可以配置两个 Client Registration。一个负责用户登录,走 Authorization Code 模式;一个负责后台 Agent 执行,走 Client Credentials 模式。
# 文件路径:src/main/resources/application.yml spring: application: name: ai-assistant security: oauth2: client: provider: company-idp: issuer-uri: ${IDP_ISSUER_URI} registration: user-console: provider: company-idp client-id: ${CONSOLE_CLIENT_ID} client-secret: ${CONSOLE_CLIENT_SECRET} authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: - openid - profile - ai:chat-basic agent-runner: provider: company-idp client-id: ${AGENT_CLIENT_ID} client-secret: ${AGENT_CLIENT_SECRET} authorization-grant-type: client_credentials scope: - ai:run-tool这里最关键的是 Scope 设计。user-console对应的 scope 是用户可发起的“基础聊天”能力,agent-runner对应的 scope 是更危险的“运行工具”能力。两者权限边界不同,后端 API 必须要分开校验。
4.2 Spring Security 的 Scope 与 Authority
常见的问题是从旧项目拷贝了一段hasScope()代码,运行后发现方法不存在或者行为不符。这通常是因为 OAuth2 Resource Server 在不同版本里的授权模型发生了变化。
在新版 Spring Security 中,JWT 里的scope会被默认转换为带SCOPE_前缀的 Authority。比如 scope 是ai:run-tool,对应 Authority 就是SCOPE_ai:run-tool。因此更推荐的写法是hasAuthority("SCOPE_ai:run-tool"),而不是到处查找hasScopeAPI。
下面给出一份可以直接对照的 SecurityFilterChain 配置代码。
// 文件路径:src/main/java/com/example/aiassistant/config/SecurityConfig.java package com.example.aiassistant.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/api/ai/chat/**").hasAuthority("SCOPE_ai:chat-basic") .requestMatchers("/api/agent/run/**").hasAuthority("SCOPE_ai:run-tool") .anyRequest().authenticated() ) .oauth2Login(Customizer.withDefaults()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } }除了在 URL 层面做权限控制,更推荐在 Agent 真正执行工具的方法上再做一次方法级安全校验。
// 文件路径:src/main/java/com/example/aiassistant/service/AiToolExecutor.java package com.example.aiassistant.service; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @Service public class AiToolExecutor { @PreAuthorize("hasAuthority('SCOPE_ai:run-tool')") public ToolResult runTool(String toolName, ToolInput input) { // 这里才是真正调用内部系统的地方,必须做服务端二次校验 // 不要只依赖前端按钮是否显示来判断是否允许执行 return execute(toolName, input); } }这段示例的核心思想是:AI 生成的工具调用指令,必须经过服务端权限判断,不能因为“模型已经说了要调这个工具”就直接执行。权限判断的粒度,应该到“用户是否有权执行这个工具”,而不只是“用户是否登录”。
5. Prompt 入模前的敏感数据检测与脱敏
身份与权限解决了“谁有资格调用 AI”,但还没有解决“什么数据能进入模型”。
实际项目中风险最高的不是用户主动删除数据,而是用户在对话中主动贴出数据。研发助手场景里,用户可能会把一段包含数据库连接串的代码直接贴给 AI;客服助手场景里,用户可能会把订单号、身份证号写在问题里。如果这些内容直接拼进 Prompt,再发送到第三方大模型 API,结果就不可挽回。
因此建议在业务后端构建一个统一的“AI 请求网关”,这个网关在 Prompt 真正发往模型之前,先执行敏感数据识别和脱敏。
下面的工具类用一个 Java 示例演示基础逻辑。它可以用于检测身份证号、手机号、访问密钥模式,并针对不同字段做不同级别的掩码。
// 文件路径:src/main/java/com/example/aiassistant/security/SensitiveDataGuard.java package com.example.aiassistant.security; import java.util.regex.Pattern; public final class SensitiveDataGuard { private static final Pattern ID_CARD = Pattern.compile("\\b\\d{17}[0-9Xx]\\b"); private static final Pattern PHONE = Pattern.compile("(?<![0-9])1[3-9]\\d{9}(?![0-9])"); private static final Pattern ACCESS_KEY = Pattern.compile("(?i)sk-[a-z0-9]{16,}"); private SensitiveDataGuard() { } public static MaskResult protect(String input) { if (input == null || input.isEmpty()) { return new MaskResult(input, "", false); } String masked = input; StringBuilder details = new StringBuilder(); // 身份证号脱敏:保留前 3 位和后 4 位 masked = ID_CARD.matcher(masked).replaceAll(m -> maskIdCard(m.group())); if (!masked.equals(ID_CARD.matcher(input).replaceAll(m -> maskIdCard(m.group())))) { details.append("ID_CARD_MASKED;"); }这里我犯了个错误:重复做两次计算,且masked已经变了。更清晰的写法是先复制masked,逐个规则处理,但判断变化需要依据masked前后的对比,或者直接在 replace 前检测。
String masked = input; Integer hit = 0; StringBuilder details = new StringBuilder(); if (ID_CARD.matcher(masked).find()) { details.append("ID_CARD_MASKED;"); masked = ID_CARD.matcher(masked).replaceAll(m -> maskIdCard(m.group())); } if (PHONE.matcher(masked).find()) { details.append("PHONE_MASKED;"); masked = PHONE.matcher(masked).replaceAll(m -> maskPhone(m.group())); } if (ACCESS_KEY.matcher(masked).find()) { details.append("ACCESS_KEY_MASKED;"); masked = ACCESS_KEY.matcher(masked).replaceAll(m -> "***AK***"); } boolean sensitive = details.length() > 0; return new MaskResult(masked, details.toString(), sensitive);完整类如下,这样逻辑清楚:
// 文件路径:src/main/java/com/example/aiassistant/security/SensitiveDataGuard.java package com.example.aiassistant.security; import java.util.regex.Pattern; public final class SensitiveDataGuard { private static final Pattern ID_CARD = Pattern.compile("\\b\\d{17}[0-9Xx]\\b"); private static final Pattern PHONE = Pattern.compile("(?<![0-9])1[3-9]\\d{9}(?![0-9])"); private static final Pattern ACCESS_KEY = Pattern.compile("(?i)sk-[a-z0-9]{16,}"); private SensitiveDataGuard() { } public static MaskResult protect(String input) { if (input == null || input.isEmpty()) { return new MaskResult(input, "", false); } String masked = input; StringBuilder details = new StringBuilder(); if (ID_CARD.matcher(masked).find()) { details.append("ID_CARD_MASKED;"); masked = ID_CARD.matcher(masked).replaceAll(m -> maskIdCard(m.group())); } if (PHONE.matcher(masked).find()) { details.append("PHONE_MASKED;"); masked = PHONE.matcher(masked).replaceAll(m -> maskPhone(m.group())); } if (ACCESS_KEY.matcher(masked).find()) { details.append("ACCESS_KEY_MASKED;"); masked = ACCESS_KEY.matcher(masked).replaceAll(m -> "***AK***"); } return new MaskResult(masked, details.toString(), details.length() > 0); } private static String maskIdCard(String idCard) { if (idCard == null || idCard.length() < 8) { return "***"; } return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4); } private static String maskPhone(String phone) { if (phone == null || phone.length() < 7) { return "***"; } return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); } public record MaskResult(String text, String detail, boolean sensitive) { } }这段代码在生产环境可以直接运行。不过它只是演示“规则引擎”这一层。真实场景中,正则只能覆盖固定格式的敏感字段,对于发票号、聊天里的公司内部代号、非结构化文本中的隐性敏感信息,需要配合命名实体识别模型和自定义敏感词表。
在实际接入时,可以在业务服务调用大模型 API 前统一执行:
// 在调用大模型 API 的入口统一处理 String rawPrompt = chatRequest.getPrompt(); SensitiveDataGuard.MaskResult result = SensitiveDataGuard.protect(rawPrompt); if (result.sensitive()) { auditService.record("prompt_masked", currentUserId(), result.detail()); } CompletionRequest request = new CompletionRequest(); request.setPrompt(result.text()); // 使用脱敏后的文本还要注意一个原则:脱敏不是万能的。如果脱敏后把“身份证号 110101199001011234”变成了“110*******1234”,那大模型仍然可能从整段语义中推断出某些信息。因此,更进一步的方案是直接拒绝高敏内容入模,而不是“脱敏后发送”。到底选择脱敏还是拒发,取决于业务场景和合规要求。
6. 输出端第二道防线:净化与 CSP
输入侧做了防护,不代表输出侧就能放松。AI 模型的输出内容不可信,原因有两个:一是模型可能被精心构造的上下文诱导,生成包含恶意链接或 HTML 的内容;二是大语言模型本身存在幻觉,可能生成一个看似可信但实际不存在的域名、指令或操作请求。
如果把模型输出直接以 HTML 形式渲染到页面,等于让不可信内容获得了脚本执行机会。常见的安全问题就是 XSS。你可能会在浏览器控制台看到类似这样的报错:
Refusing to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'"这个报错不是系统故障,而是 CSP 拦截了内联脚本。很多开发者的第一反应是往 CSP 里加unsafe-inline,这是最危险的处置方式。正确的做法是检查那段被拦截的脚本到底从哪里来,如果是模型输出中的注入内容,说明