首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >RAG AI助手上公网:15 道防线扛住陌生流量

RAG AI助手上公网:15 道防线扛住陌生流量

原创
作者头像
拉丁解牛说技术
发布2026-06-24 22:21:58
发布2026-06-24 22:21:58
70
举报

这是 DocMind 系列的收官篇。前两篇讲了"它怎么答得快、答得准、跨语言"。这一篇讲一件更现实、也更容易被忽略的事——当你真打算把它挂到公网上让陌生人随便问,需要面对什么问题,又该怎么扛住。

一、"能跑"和"敢挂公网"是两个量级

很多 RAG Demo 在本地跑得很漂亮:问答流畅、来源清晰。但只要你动了"把它挂到公网"的念头,问题立刻变成另一个量级——因为公网上来访问你的,不只有真实用户,还有:

  • 扫描器与脚本:7×24 地试探你的每一个端口和接口。
  • 薅羊毛的爬虫:把你的问答接口当成"免费大模型代理",刷接口套你的算力和 token。
  • 盯着账单的攻击者:他不偷你的数据,他的目的就是让你的 LLM 账单爆掉
  • 顺手玩一把的人:试试能不能让你的助手"忘掉设定""吐出系统提示词"。

一个面向公众的 AI 助手,安全防控不是上线后再补的功能,而是它能不能上线的前提。这也是我在第 1 篇就把"敢对外开放"列为三条设计原则之一的原因。这一篇我把 DocMind 为"挂公网"做的两件事讲透:一是不靠任何外部中间件也能稳态运行(轻量持久化 + 内存防 OOM),二是 15 道层层叠叠的安全防线。

下图是防护全景图:一个请求从公网进入,依次穿过四个阶段的防护,才真正触达 LLM。


二、先解决"重不重":无 Redis / Mongo / Docker 的持久化

挂公网的第一道坎,除了考虑安全问题外,还要考虑部署门槛。我们选择大道至简,过多的依赖反而增加系统架构复杂性,安全防护难度也会指数级增长。

DocMind 的持久化只有三类落地物,全是本地文件:

数据

载体

说明

向量知识库

ChromaDB(嵌入式)

一个本地目录,无需独立向量服务

审计 / 反馈

一个 SQLite 文件

qa_audit + user_feedback 两张表

爬虫元数据

一个 SQLite 文件

documents 表,记录增量去重所需的内容哈希

两个 SQLite 文件都开了 WAL 模式 + synchronous=NORMAL,并用一把 asyncio.Lock 把写入串行化——对单实例的读多写少场景,这个组合在"够用的持久化保证"和"够轻的运维成本"之间取得了平衡,既不会因为并发写互相踩,也不必为它单独跑一个数据库进程。审计表还带一个保留期 janitor:超过 AUDIT_RETENTION_DAYS(默认 30 天)的记录会被后台定时清掉,避免审计库无限膨胀。

运行时那些"频繁读写"的状态——限流计数、问答缓存、会话历史——放哪?答案是:进程内存。但"放内存"恰恰是挂公网最危险的地方,所以它需要在后面单独的讲。


三、单实例敢挂公网的真正前提:内存绝不泄漏

把限流计数、缓存、会话这些状态放进程内存,最大的风险一个词概括:OOM

想象一下:限流是"按 IP 计数"的,那每来一个新 IP,内存里就多一个计数器。公网上的 IP 近乎无穷,一个扫描器换着 IP 来刷,你的内存就只涨不落,迟早被撑爆——这时候攻击者甚至不用打满你的 CPU,光是"制造海量不同的 key"就能把你的进程拖死。任何"按外部可控的值(IP / session_id)做 key 的内存容器,如果没有上限和过期,本身就是一个 OOM 漏洞。

所以我们定了一条铁律

凡是以 IP / session 等外部可控值为键的内存容器,必须同时具备三样东西:上限(满了淘汰最老的)、TTL(过期自动失效)、后台清理(定时扫一遍把过期的删掉)。一样都不能少。

