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.
20 KiB
提问箱 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 | 一次网络抖动就降级放行,命中率会明显低于预期 |
改进建议(时序)
- 硬超时 3–5s,用独立
context.WithTimeout,不复用请求 ctx; - 内置熔断器:连续 N 次失败或收到 429 → 熔断 5–10 分钟,期间直接 fail-open 且不发邮件;
- 结果按
hash(userID + 内容)缓存,复用censor_logs的写法; - 若坚持不阻塞体验,可改为先入库 → 异步判定 → 命中则转"待审/折叠",但这就不是方案 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 判定是非确定性的:同一段文本两次调用可能给出不同结果。如果"命中 = 拒绝提交",那么误判导致的提问丢失是不可逆的——提问者写了一大段,被吞了,且不知道为什么。
建议三选一(按推荐度排序):
- 命中 → 提示 + 仍允许提交,但给箱主打 ⚠️ 标记并默认折叠(推荐:零丢失,箱主有最终处置权)
- 命中 → 提示 + 要求二次确认后提交(保留"冷静期"效果,又不丢内容)
- 命中 → 硬拦(不推荐,除非你接受误判成本)
另需注意:提示文案走 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各加一个标记列(GORMAutoMigrate自动加列,无需手写迁移)- 箱主后台:⚠️ 角标 + 默认折叠 + 可筛选 + 一键"忽略/删除"
- 设置页新增「审核严格度」三档,把权衡交还给用户:
- 严格:维持现状(不过即拒)
- 宽松(建议默认):仅高危三类硬拦,其余标记
- 仅标记:全部不拒,只标记
- 阈值自建:不采信七牛
suggestion,改用已有的detail.Score+ 可配阈值(默认偏高,如 0.9) - 容错:短文本可跳过判定;箱主"这不是辱骂"反馈计数,用于后续调阈值
这条路线顺带消掉的风险
零延迟、零外部依赖 → SSRF、Key 明文、Key 被刷、邮件轰炸四个 P0 全部不再存在,也无需引入队列或加密主密钥。
7. 需要你确认的点
- 命中后的语义:是「①仅提示 + 仍提交(打标记折叠)」/「②提示 + 二次确认后提交」/「③硬拦拒绝」? —— 我的建议是 ①,理由见 §3.3。
- 是否接受同步阻塞?若不接受,方案 3「前端提示提问者」就要改成异步 + 待审队列(需要新增队列能力,项目现在没有)。
- API 地址范围:固定七牛
https://api.qnaigc.com/v1(消灭 SSRF 面)/ 还是允许任意 OpenAI 兼容地址? - 模型名:系统固定一个小模型,还是用户自填?
- Key 加密:是否接受引入 at-rest 加密(需要一个主密钥,放
[server]段)?还是先明文 +json:"-"+ 掩码回显,后续再补? - 告警收件人:复用
conf.Security.SecurityAlertEmail,还是发给箱主本人(users.email),还是两者都发? - 调用范围:只审提问,还是也要审回答 / 追问 / 私聊?
- 是否先做 L1/L2:我建议先收紧七牛
IsPass()(把abuse纳入 + score 阈值),这条零成本、零延迟,可能就解决了你 80% 的诉求,之后再决定 LLM 要不要上。要不要我先出这部分的变更清单?