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

20 KiB
Raw Blame History

提问箱 LLM 内容审核方案 · 可行性评审

  • 日期:2026-09-10
  • 范围:可行性评审,未改动任何代码
  • 背景更正:项目文本/图片审核服务为七牛云(阿里云通道虽仍在代码中,但 conf/app.sample.ini 的 aliyun_access_key 为空,实际不生效)

0. 七牛云能力核对(评审基准)

已验证(官方文档 文本审核 API):

项 事实
端点 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 大模型推理):

  • 提供 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):

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 要不要上。要不要我先出这部分的变更清单?