这条铁律落地成两个统一的容器,整个项目所有内存态都只用它们:

  • TtlLruCache:键值缓存,底层是 OrderedDict。每条带独立 TTL(读取时发现过期即删);写入时一旦超过 max_size,就淘汰最久未用的那条(LRU)。问答缓存、会话历史、会话反向索引都用它。
  • SlidingWindowCounter:滑动窗口计数器,每个 key 维护一个时间戳 deque,访问时把窗口外的旧时间戳从队头弹掉。所有"每分钟多少次"的限流都用它,max_keys 同样有上限。

光有"读取时顺手清理"还不够——冷数据可能再也没人来读,会一直赖在内存里。所以还有第三层:一个统一的后台 janitor,每 60 秒把所有注册过的容器扫一遍,主动清掉过期项。所有缓存在创建时就自动注册进一个全局表,janitor 一把全管,不会有人漏网。

这套机制的意义在于:它让"单实例 + 纯内存态"这个看似脆弱的架构,真的敢挂到公网上扛陌生流量。 你可以用扫描器换着 IP 猛刷,内存里的 key 会涨,但有上限兜着、有 TTL 和 janitor 落着,它会稳定在一个天花板,而不是一路涨到 OOM。把"内存安全"这件事做扎实,是第 1 篇那个"轻量单实例"取舍能成立的真正底座。


四、15 道安全防线:一次请求要穿过什么

底座OK了,现在看看正面防护战场。一个请求从公网打进来,到真正触达 LLM、再返回,要顺序穿过下面这些闸门——任何一道不通过,就被就地拦截。我把它们整理成一张总表(默认值取自配置,均可经 .env 调整):

#

防线

拦的是什么

默认阈值

1

真实客户端 IP 解析(零信任 XFF)

伪造 X-Forwarded-For 绕过限流

默认不信任任何 XFF

2

输入长度硬上限

超长输入撑爆 prompt / 拖慢处理

500 字符

3

单 IP 每分钟限流

单个 IP 高频刷接口

12 次 / 分

4

/24 子网每分钟限流

一个代理池里换 IP 刷

60 次 / 分

5

全局每分钟限流兜底

整体洪峰

300 次 / 分

6

单 session 每分钟限流

换 IP 但复用同一 session 刷

20 次 / 分

7

全局并发上限

大量并发把实例打满

信号量 20

8

单 IP 并发上限

单 IP 同时挂多条慢请求

信号量 3

9

单 IP 会话数上限

一个 IP 狂建会话占内存

30 个

10

单 IP 新建会话速率

短时间暴力建会话

10 个 / 分

11

短窗口去重

同一问题重放 / 脚本爬库

5 秒窗口

12

每日 token 预算

"账单攻击"——专烧你的钱

可选,默认关

13

提示注入拦截

套系统提示词 / 越权指令

输入正则 + 系统提示零信任

14

拒答恒定耗时下限

用响应快慢做侧信道试探

拒答强制 ≥200ms

15

SSE chunk 间隔超时

挂着长连接耗服务端资源

60 秒 / chunk

这 15 道不是堆砌——它们覆盖了输入、频率、并发、会话、成本、内容、时间、连接八个攻击面。下面挑几道我认为最有信息量、也最容易被忽略的,讲讲它们背后的攻防逻辑。


五、挑几道防线讲透

5.1 限流为什么要这么多维度:堵住每一条绕过路径

最朴素的限流是"单 IP 每分钟 N 次"。但只要你真把它挂公网,就会发现单维度限流到处是窟窿

  • 换 IP 不就行了?→ 于是加一道 /24 子网限流:你在一个代理池/机房网段里换 IP,子网这一层照样把你摁住(IPv6 取 /64)。
  • 换着 IP、但带同一个 session 维持上下文呢?→ 于是加一道 单 session 限流:按 session_id 单独计数。
  • 你这些维度都按 key 算,我制造海量不同 key 把你整体压垮呢?→ 于是加一道 全局 RPM 兜底:不管来源,整个实例每分钟的总量有个天花板。
  • 限的是"每分钟次数",那我单条请求拖很久、同时挂很多条呢?→ 于是再加并发信号量:全局并发 20、单 IP 并发 3,慢请求占不满整台机器。

