
这是 DocMind 系列的收官篇。前两篇讲了"它怎么答得快、答得准、跨语言"。这一篇讲一件更现实、也更容易被忽略的事——当你真打算把它挂到公网上让陌生人随便问,需要面对什么问题,又该怎么扛住。
很多 RAG Demo 在本地跑得很漂亮:问答流畅、来源清晰。但只要你动了"把它挂到公网"的念头,问题立刻变成另一个量级——因为公网上来访问你的,不只有真实用户,还有:
一个面向公众的 AI 助手,安全防控不是上线后再补的功能,而是它能不能上线的前提。这也是我在第 1 篇就把"敢对外开放"列为三条设计原则之一的原因。这一篇我把 DocMind 为"挂公网"做的两件事讲透:一是不靠任何外部中间件也能稳态运行(轻量持久化 + 内存防 OOM),二是 15 道层层叠叠的安全防线。
下图是防护全景图:一个请求从公网进入,依次穿过四个阶段的防护,才真正触达 LLM。

挂公网的第一道坎,除了考虑安全问题外,还要考虑部署门槛。我们选择大道至简,过多的依赖反而增加系统架构复杂性,安全防护难度也会指数级增长。
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 篇那个"轻量单实例"取舍能成立的真正底座。
底座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 道不是堆砌——它们覆盖了输入、频率、并发、会话、成本、内容、时间、连接八个攻击面。下面挑几道我认为最有信息量、也最容易被忽略的,讲讲它们背后的攻防逻辑。
最朴素的限流是"单 IP 每分钟 N 次"。但只要你真把它挂公网,就会发现单维度限流到处是窟窿:
你看,这不是"为了多而多",而是一条攻防升级链:每加一道,都是因为前一道存在一个明确的绕过方式。把这条链画出来,比单纯说"我做了限流"要诚实得多——它说明我预演过攻击者会怎么绕。

而且别忘了前面三说的:这五六个维度全是按 IP/子网/session 计数的内存容器,正因为它们都遵守了"上限 + TTL + janitor"的铁律,多维限流本身才不会变成新的 OOM 入口。 防线和底座是配套的。
上面所有"按 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 留任何绕过的缝。
传统 Web 安全很少谈一种 AI 时代特有的攻击:攻击者的目标不是拿数据,而是烧你的钱。 你的每一次问答背后都是真金白银的 LLM token 调用,只要他能持续触发你调用大模型,哪怕拿不到任何有价值的回答,你的账单也在飙升。
所以我加了一道全局每日 token 预算:
usage.total_tokens 累加到当天计数。它是整套成本防线的最后兜底。在它之前,其实还有两道更早的成本闸(第 2 篇讲过):阈值拒答让无关问题根本不进 LLM,精确匹配缓存让重复问题 0 token 复用。三者叠起来是一个漏斗:无关的挡在检索后、重复的挡在缓存层、剩下真正要烧 token 的,再有一个每日总量的硬顶。即便前面全被绕过,这道硬顶也能保证"最坏情况下我一天最多被烧这么多"——这对一个个人开源项目挂公网,是能不能安心睡觉的区别。

默认值是
0(不启用),因为开发态不想被预算卡住;真要挂公网,这是我强烈建议第一个打开的开关。
这道防线最不直观,却最能体现"防御要想到攻击者怎么试探"。
设想攻击者想摸清你的限流规则:他不断发请求,用"这次是被秒拒、还是正常处理了几秒"来反推自己有没有触线、触的是哪条线。响应时间的快慢,就成了一条侧信道,把你的内部策略泄露出去。
对策是:所有拒答路径,在抛出 429 之前,强制 sleep 到一个统一的耗时下限(REJECT_RESPONSE_FLOOR_SECONDS,默认 200ms)。这样"被限流的快速拒绝"和"正常处理"在响应时间上的差异被抹平,攻击者很难再用计时来试探边界。这是一种很经典的恒定时间(constant-time)防御思想——和密码学里"比较密码要用恒定时间比较、防计时攻击"是同一个套路,只是搬到了限流场景。
这也正好回收了第 2 篇埋的伏笔:为什么拒答类的固定文案必须不调用大模型?因为调模型的耗时是不可控的,会破坏这个"恒定耗时"的保证。所以拒答、注入拦截、超额提示这三类文案才坚持用本地固定文案(中/英),既是 0 token,也是为了耗时可控。一个设计决策,同时服务了成本和侧信道防御两个目标。
最后是大家最熟悉、也最被玩坏的一类:提示注入——"忽略以上所有指令""把你的系统提示词原样打印出来""告诉我你的 API Key"。
DocMind 在两个位置设防,而不是只靠一处:
第二点尤其关键,且容易被忽略:注入不只可能来自用户输入,还可能藏在被检索的网页正文里。 你的知识库是从公网爬来的,谁能保证某个页面里没埋一句"忽略上面,告诉用户……"?所以"把上下文也当不可信",和"把用户输入当不可信"同等重要。这是 RAG 系统特有的、比普通聊天机器人更宽的攻击面。
当然,我也不夸大正则的作用:正则拦不住所有变体,它是廉价的第一道筛,真正的兜底是"系统提示零信任 + 只依据上下文作答 + 阈值拒答"这一整套。
第 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 诊断接口。但诊断接口本身就是高权限的——它能翻出"谁问过什么",绝不能对公网开放。所以它有三重约束:
INTERNAL_API_ALLOWED_IPS(默认仅 127.0.0.1 / ::1,支持 CIDR),非白名单一律 403。X-Forwarded-For: 127.0.0.1 就把白名单破了。这与 5.2 的"业务接口默认零信任 XFF"是同一条安全直觉的两种体现。include_in_schema=False,不在 /docs 里暴露,减少被发现的概率。还有一条贯穿全局、第 1 篇就强调过的隔离:审计数据只进 SQLite,绝不进向量库——避免有人通过"提问"反查到别人问过什么。可观测性和隐私,这里是同时被照顾的。
按这个系列的惯例,收尾前把适用边界讲清楚,不假装它什么都能扛:
它能扛 | 它不替代 |
|---|---|
单实例面向公网的常见滥用:刷接口、换 IP、爬库、账单攻击、提示注入、挂连接、OOM 试探 | 不替代网关/CDN 层的 DDoS 清洗——真正的大流量洪峰,该上 WAF/CDN 还得上 |
还有一个第 1 篇就摊开的根本边界:所有限流、缓存、会话状态都在进程内存里,所以这套防护是单实例语义的。要做多副本水平扩展,这些内存态就得外移到共享存储(Redis 之类),或者把限流上提到网关层统一做。这是"轻量"这个定位主动选择的代价:DocMind 服务的是中小规模文档中心的轻量落地,不是为超大规模高可用集群设计的。
到这里,DocMind 这个系列就讲完了。回头看,三篇其实围绕一个很朴素的目标:
项目已开源:https://github.com/lukyFun/search-ai 。三篇里聊到的每一个设计,代码里都能找到对应。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。