一套小连招搞定文章评论审核!Java实战篇(结合DeepSeek实现)
本文介绍了一套高效的多层次评论内容审核方案,针对公开网站防止恶意评论和违禁内容。作者比较了本地词库、开源组件和第三方API等主流方案后,提出融合四种技术的综合方案:本地Trie树敏感词库、开源组件、第三方API和AI审核,通过权重判定提高准确率至99.99%。重点讲解了基于Trie树的本地敏感词检测实现,包括树结构构建和Java代码示例,并指出该方案兼顾准确性与成本效益,尤其适合中小型网站。
前言
最近给自己的笔记网站增加了一个用户评论功能,由于网站公开访问,所以评论内容的审核就显得尤为重要。一方面,如果有人恶意刷评论,那么评论区将会充斥着各种无关评论,另一方面,如果有用户发布违禁内容,作为站长没有发现,那估计得进去喝茶了。所以保险起见我必须要对评论的内容做极为严格的审核。以下我研究多日整理的一套方案,目前测试审核准确率达到 99.99%。特此分享一下。
实现思路
开发前期我也调研了很多评论审核相关的内容,发现几种主流的方案:
- 本地词库结合Trie树(前缀树)的敏感词检测算法
- 开源的敏感词库组件(sensitive-word)
- 第三方平台的内容安全审核 API(京东云、阿里云、腾讯云、百度云、等等…)
但经过一番比较我发现无论哪种方案都不够完美,本地词库依赖敏感词的数据量,需要定期维护新的敏感词,需要人工投入;开源的敏感词库组件又依赖组件自身的词库,部分生僻或者新潮的敏感词无法准确校验;第三方平台的服务倒是很好用,但是算起来也很贵,这个小破站不值得用这么好的。
万般无奈之下我想到了一个点子:现在 AI 这么火,我能不能用 AI 来帮我审核呢,所以我就以 DeepSeek 为例测试了一下,测试结果基本符合预期,由于内容包含敏感词,这里就不展示了,自行脑补一下。但调用 AI 接口响应比较慢,而且单 AI 审核的准确率无法保证,所以我决定直接放出一套小连招,上面的四种方案一起用,根据权重决定,所以就有了下面的方案。
具体实现
具体实现上我们主要针对提到的四种判断方式进行分步讲解。
本地敏感词库校验
实现思路
本地词库结合 Trie 树(前缀树)的敏感词检测算法,这种算法也是目前比较通用的敏感词算法。实现原理也很简单,如示例:
敏感词库:[“中国”, “中国人”, “美国”]
构建的Trie树:
root
/
中美
/
国国
/
人
其中,“国”节点(在“中”下面)和“人”节点(在“国”下面)以及“国”节点(在“美”下面)都是结束节点。
检测文本:“我是中国人”
检测过程:
- 从第一个字符“我”开始,不在根节点的子节点中(根节点有“中”和“美”),跳过。
- 到“是”,同样跳过。
- 到“中”,匹配到,进入“中”节点;下一个字符“国”,匹配到“中”节点的子节点“国”,并且“国”是结束节点,所以检测到敏感词“中国”。
- 继续:在“国”节点下,下一个字符是“人”,匹配到“国”节点的子节点“人”(假设我们构建了“中国人”这个词,那么“中”->“国”->“人”),并且“人”是结束节点,所以检测到敏感词“中国人”。
代码实现
package com.muke.base.common.utils;
import jodd.util.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import javax.annotation.PostConstruct;
/**
* @Description: 敏感词校验工具类
* @author: 专业bug开发(kk)
* @date: 2025年04月06日 14:42
*/
@Slf4j
@Component
public class SensitiveUtil {
/**
* 敏感词文件
*/
private static final String SENSITIVE_WORD = "sensitive-words.txt";
/**
* 根节点
*/
private static final TrieNode ROOT_NODE = new TrieNode();
@PostConstruct
public void init() {
try (
InputStream is = this.getClass().getClassLoader().getResourceAsStream(SENSITIVE_WORD);
BufferedReader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(is)))
) {
String keyword;
while ((keyword = reader.readLine()) != null) {
// 添加到前缀树
this.addKeyword(keyword);
}
} catch (Exception e) {
log.warn("加载敏感词文件失败:{} ", e.getMessage());
}
}
/**
* 将一个敏感词添加到前缀树中
*
* @param keyword 敏感词
*/
private void addKeyword(String keyword) {
TrieNode tempNode = ROOT_NODE;
for (int i = 0; i < keyword.length(); i++) {
char c = keyword.charAt(i);
TrieNode subNode = tempNode.getSubNode(c);
if (subNode == null) {
// 初始化子节点
subNode = new TrieNode();
tempNode.addSubNode(c, subNode);
}
// 指向子节点,进入下一轮循环
tempNode = subNode;
// 设置结束标识
if (i == keyword.length() - 1) {
tempNode.setKeywordEnd(true);
}
}
}
/**
* 过滤敏感词
*
* @param text 待过滤的文本
* @return 过滤后的文本
*/
public static boolean containsSensitiveWords(String text) {
// 空文本直接返回false
if (StringUtil.isBlank(text)) {
return false;
}
// 指针1 - 当前Trie节点
TrieNode tempNode = ROOT_NODE;
// 指针2 - 检测起始位置
int begin = 0;
// 指针3 - 当前检测位置
int position = 0;
while (position < text.length()) {
char c = text.charAt(position);
// 跳过符号
if (isSymbol(c)) {
// 如果在根节点,移动起始位置
if (tempNode == ROOT_NODE) {
begin++;
}
position++;
continue;
}
// 检查下级节点
tempNode = tempNode.getSubNode(c);
if (tempNode == null) {
// 不是敏感词,从下一个位置重新开始检测
position = ++begin;
tempNode = ROOT_NODE;
} else if (tempNode.isKeywordEnd()) {
// 发现完整的敏感词,立即返回true
return true;
} else {
// 继续检查下一个字符
position++;
}
}
return false;
}
/**
* 判断是否为符号
*
* @param c 字符
* @return 判断
*/
private static boolean isSymbol(Character c) {
// 0x2E80~0x9FFF 是东亚文字范围
return !isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
public static boolean isAsciiAlpha(char ch) {
return isAsciiAlphaUpper(ch) || isAsciiAlphaLower(ch);
}
public static boolean isAsciiAlphaUpper(char ch) {
return ch >= 'A' && ch <= 'Z';
}
public static boolean isAsciiAlphaLower(char ch) {
return ch >= 'a' && ch <= 'z';
}
public static boolean isAsciiNumeric(char ch) {
return ch >= '0' && ch <= '9';
}
public static boolean isAsciiAlphanumeric(char ch) {
return isAsciiAlpha(ch) || isAsciiNumeric(ch);
}
/**
* 前缀树
*/
private static class TrieNode {
// 关键词结束标识
private boolean isKeywordEnd = false;
// 子节点
private final Map<Character, TrieNode> subNodes = new HashMap<>();
public boolean isKeywordEnd() {
return isKeywordEnd;
}
public void setKeywordEnd(boolean keywordEnd) {
isKeywordEnd = keywordEnd;
}
// 添加子节点
public void addSubNode(Character c, TrieNode node) {
subNodes.put(c, node);
}
// 获取子节点
public TrieNode getSubNode(Character c) {
return subNodes.get(c);
}
}
}
使用示例:
//1.本地词库检测
if (SensitiveUtil.containsSensitiveWords(content)) {
log.info("本地词库检测为敏感词");
return true;
}
开源敏感词校验
sensitive-word 基于 DFA 算法实现的高性能敏感词工具。这里只演示简单的实现,具体使用可以参考官方文档。
引入方法:
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId>
<version>0.26.0</version>
</dependency>
使用方法:
//2.开源词库检测
if (SensitiveWordHelper.contains(content)) {
log.info("开源词库检测为敏感词");
return true;
}
AI 敏感词校验
实现思路
接入硅基流动的模型广场大模型,接入方式参考官方文档:也可以参考其他文章,比如:https://zhuanlan.zhihu.com/p/29891194096
<dependency>
<groupId>io.github.pig-mesh.ai</groupId>
<artifactId>deepseek-spring-boot-starter</artifactId>
<version>1.4.5</version>
</dependency>
- 短文本快速通道:对5字符以下的短内容使用单一高优先级模型快速检测
- 中长文本并行检测:对更长的内容采用多模型并行检测+智能决策机制
- 置信度分级:根据模型返回的置信度进行分级判定