你看,这不是"为了多而多",而是一条攻防升级链:每加一道,都是因为前一道存在一个明确的绕过方式。把这条链画出来,比单纯说"我做了限流"要诚实得多——它说明我预演过攻击者会怎么绕

而且别忘了前面三说的:这五六个维度全是按 IP/子网/session 计数的内存容器,正因为它们都遵守了"上限 + TTL + janitor"的铁律,多维限流本身才不会变成新的 OOM 入口。 防线和底座是配套的。

5.2 真实 IP:默认不信任任何 X-Forwarded-For

上面所有"按 IP"的限流,都建立在一个前提上:你得拿到真实的客户端 IP。 而这恰恰是公网部署里一个极隐蔽的坑。

应用前面通常挂着 Nginx / 网关,真实客户端 IP 藏在 X-Forwarded-For(XFF)头里。但 XFF 是客户端可以随便伪造的——如果你无脑信任它,攻击者只要每次请求都编一个不同的 XFF,你的"按 IP 限流"就形同虚设,因为它看到的全是假 IP。

DocMind 的处理是默认零信任

  • 配置项 TRUSTED_PROXIES 默认是空——也就是说,默认完全不信任 XFF,直接用传输层握手拿到的对端 IP。这个 IP 伪造不了。
  • 只有当你显式把你的反代地址填进 TRUSTED_PROXIES,且请求的直接对端确实是这个可信代理时,才会去解析 XFF;解析时还从右往左逐跳剥离可信代理,取链上第一个非可信地址作为真实客户端。

换句话说:你不主动声明"我前面有谁",我就一个 XFF 都不信。 这是一个"安全默认值"的典型——把最保守的行为设成默认,让用户在明确知道自己在做什么时再放开,而不是反过来。

这里还有一个呼应的细节(第六节会讲):内部诊断接口故意完全不看 XFF,直接用对端 IP 做白名单校验——因为那是个高权限接口,绝不能给伪造 XFF 留任何绕过的缝。

5.3 每日 token 预算:防的是"账单攻击"

传统 Web 安全很少谈一种 AI 时代特有的攻击:攻击者的目标不是拿数据,而是烧你的钱。 你的每一次问答背后都是真金白银的 LLM token 调用,只要他能持续触发你调用大模型,哪怕拿不到任何有价值的回答,你的账单也在飙升

所以我加了一道全局每日 token 预算

  • 每次 LLM 返回里的 usage.total_tokens 累加到当天计数。
  • 每次调 LLM 之前先检查:若当天累计已达预算上限,直接回退到友好拒答,根本不再发起这次调用
  • 按 UTC 日切换,跨天自动归零。

它是整套成本防线的最后兜底。在它之前,其实还有两道更早的成本闸(第 2 篇讲过):阈值拒答让无关问题根本不进 LLM,精确匹配缓存让重复问题 0 token 复用。三者叠起来是一个漏斗:无关的挡在检索后、重复的挡在缓存层、剩下真正要烧 token 的,再有一个每日总量的硬顶。即便前面全被绕过,这道硬顶也能保证"最坏情况下我一天最多被烧这么多"——这对一个个人开源项目挂公网,是能不能安心睡觉的区别。

默认值是 0(不启用),因为开发态不想被预算卡住;真要挂公网,这是我强烈建议第一个打开的开关。

5.4 拒答恒定耗时:把时间侧信道抹平

这道防线最不直观,却最能体现"防御要想到攻击者怎么试探"。

