Files
tamabox/outputs/llm-moderation-feasibility-2026-09-10.md
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

311 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 提问箱 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 要不要上。要不要我先出这部分的变更清单?