智能决策机制:
- 多模型投票机制
- 置信度加权评估
- 超时容错处理
**Tip:因为部分深度思考模型响应时间较长,所以尽量不要使用深度思考,影响判断失效。
代码实现
package com.muke.base.service.impl;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.muke.base.common.enums.DetectionStatusEnum;
import com.muke.base.domain.dto.ModelConfig;
import com.muke.base.service.AiChatService;
import io.github.pigmesh.ai.deepseek.core.DeepSeekClient;
import io.github.pigmesh.ai.deepseek.core.chat.ChatCompletionRequest;
import io.github.pigmesh.ai.deepseek.core.chat.ChatCompletionResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
* @author muke
*/
@Slf4j
@Service
public class AiChatServiceImpl implements AiChatService {
@Resource
private DeepSeekClient deepSeekClient;
private static final List<ModelConfig> MODEL_CONFIGS = Collections.unmodifiableList(Arrays.asList(
new ModelConfig("Pro/deepseek-ai/DeepSeek-V3", 10, 1.0f),
new ModelConfig("THUDM/GLM-4-32B-0414", 9, 0.95f),
new ModelConfig("deepseek-ai/DeepSeek-V3", 8, 0.9f),
new ModelConfig("internlm/internlm2_5-7b-chat", 7, 0.85f)
));
private static final ExecutorService MODEL_EXECUTOR = Executors.newCachedThreadPool(
new ThreadFactoryBuilder().setNameFormat("ai-detection-%d").setDaemon(true).build()
);
/**
* AI检测敏感词
* @param word 待检测内容
* @return DetectionStatus 检测状态
*/
@Override
public DetectionStatusEnum aiCheckWord(String word) {
if (StringUtils.isEmpty(word)) {
return DetectionStatusEnum.CLEAR;
}
// 短内容使用单模型快速检测
if (word.length() < 5) {
return checkWithSingleModel(word, MODEL_CONFIGS.get(1));
}
// 中长内容使用多模型检测
return checkWithOptimizedModels(word);
}
private DetectionStatusEnum checkWithOptimizedModels(String content) {
List<ModelConfig> sortedModels = MODEL_CONFIGS.stream()
.sorted(Comparator.comparingInt(ModelConfig::getPriority).reversed())
.collect(Collectors.toList());
// 创建并行任务列表
List<CompletableFuture<DetectionStatusEnum>> futures = sortedModels.stream()
.map(config -> CompletableFuture.supplyAsync(
() -> checkWithSingleModel(content, config),
MODEL_EXECUTOR
).exceptionally(e -> {
log.warn("模型检测异常: {}", e.getMessage());
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
})).collect(Collectors.toList());
// 使用allOf等待所有任务完成(无论成功与否)
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
try {
// 设置总超时时间
allFutures.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.warn("部分模型检测未能在5秒内完成,继续处理已完成结果");
} catch (Exception e) {
log.error("多模型检测发生异常", e);
}
// 收集所有结果(包括已完成和异常的)
List<DetectionStatusEnum> results = futures.stream()
.map(future -> {
try {
// 未完成则返回默认
return future.getNow(DetectionStatusEnum.SUGGEST_CLOUD_CHECK);
} catch (Exception e) {
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
}).collect(Collectors.toList());
// 综合评估策略
return evaluateConsensusResults(results);
}
/**
* 综合评估策略(升级版)
*/
private DetectionStatusEnum evaluateConsensusResults(List<DetectionStatusEnum> results) {
if (results.isEmpty()) {
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
// 统计各状态数量
Map<DetectionStatusEnum, Long> countMap = results.stream()
.collect(Collectors.groupingBy(
status -> status,
Collectors.counting()
));
long sensitiveCount = countMap.getOrDefault(DetectionStatusEnum.SENSITIVE, 0L);
long clearCount = countMap.getOrDefault(DetectionStatusEnum.CLEAR, 0L);
long totalModels = results.size();
// 策略1:超过半数模型明确判定敏感
if (sensitiveCount > totalModels / 2) {
log.info("AI多模型判定结果为:超过半数模型明确判定敏感");
return DetectionStatusEnum.SENSITIVE;
}
// 策略2:超过70%模型明确判定非敏感
if (clearCount > totalModels * 0.7) {
log.info("AI多模型判定结果为:超过70%模型明确判定非敏感");
return DetectionStatusEnum.CLEAR;
}
// 策略3:存在敏感判定且总占比超过30%
if (sensitiveCount > 0 && (sensitiveCount + clearCount) * 0.3 < sensitiveCount) {
log.info("AI多模型判定结果为:存在敏感判定且总占比超过30%");
return DetectionStatusEnum.SENSITIVE;
}
// 其他情况建议云校验
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
private DetectionStatusEnum checkWithSingleModel(String content, ModelConfig config) {
try {
long startTime = System.currentTimeMillis();
String prompt = "请严格判断以下内容是否包含敏感信息(暴力、色情、反动、诱导等)," +
"用JSON格式回答:{\"sensitive\":布尔值, \"confidence\":0-1的置信度}\n" +
"内容:" + content;
ChatCompletionRequest request = ChatCompletionRequest.builder()
.addUserMessage(prompt)
.model(config.getName())
.stream(false)
.temperature(0.0)
.maxCompletionTokens(30)
.reasoningEffort("low")
.build();
// 带超时的请求
CompletableFuture<ChatCompletionResponse> future = CompletableFuture.supplyAsync(
() -> deepSeekClient.chatCompletion(request).execute()
);
ChatCompletionResponse response = future.get(10000, TimeUnit.MILLISECONDS);
if (response == null || response.choices() == null || response.choices().isEmpty()) {
log.warn("模型 {} 返回空响应", config.getName());
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
String answer = response.choices().get(0).message().content().trim()
.replace("\uFEFF", "")
.replaceAll("^```json|```$", "");
try {
JsonNode json = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readTree(answer);
boolean sensitive = json.path("sensitive").asBoolean();
float confidence = (float) json.path("confidence").asDouble(0.5);
log.info("模型 {} 检测结果: {}, 置信度: {}, 检测耗时: {}ms", config.getName(), sensitive, confidence, System.currentTimeMillis() - startTime);
// 根据置信度确定最终状态
if (sensitive) {
return confidence >= 0.8 ? DetectionStatusEnum.SENSITIVE : DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
} else {
return confidence >= 0.7 ? DetectionStatusEnum.CLEAR : DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
} catch (Exception e) {
log.warn("解析模型 {} 响应失败: {}", config.getName(), answer);
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
} catch (TimeoutException e) {
log.warn("模型 {} 检测超时", config.getName());
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
} catch (Exception e) {
log.error("模型 {} 检测异常: {}", config.getName(), e.getMessage());
return DetectionStatusEnum.SUGGEST_CLOUD_CHECK;
}
}
}
云厂商 API 检测
这就不多介绍了,基本上所有的云厂商都提供了敏感词检测的能力,收费也都不一样,自行根据需求选择即可,我这里用的是阿里云的服务,按照文档对接还是很便捷的,我这里直接贴出我的工具类以供参考。
package com.muke.base.common.utils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.aliyun.imageaudit20191230.Client;
import com.aliyun.imageaudit20191230.models.ScanTextRequest;
import com.aliyun.imageaudit20191230.models.ScanTextResponse;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* @Description: 阿里云文件校验
* @author: 专业bug开发(kk)
* @date: 2025年05月26日 22:08
*/@Slf4j
@Component
@ConfigurationProperties(prefix = "aliyun.user")
public class AliGreenTextScanUtil {
private static final List<String> SCAN_SCENES = Collections.unmodifiableList(Arrays.asList(
"spam", "politics", "abuse", "terrorism",
"porn", "contraband", "flood", "ad"
));
// 预构建的labels列表(线程安全)
private static final List<ScanTextRequest.ScanTextRequestLabels> PREDEFINED_LABELS;
static {
List<ScanTextRequest.ScanTextRequestLabels> labels = new ArrayList<>(SCAN_SCENES.size());
for (String scene : SCAN_SCENES) {
labels.add(new ScanTextRequest.ScanTextRequestLabels().setLabel(scene));
}
PREDEFINED_LABELS = Collections.unmodifiableList(labels);
}
// 客户端缓存(按需创建)
private volatile Client client;
private final Object clientLock = new Object();
private String accessKeyId;
private String secret;
/**
* 创建/获取客户端实例(线程安全)
*/
private Client getClient() throws Exception {
Client currentClient = this.client;
if (currentClient == null) {
synchronized (clientLock) {
currentClient = this.client;
if (currentClient == null) {
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(secret)
.setEndpoint("imageaudit.cn-shanghai.aliyuncs.com");
this.client = currentClient = new Client(config);
}
}
}
return currentClient;
}
/**
* 优化后的文本内容审核方法
*/
public Boolean scanText(String content) {
if (StringUtils.isBlank(content)) {
return true;
}
try {
// 1. 构建检测任务(复用预定义的labels)
ScanTextRequest.ScanTextRequestTasks task = new ScanTextRequest.ScanTextRequestTasks()
.setContent(content);
ScanTextRequest request = new ScanTextRequest()
.setTasks(Collections.singletonList(task))
.setLabels(PREDEFINED_LABELS);
// 2. 执行检测(带超时控制)
Client client = getClient();
ScanTextResponse response = client.scanTextWithOptions(request, new RuntimeOptions());
log.info("阿里云检测响应结果:{}", JSON.toJSONString(response));
// 3. 优化响应解析
return parseResponse(response);
} catch (Exception e) {
log.error("文本审核异常 - 内容: {}", content.substring(0, Math.min(content.length(), 50)), e);
return false;
}
}
/**
* 优化响应解析逻辑
*/
private boolean parseResponse(ScanTextResponse response) {
if (response == null || response.getBody() == null) {
log.warn("空响应");
return false;
}
try {
JSONObject data = JSON.parseObject(JSON.toJSONString(response.getBody())).getJSONObject("data");
if (data == null) {
log.warn("缺少data字段");
return false;
}
JSONArray elements = data.getJSONArray("elements");
if (elements == null || elements.isEmpty()) {
log.warn("缺少elements数据");
return false;
}
JSONObject element = elements.getJSONObject(0);
JSONArray results = element.getJSONArray("results");
if (results == null) {
log.warn("缺少results数据");
return false;
}
// 流式处理结果,发现违规立即返回
for (int i = 0; i < results.size(); i++) {
JSONObject result = results.getJSONObject(i);
if ("block".equals(result.getString("suggestion"))) {
log.info("文本违规 - 类型: {}", result.getString("label"));
return true;
}
}
return false;
} catch (Exception e) {
log.error("响应解析异常", e);
return false;
}
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
resetClient();
}
public void setSecret(String secret) {
this.secret = secret;
resetClient();
}
private void resetClient() {
synchronized (clientLock) {
this.client = null;
}
}
}
实现总结
上面我们已经逐一介绍了每种敏感词检测方式的实现,这里我们总结一下我现在的实现方式,如何将这几种方式合并起来实现一个完整的小连招。
- UtilService
package com.muke.base.service;
/**
* @author muke
*/public interface UtilService {
/**
* 内容校验敏感词
* @param content 内容
* @return 是否敏感
*/
Boolean checkSensitiveWords(String content);
}
- UtilServiceImpl
package com.muke.base.service.impl;
import com.github.houbb.sensitive.word.core.SensitiveWordHelper;
import com.muke.base.common.enums.DetectionStatusEnum;
import com.muke.base.common.utils.AliGreenTextScanUtil;
import com.muke.base.common.utils.SensitiveUtil;
import com.muke.base.service.AiChatService;
import com.muke.base.service.UtilService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* @Description:
* @author: 专业bug开发(kk)
* @date: 2025年05月26日 20:43
*/@Slf4j
@Service
public class UtilServiceImpl implements UtilService {
@Resource
private AiChatService aiChatService;
@Resource
private AliGreenTextScanUtil aliGreenTextScanUtil;
/**
* 内容校验敏感词
*
* @param content 内容
* @return 是否敏感
*/
@Override
public Boolean checkSensitiveWords(String content) {
if (StringUtils.isEmpty(content)) {
return false;
}
//1.本地词库检测
if (SensitiveUtil.containsSensitiveWords(content)) {
log.info("本地词库检测为敏感词");
return true;
}
//2.开源词库检测
if (SensitiveWordHelper.contains(content)) {
log.info("开源词库检测为敏感词");
return true;
}
//3.多AI并行检测
DetectionStatusEnum aiStatus = aiChatService.aiCheckWord(content);
if (aiStatus.shouldBlock()) {
log.info("多AI并行检测为敏感词");
return true;
}
if (!aiStatus.needsCloudCheck()) {
return false;
}
log.info("AI校验结果为需要进一步云检测,开始进行云检测");
//4.云检测
return aliGreenTextScanUtil.scanText(content);
}
}

总结
传统方式的敏感词检测基本都是基于敏感词库实现,这种方式过度依赖于敏感词库的词汇量,导致很多新出现的敏感词需要手动维护到词库,有些图方便则直接使用云厂商的服务,很少有跟 AI 相结合的,这次的也算是一个创新,将 AI 能力运用到了实际场景中。这里我是直接实时检测用户评论,如果对失效要求不严格的可以异步检测,但不管怎么样实现的思路都是一样的。
因为文章审核的原因,这里不提供原敏感词文件,需要的联系作者获取。
作者原文发布于:专业bug开发的小站,欢迎感兴趣的来看看
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)