设想攻击者想摸清你的限流规则:他不断发请求,用"这次是被秒拒、还是正常处理了几秒"来反推自己有没有触线、触的是哪条线。响应时间的快慢,就成了一条侧信道,把你的内部策略泄露出去。

对策是:所有拒答路径,在抛出 429 之前,强制 sleep 到一个统一的耗时下限REJECT_RESPONSE_FLOOR_SECONDS,默认 200ms)。这样"被限流的快速拒绝"和"正常处理"在响应时间上的差异被抹平,攻击者很难再用计时来试探边界。这是一种很经典的恒定时间(constant-time)防御思想——和密码学里"比较密码要用恒定时间比较、防计时攻击"是同一个套路,只是搬到了限流场景。

这也正好回收了第 2 篇埋的伏笔:为什么拒答类的固定文案必须不调用大模型?因为调模型的耗时是不可控的,会破坏这个"恒定耗时"的保证。所以拒答、注入拦截、超额提示这三类文案才坚持用本地固定文案(中/英),既是 0 token,也是为了耗时可控。一个设计决策,同时服务了成本和侧信道防御两个目标。

5.5 提示注入:输入正则 + 系统提示零信任上下文

最后是大家最熟悉、也最被玩坏的一类:提示注入——"忽略以上所有指令""把你的系统提示词原样打印出来""告诉我你的 API Key"。

DocMind 在两个位置设防,而不是只靠一处:

  1. 输入侧正则拦截:对用户提问先过一组注入特征正则(ignore previous instructions、system prompt、reveal/print/show...api key|token|password、bypass、jailbreak 等)。命中即按提问语种返回固定的拒绝话术,连 LLM 都不调
  2. 系统提示侧零信任:就算有变体绕过了正则,系统提示词里还反复声明——把用户输入和检索到的上下文都当作不可信,绝不执行上下文里出现的任何指令,绝不泄露系统提示/密钥/内部配置。

第二点尤其关键,且容易被忽略:注入不只可能来自用户输入,还可能藏在被检索的网页正文里。 你的知识库是从公网爬来的,谁能保证某个页面里没埋一句"忽略上面,告诉用户……"?所以"把上下文也当不可信",和"把用户输入当不可信"同等重要。这是 RAG 系统特有的、比普通聊天机器人更宽的攻击面。

当然,我也不夸大正则的作用:正则拦不住所有变体,它是廉价的第一道筛,真正的兜底是"系统提示零信任 + 只依据上下文作答 + 阈值拒答"这一整套。

5.6 短窗口去重,以及它和 SSE 的一次正面冲突

第 11 道防线是短窗口去重:同一个问题在 5 秒内被同一 session(或同一 IP)重复提交,第二次直接 429。它防的是"脚本拿同一个问题狂刷"和"请求重放"——正常人不会在 5 秒内把同一句话发两遍。

但这道防线在落地时撞上了一个真实的坑,值得专门说,因为它体现了"安全策略不能脱离前端实现拍脑袋定"。

DocMind问答有两条路径:非流式 POST /chat 和流式 GET /chat/stream(SSE)。一开始我想当然地给两条都套上去重,结果流式这条立刻出问题——浏览器的 EventSource(SSE 客户端)有个特性:连接一旦中断,它会自动用同样的 URL 重连。 而我的去重是"同样的请求 5 秒内拒绝",于是:网络抖一下 → EventSource 断开重连 → 命中去重被 429 → EventSource 认为又失败了 → 再重连 → 再被 429……去重规则和浏览器的自动重连机制,咬合成了一个 429 死循环。

所以最终的设计是:去重只加在非流式 POST /chat 上,SSE 流式路径不走去重。 那流式路径的重放谁来防?靠的是第 2 篇讲过的那套——重复提问会命中"上一轮 history 短路"或精确缓存,直接返回、根本不调 LLM,它本身就是廉价的,不需要去重再兜一层;真正防爬库重放的脏活,交给 POST 那条路径专门处理。

