# 提问箱 LLM 内容审核方案 · 可行性评审 - 日期:2026-09-10 - 范围:可行性评审,**未改动任何代码** - 背景更正:项目文本/图片审核服务为**七牛云**(阿里云通道虽仍在代码中,但 `conf/app.sample.ini` 的 `aliyun_access_key` 为空,实际不生效) --- ## 0. 七牛云能力核对(评审基准) 已验证(官方文档 [文本审核 API](https://developer.qiniu.com/censor/api/7260/api-text-censor)): | 项 | 事实 | |---|---| | 端点 | `POST https://ai.qiniuapi.com/v3/text/censor`(代码 `qiniu.go:85` 一致) | | 审核场景 | 仅 `antispam` 一种 | | 返回 label | `normal` / `spam` / `ad` / `politics` / `terrorism` / **`abuse`(辱骂)** / `porn` / `flood` / `contraband` / `meaningless` | | 管控建议 | `result.suggestion`:`block` / `review` / `pass`,并带 `score` | | 限制 | 文本长度 ≤ 2000 字;**不支持白名单**,自定义拉黑词库需提交工单 | 已验证([七牛 AI 大模型推理](https://developer.qiniu.com/aitokenapi/13379/real-time-ai-interface-api)): - 提供 MaaS 推理端点 `https://api.qiniuapi.com/v1` → 实为 **`https://api.qnaigc.com/v1`** - 兼容 OpenAI `/v1/chat/completions`、Anthropic `/v1/messages`、支持流式 - 50+ 模型(DeepSeek / Qwen / Kimi / GLM 等) - 使用**独立的 API Key**,与七牛对象存储 AK/SK 不是同一套凭证 代码内已验证的现状(`/internal/security/censor/qiniu.go`): ```go func (r *QiniuTextCensorResponse) IsPass() bool { for _, detail := range r.Result.Scenes.Antispam.Details { blockedLabels := []string{"politics", "terrorism", "porn"} // ← 只有 3 类 if lo.Contains(blockedLabels, detail.Label) { return false } } return r.Result.Scenes.Antispam.Suggestion != "block" // ← review 放行 } ``` **关键发现:`abuse`(辱骂)不在硬拦名单内,且 `review` 一律放行** —— 辱骂类内容现在基本是放行的。 > ⚠️ 推测待实测:`abuse` 在七牛返回的是 `review` 而非 `block`。建议直接抽样 `censor_logs.raw_response` 里 `label=abuse` 的记录验证(零成本,不需要额外调接口)。 **另一条已验证的好消息**:`censor.go:50-53` 命中缓存后会用 `QiniuTextCensorParser(raw)` **重新计算** `Pass`(缓存只省 API 调用费,不锁死判定结果)。所以后续若收紧 `IsPass()` 逻辑,**不需要清理 31 天缓存**。 --- ## 1. 总体结论 **方案可行,但按现在的描述直接落地会有 4 个 P0 问题(SSRF、Key 明文、成本被刷、邮件轰炸)。** 更重要的一点判断:**方案里"系统内置的若干种屏蔽判定类型",大部分根本不需要 LLM。** 七牛已经按 `antispam` 场景返回 9 类 label + score,且代码里已经拿到这些数据只是没用上。合理分层是: | 层级 | 手段 | 成本 | 覆盖 | |---|---|---|---| | L1(应先做) | 收紧七牛 `IsPass()`:`abuse` + score 阈值 | **0 元,0 延迟** | 辱骂、垃圾、广告、灌水、违禁 | | L2(补充) | 本地负面情绪词库打分 | 0 元 | 七牛不覆盖的语气/情绪词 | | L3(本方案) | LLM 语义判定 | 钱 + 延迟 | 阴阳怪气、暗示、语境依赖的恶意 | **建议:L3 只承担 L1/L2 覆盖不到的部分**,否则你在用最贵、最慢、最不确定的手段去解决已经有确定性解的问题。 --- ## 2. 数据流与调用时序 ### 2.1 现状(已验证) `route/question/page.go` 的 `New()` 是**全同步**链路,任一环节返回即终止: ``` 长度校验 → 验证码 → 同IP重复拦截 → 屏蔽词 → 七牛审核(10s超时) → 入库 → 邮件通知(goroutine) ↓ 失败 fail-closed 拒绝发布 ``` 现状里唯一的异步是 `shared.SafeGo`(`internal/shared/goutil.go`)——**裸 goroutine,无重试、无持久化、进程重启即丢失**,项目里没有队列/Worker,Redis 只用于 session 与 cache。 ### 2.2 拟议方案插在哪里 按方案 3「命中时在前端提示提问者」,LLM 判定**必须同步阻塞**在入库之前: ``` ... → 屏蔽词 → 七牛审核 → 【LLM 审核】→ 入库 ├─ 命中 → 前端提示 / 拦截 ├─ 未命中 → 正常入库 └─ 超限/连不上 → 跳过 + 前端异常提示 + 邮件告警 + 正常入库 ``` ### 2.3 时序上的问题 | # | 问题 | 级别 | 说明 | |---|---|---|---| | T1 | **同步阻塞被放大** | 🟠 P1 | 七牛审核约 100–500ms;LLM 判定通常 1–10s(推理模型可达 30s+)。提问提交从"几乎无感"变成明显卡顿 | | T2 | **超时预算未定义** | 🟠 P1 | 方案未给超时值。必须独立设 deadline(建议 3–5s),且不能复用请求 ctx(客户端断连会误判为调用失败 → 触发邮件告警) | | T3 | **429 与连接失败未区分** | 🟠 P1 | 429(配额耗尽)应当立即熔断,不要再每次都等满超时;连接失败才走告警路径 | | T4 | **无结果缓存** | 🟡 P2 | 相同内容重复提交会重复计费。可直接复用 `censor_logs` 已有的 31 天缓存范式 | | T5 | **临时性错误无重试** | 🟡 P2 | 一次网络抖动就降级放行,命中率会明显低于预期 | **改进建议(时序)** 1. 硬超时 3–5s,用独立 `context.WithTimeout`,**不复用请求 ctx**; 2. 内置熔断器:连续 N 次失败或收到 429 → 熔断 5–10 分钟,期间直接 fail-open 且**不发邮件**; 3. 结果按 `hash(userID + 内容)` 缓存,复用 `censor_logs` 的写法; 4. 若坚持不阻塞体验,可改为**先入库 → 异步判定 → 命中则转"待审/折叠"**,但这就不是方案 3 描述的"前端提示"了,需要你二选一(见 §7)。 --- ## 3. 逐条评审 ### 3.1 用户自填 API 地址与 Key **可行,但这是整个方案风险最集中的一条。** 🔴 **P0-1 · SSRF(服务端请求伪造)** 用户填的地址由**你的服务器**发起请求。攻击者注册账号后填入 `http://127.0.0.1:6379`、`http://169.254.169.254/latest/meta-data/`、内网段地址,即可: - 探测内网拓扑与端口存活 - 打云厂商 metadata 拿临时凭证 - 1C1G 机器上 Redis(6379) / Postgres(5432) 通常与 Web 同机 而且**方案 4 的"保存前连通性测试"本身就是一个开箱即用的内网探测入口**——不需要任何绕过技巧,填个地址点保存就能看到响应差异。 必须的防护(缺一不可): - 仅允许 `https`(例外:显式允许 `http://localhost` 供自建模型调试,且需独立开关) - 解析 DNS 后**逐个 IP 校验**,拒绝 loopback / link-local(169.254/16) / private(10/8, 172.16/12, 192.168/16) / IPv6 ULA - **禁用 HTTP 重定向跟随**(否则 302 到内网即可绕过上面的校验) - 建议在 `internal/shared/multidial.go` 已有自定义 Dialer 的基础上实现,统一在 dial 层拦截 🔴 **P0-2 · Key 存储安全** - 已验证:全项目**没有任何 at-rest 加密**(grep `aes` / `Encrypt(` / `secretbox` 无结果) - `users` 表单表存储,新增列即明文落库;DB/备份/导出 CSV 泄露 = Key 全泄露 - 建议:派生密钥加密存储(不要直接用 `conf.Server.Salt`,用 HKDF 派生独立子密钥);后台**只回显掩码**(`sk-...abcd`);**永不写日志**(现有代码有把完整响应写日志的习惯,需确认 LLM 请求体不会被 logrus 打出来) 🟠 **P1 · 协议兼容性未定义** "API 地址"是任意 OpenAI 兼容地址,还是固定七牛 `https://api.qnaigc.com/v1`?若允许任意地址,则: - 需约定必须 OpenAI 兼容(否则无法解析) - 模型名由用户填还是系统固定?不同模型对 JSON 输出的遵循度差异极大 - 建议:**内置预设 + 允许自定义**,预设直接给七牛官方端点,用户只填 Key 和选模型(顺带消掉 SSRF 面) ### 3.2 内置判定类型,用户只能勾选 **方向正确。** 但强烈建议: - **类型优先映射到七牛 label**(`abuse` / `spam` / `ad` / `flood` / `contraband` …),这部分走 L1,零成本、确定性、无延迟 - LLM 类型只保留七牛覆盖不到的少数几种(如「阴阳怪气 / 恶意暗示」「情绪宣泄 / 人身攻击」) - 类型定义要写死在 Go 常量 + i18n key 里,与现有 `ForbiddenType`(`internal/security/censor/text.go:16`)保持同一种风格 🟡 注意:判定类型与界面语言是 4 种(zh-CN / zh-TW / en / ja),提示词里的类型描述要用提问箱主人设置的语言,但**被审核的提问文本可能是任意语言**,prompt 需显式要求"按原文语言判断"。 ### 3.3 命中时在前端提示提问者 🟠 **P1 · 提示会泄露规则,可被枚举探测** 若提示写成"你的提问包含辱骂内容",攻击者就能用二分法逐词试探出完整判定规则,进而绕过。 现有屏蔽词的提示(`question.errBlockWords`)只说"包含屏蔽词"、不说是哪个 —— **LLM 提示应对齐这个粒度**,不暴露具体类型。 🟠 **P1 · 误判兜底(最重要的一条)** LLM 判定是非确定性的:同一段文本两次调用可能给出不同结果。如果"命中 = 拒绝提交",那么**误判导致的提问丢失是不可逆的**——提问者写了一大段,被吞了,且不知道为什么。 建议三选一(按推荐度排序): 1. **命中 → 提示 + 仍允许提交**,但给箱主打 ⚠️ 标记并默认折叠(推荐:零丢失,箱主有最终处置权) 2. 命中 → 提示 + 要求二次确认后提交(保留"冷静期"效果,又不丢内容) 3. 命中 → 硬拦(不推荐,除非你接受误判成本) 另需注意:**提示文案走 i18n**,4 种语言各一份(现有 `admin.*` / `question.*` key 的写法)。 ### 3.4 保存前连通性与鉴权测试 **可行,但是 SSRF 的头号入口(见 P0-1)。** 补充: - 测试请求本身也要有超时(≤5s)与 SSRF 校验 - 建议用**最小 chat 请求**(`max_tokens=1`)而不是 `/v1/models`:部分 Key 只有推理权限没有列举模型权限,用 `/v1/models` 会误判为鉴权失败 - 测试通过 ≠ 长期可用:Key 可能随时被吊销/欠费。**保存时通过只是准入条件,运行期的熔断与告警才是主力** - 保存动作必须防重放:测试是服务端发起的外网请求,需加频率限制(否则可被用来打第三方 / 放大攻击) ### 3.5 失败降级:前端提示 + 邮件告警 + 跳过审核正常提交 🟠 **P1 · fail-open 之后没有补审** "跳过 LLM 审核并正常提交"意味着降级窗口内恶意内容**照常入库且无人复查**。而攻击者完全可以**主动制造失败**(刷到 Key 超限 / 打满你的连接),把系统稳定地压在降级态。 建议:降级期间入库的提问标记为 `llm_review = 'skipped'`,后台提供"补审"入口(复用 `cmd/censor.go` 已有的批量补审范式),否则降级态就是永久免审。 🔴 **P0-3 · 成本被刷(用户 Key 直接经济损失)** Key 是**用户自己的钱**。提问入口匿名开放,攻击者可对某个启用了 LLM 的箱主持续提问,一夜烧光配额: - 必须有**每箱主每日调用上限**(超上限即 fail-open,不再调用) - 复用 `internal/route/ratelimit.go` 已有的内存滑动窗口限流器做**单 IP 限流** - 超限后应**自动停用**该箱主的 LLM 审核并通知箱主,而不是静默继续烧钱 🔴 **P0-4 · 邮件轰炸 → SMTP 账号被封** "调用失败就发邮件"在攻击场景下 = **每条提问一封邮件**。攻击者刷 1000 条提问,你的发信账号会被标记为垃圾邮件发送者甚至直接封号,**影响的是全站所有邮件(含找回密码)**。 必须的节流: - 按 **箱主 + 时间窗聚合**(建议 10 分钟最多 1 封),窗口内汇总次数 - 收敛粒度建议按**错误类别**(连接失败 / 超时 / 429 / 鉴权失败)分别计数,而不是每次都发 - 触发熔断后**停止发信**,只在恢复时发一封汇总 - 告警收件人建议复用 `conf.Security.SecurityAlertEmail` 的模式,并允许箱主选择关闭告警 --- ## 4. 失败降级策略 · 建议矩阵 | 失败类型 | 是否跳过审核 | 是否提示提问者 | 是否发邮件 | 是否熔断 | |---|---|---|---|---| | 超时 | 跳过 | 否(静默) | 窗口内聚合 | 连续 N 次 → 熔断 | | 连接失败 | 跳过 | 否(静默) | 窗口内聚合 | 是 | | 429 / 配额耗尽 | 跳过 | 否 | 是(1 次) | 是(较长) | | 401 / 403 鉴权失败 | 跳过 | 否 | 是(1 次) | **自动停用该功能** | | 响应解析失败 | 跳过(视为未命中) | 否 | 窗口内聚合 | 连续 N 次 → 熔断 | | 达到日调用上限 | 跳过 | 否 | 是(1 次) | 是(到次日) | > 原则:**提问者侧永远不要暴露"审核系统坏了"**——否则等于告诉对方"现在可以随便发"。 --- ## 5. 风险清单 | ID | 级别 | 问题 | 处置 | |---|---|---|---| | P0-1 | 🔴 | 用户自填 API 地址导致 SSRF,连通性测试即探测入口 | 协议白名单 + DNS 解析后逐 IP 校验 + 禁用重定向 | | P0-2 | 🔴 | Key 明文落库、无 at-rest 加密、可能回显/入日志 | 派生密钥加密 + 后台掩码 + 禁用请求体日志 | | P0-3 | 🔴 | 用户 Key 被刷造成直接经济损失 | 每日调用上限 + 单 IP 限流 + 超限自动停用 | | P0-4 | 🔴 | 失败即发信 → 邮件轰炸 → SMTP 封号 | 箱主+时间窗聚合 + 熔断后停发 + 允许关闭告警 | | P1-1 | 🟠 | 同步阻塞放大(1–10s+),超时预算未定义 | 独立 3–5s deadline,不复用请求 ctx | | P1-2 | 🟠 | 误判导致提问不可逆丢失 | 改"提示 + 标记折叠"或"二次确认",不要硬拦 | | P1-3 | 🟠 | 提示文案泄露判定规则,可被枚举绕过 | 提示不暴露具体类型,对齐现有屏蔽词语气 | | P1-4 | 🟠 | fail-open 后无补审,攻击者可主动维持降级态 | 标记 `skipped` + 后台补审入口 | | P1-5 | 🟠 | LLM 输出解析不可靠(中文自由文本) | 强制 JSON 输出(`response_format`),严格 schema 校验,解析失败视为未命中 | | P2-1 | 🟡 | 无结果缓存,重复内容重复计费 | 复用 `censor_logs` 31 天缓存范式 | | P2-2 | 🟡 | 临时错误无重试 | 1 次快速重试(仅对连接类错误) | | P2-3 | 🟡 | 提问内容出网给第三方 LLM(匿名提问的隐私) | 设置页明确告知 + 默认关闭 | | P2-4 | 🟡 | 七牛文本上限 2000 字;`AnswerContentLen` 默认即 2000(边界) | 站长若调高超 2000,七牛会拒 → fail-closed 拒绝发布。需加前置截断或上限校验 | | P2-5 | 🟡 | 无调用审计(谁、何时、花多少) | 建议记录调用量/耗时/结果,供箱主自查 | --- ## 6. 成本与限额 | 维度 | 建议 | |---|---| | 调用时机 | 只对**提问**调用,不要覆盖回答 / 追问 / 私聊(成本与延迟都会失控) | | 单箱主日上限 | 建议默认 100–300 次/天,可在 `[limits]` 配置,超限 fail-open + 通知箱主 | | 单 IP 限流 | 复用 `internal/route/ratelimit.go`,建议 5–10 次/分钟 | | 输入长度 | 截断到提问上限(`QuestionContentLen`,默认 1000 字符),避免超长文本爆 token | | 模型 | 审核是分类任务,用小模型即可(成本差一个数量级);不要上推理模型 | | 输出 | `max_tokens` 压到最小(如 64),只回 JSON | | 缓存 | 相同内容 31 天内不重复调用 | | 免费额度 | 七牛 AI 推理对新用户有免费 token,可作为内置预设的冷启动优势 | --- ## 6.5 修订:撤回 L1 建议(用户实测反馈后追加) > 2026-09-10 追加。用户反馈:**七牛对「辱骂」的判定过于严格,正常对话经常被误伤。** 这条反馈直接推翻了本文档 §1 的 L1 建议("收紧 `IsPass()`,把 `abuse` 纳入硬拦")——**该建议予以撤回**,照做只会放大误伤。 配合代码复核,误伤的真实成因有两个,且都是**硬拦语义**造成的: | 成因 | 代码位置 | 现状 | |---|---|---| | 七牛把正常内容判为 `abuse` 且 `suggestion=block` | `qiniu.go:55-68` → `!Pass` → 拒绝 | 提问/私聊**直接发不出去**,用户只看到"内容安全检查不通过 [辱骂]" | | 审核服务不可用/超时 | `page.go:574`、`chat_handler.go:258` → `err != nil` → **fail-closed 拒绝** | 七牛抖动期间所有提问与私聊一律失败 | 第二个成因尤其容易被忽略:**这不是"判得准不准"的问题,是审核服务一抖全站就发不出内容。** ### 关键约束(七牛官方已确认) 文本审核**不支持白名单**,自定义拉黑词库需**提交工单**处理 —— 也就是说: **我们既不能让七牛判得更准,也不能让它判得更松,唯一能控制的是"命中之后怎么办"。** ### 修订后的设计原则 **显式规则硬拦,算法推断只标记。** | 判定来源 | 处置 | 理由 | |---|---|---| | 箱主自定义屏蔽词 | **硬拦** | 用户显式指定,知道自己拦的是什么,误伤自负 | | 算法判定 `politics` / `terrorism` / `porn` | **硬拦** | 合规底线,不能软 | | 算法判定 `abuse` / `spam` / `ad` / `flood` / `contraband` / `meaningless` | **只打 ⚠️ 标记,照常送达** | 误报率高,误伤不可逆,把决定权交回箱主 | | 审核服务不可用 | **fail-open + 标记待审** | 避免服务抖动导致全站发不出内容 | ### 落地形态 - `questions` / `messages` 各加一个标记列(GORM `AutoMigrate` 自动加列,无需手写迁移) - 箱主后台:⚠️ 角标 + 默认折叠 + 可筛选 + 一键"忽略/删除" - 设置页新增「审核严格度」三档,把权衡交还给用户: - **严格**:维持现状(不过即拒) - **宽松(建议默认)**:仅高危三类硬拦,其余标记 - **仅标记**:全部不拒,只标记 - 阈值自建:不采信七牛 `suggestion`,改用已有的 `detail.Score` + 可配阈值(默认偏高,如 0.9) - 容错:短文本可跳过判定;箱主"这不是辱骂"反馈计数,用于后续调阈值 ### 这条路线顺带消掉的风险 零延迟、零外部依赖 → **SSRF、Key 明文、Key 被刷、邮件轰炸四个 P0 全部不再存在**,也无需引入队列或加密主密钥。 --- ## 7. 需要你确认的点 1. **命中后的语义**:是「①仅提示 + 仍提交(打标记折叠)」/「②提示 + 二次确认后提交」/「③硬拦拒绝」? —— 我的建议是 ①,理由见 §3.3。 2. **是否接受同步阻塞**?若不接受,方案 3「前端提示提问者」就要改成异步 + 待审队列(需要新增队列能力,项目现在没有)。 3. **API 地址范围**:固定七牛 `https://api.qnaigc.com/v1`(消灭 SSRF 面)/ 还是允许任意 OpenAI 兼容地址? 4. **模型名**:系统固定一个小模型,还是用户自填? 5. **Key 加密**:是否接受引入 at-rest 加密(需要一个主密钥,放 `[server]` 段)?还是先明文 + `json:"-"` + 掩码回显,后续再补? 6. **告警收件人**:复用 `conf.Security.SecurityAlertEmail`,还是发给箱主本人(`users.email`),还是两者都发? 7. **调用范围**:只审提问,还是也要审回答 / 追问 / 私聊? 8. **是否先做 L1/L2**:我建议先收紧七牛 `IsPass()`(把 `abuse` 纳入 + score 阈值),这条零成本、零延迟,可能就解决了你 80% 的诉求,之后再决定 LLM 要不要上。要不要我先出这部分的变更清单?