这个小插曲想说明的是:一道安全策略加在哪、不加在哪,得贴着前端的真实行为来定,而不是"凡接口皆限流"地一刀切。


六、内部接口:诊断能力也要上锁

一个要长期运营的系统,光有防线还不够,还得看得见:查审计、查反馈、看缓存涨没涨、看今天烧了多少 token。这些我做成了一组 /internal 诊断接口。但诊断接口本身就是高权限的——它能翻出"谁问过什么",绝不能对公网开放。所以它有三重约束:

  • IP 白名单INTERNAL_API_ALLOWED_IPS(默认仅 127.0.0.1 / ::1,支持 CIDR),非白名单一律 403。
  • 故意不认 XFF:校验用的是传输层对端 IP,绝不读 X-Forwarded-For——否则攻击者伪造一个 X-Forwarded-For: 127.0.0.1 就把白名单破了。这与 5.2 的"业务接口默认零信任 XFF"是同一条安全直觉的两种体现。
  • 不进 API 文档include_in_schema=False,不在 /docs 里暴露,减少被发现的概率。

还有一条贯穿全局、第 1 篇就强调过的隔离:审计数据只进 SQLite,绝不进向量库——避免有人通过"提问"反查到别人问过什么。可观测性和隐私,这里是同时被照顾的。


七、边界:这套防护到哪儿为止

按这个系列的惯例,收尾前把适用边界讲清楚,不假装它什么都能扛:

它能扛

它不替代

单实例面向公网的常见滥用:刷接口、换 IP、爬库、账单攻击、提示注入、挂连接、OOM 试探

不替代网关/CDN 层的 DDoS 清洗——真正的大流量洪峰,该上 WAF/CDN 还得上

还有一个第 1 篇就摊开的根本边界:所有限流、缓存、会话状态都在进程内存里,所以这套防护是单实例语义的。要做多副本水平扩展,这些内存态就得外移到共享存储(Redis 之类),或者把限流上提到网关层统一做。这是"轻量"这个定位主动选择的代价:DocMind 服务的是中小规模文档中心的轻量落地,不是为超大规模高可用集群设计的。


八、写在最后:三篇,一个朴素的目标

到这里,DocMind 这个系列就讲完了。回头看,三篇其实围绕一个很朴素的目标:

  • 第 1 ——让文档中心不止能"搜"、还能"答":整体架构与几个关键取舍(模拟流式的代价、阈值拒答的边界、缓存的精确匹配局限、轻量的单实例代价)。
  • 第 2 ——答得准还跨语言:BGE-M3 共享语义空间做跨语言检索,"镜像提问语言"做多语种回复,加上阈值拒答 / 来源溯源 / 上下文零信任构成的防幻觉纵深。
  • 第 3 (本篇)——敢挂公网:无中间件的轻量持久化、内存防 OOM 的底座,以及覆盖八个攻击面的 15 道安全防线。

项目已开源:https://github.com/lukyFun/search-ai 。三篇里聊到的每一个设计,代码里都能找到对应。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、"能跑"和"敢挂公网"是两个量级
  • 二、先解决"重不重":无 Redis / Mongo / Docker 的持久化
  • 三、单实例敢挂公网的真正前提:内存绝不泄漏
  • 四、15 道安全防线:一次请求要穿过什么
  • 五、挑几道防线讲透
    • 5.1 限流为什么要这么多维度:堵住每一条绕过路径
    • 5.2 真实 IP:默认不信任任何 X-Forwarded-For
    • 5.3 每日 token 预算:防的是"账单攻击"
    • 5.4 拒答恒定耗时:把时间侧信道抹平
    • 5.5 提示注入:输入正则 + 系统提示零信任上下文
    • 5.6 短窗口去重,以及它和 SSE 的一次正面冲突
  • 六、内部接口:诊断能力也要上锁
  • 七、边界:这套防护到哪儿为止
  • 八、写在最后:三篇,一个朴素的目标
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档