Featured image of post 模型安全检测 03:JoySafety 技术架构与业务模块分析

模型安全检测 03:JoySafety 技术架构与业务模块分析

配置期/执行期分离、DAG 引擎、版本化状态机、四大 skill 实现与开源版局限,源码级拆解。

前置阅读:安全编排框架-4(Mac 本地部署实操笔记)——部署视角的容器清单和两个时期划分 日期:2026-09-13 素材来源:JoySafety 源码通读(safety/ 目录全模块) 读者定位:已部署成功、想理解"它到底是怎么跑起来的"的读者;后续要写自定义策略、自定义能力的读者


〇、一张图看懂全局

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
                        ┌─────────────────────────────────────────┐
                        │           配置期(写配置)                │
                        │  quickstart 脚本 / 运维人员              │
                        │        │                                │
                        │        ▼                                │
                        │  safety-admin (8006, Java/SpringBoot)   │
                        │   原子能力/业务/DAG策略/敏感词/知识库      │
                        │        │ 写入(版本化状态机)             │
                        │        ▼                                │
                        │      MySQL ──────────┐                  │
                        └──────────────────────┼──────────────────┘
                                               │ ① api 每 60s HTTP 轮询 admin
                                               │    拉取 online 配置(不直连 MySQL!)
┌──────────────────────────────────────────────┼──────────────────┐
│                        执行期(跑审核)        ▼                  │
│  业务方 ──HTTP/HMAC-SHA1──► safety-api (8007, Java/SpringBoot)  │
│                                │  DAG 引擎逐节点执行              │
│              ┌───────────┬─────┴──────┬───────────────┐         │
│              ▼           ▼            ▼               ▼         │
│       safety-keywords  safety-bert  safety-knowledge  (fasttext)│
│       AC自动机敏感词     BERT分类     知识库/RAG代答/多轮  默认未启用 │
│              │                         │                        │
│              ▼                         ▼                        │
│            MySQL              Vearch向量库 + 本地bge embedding   │
│                                          + OpenAI兼容LLM        │
│  Redis: 会话历史 / free_taxi任务队列 / 分布式锁 / 结果缓存         │
└─────────────────────────────────────────────────────────────────┘

三条最重要的架构认知(后面所有细节都围绕这三条):

  1. admin 不在请求链路上。它只负责"配置期"写库和下发配置,挂了对线上审核零影响。
  2. api 不直连 MySQL。safety-api 通过 HTTP 轮询 admin 的 /config/defense/api/conf 接口拿全量 online 配置(默认 60s 一次),这是"改配置不生效、重启 api 才行"(笔记-4 坑 5)的根因。
  3. skill 服务全部无状态、HTTP 解耦。bert/keywords/knowledge 只是"会干活的工具人",编排逻辑全在 safety-api 的 DAG 引擎里。

一、模块全景表

模块语言/框架角色一句话职责
safety-basic纯 Java 库(无 Spring)核心 jarDAG 引擎抽象(Function/Router/Node)、会话上下文、签名工具,被 api/admin 依赖
safety-apiJava / Spring Boot 2.0.9执行层对外审核接口、DAG 构建与执行、free_taxi 异步任务体系、配置动态加载
safety-adminJava / Spring Boot + MyBatis管理层配置 CRUD + 版本化状态机 + 配置下发 API
safety-keywordsJava / Spring Boot能力层敏感词检测(AC 自动机 + 正则倒排加速)
safety-bertPython / Tornado 多进程能力层BERT 分类模型推理(HF transformers)
safety-fasttextPython / Flask+gunicorn能力层FastText 分类(默认不启用,不在根 compose 里)
safety-knowledgePython / Tornado(20 进程)能力层知识库向量检索、RAG 代答、多轮对话检测
safety-demoPython/Go/Java示例三种语言调 Defense API 的签名示例
example/Python 脚本初始化一次性灌演示数据(建向量库→知识入库→注册能力→建业务→配策略)

批注:Java 侧用的是 Spring Boot 2.0.9(2018 年的版本)+ Jedis 2.5.2 + fastjson 混用,技术栈偏老旧,二开时依赖升级是个工程量。Python 侧反而现代:Tornado 多进程、onnxruntime、HF transformers。


二、safety-basic:DAG 引擎的抽象层

包根 com.jd.security.llmsec.core,是整个框架的"定义层":

子包关键类职责
engineFunction 接口、AbstractFunction(模板方法)、Node、Router 接口DAG 的三要素抽象
engine.routers7 种 Router 实现groovy / stupid_end / simple_next / keyword / risk_end / user_end / robot_end
sessionSessionContext、ResponseMode、CheckConf一次审核的会话上下文(groovy 脚本里的 ctx 就是它)
checkRiskCheckType 枚举 + 各 RiskCheckResult 子类检测结果类型体系(keyword/single_label_pred/rag_answer/parallel…)
confNodeConfig、FunctionConfig、RouterConfigDAG 节点的配置 POJO(YAML 反序列化目标)
authAuthRequest.basicCheck()、AuthControlConf鉴权模型
utilSignUtil(HMAC-SHA1)、GroovyHelper签名、groovy 引擎工厂

核心抽象就三个:

1
2
3
Function.run(SessionContext) → RiskCheckResult   // 干活:调模型/查词库/问知识库
Router.next(SessionContext)   → 下一个 Node 或 null(null = 链路结束)
Node = NodeConfig + Function + Router            // 节点 = 配置 + 能力 + 路由

关键认知:它名义上叫 DAG,实际是"链式逐跳"。节点之间没有 Edge 实体类——边由 Router 返回的下一个 nodeId 隐式表达;执行就是 while (curNode != null) { curNode.run(ctx); curNode = curNode.next(ctx); }。真正的并行只存在于 parallel 虚拟节点内部(一个节点里并发跑多个 Function 再归约),不存在图上并发分支的汇合调度。理解这点对写策略很重要:分支逻辑全靠 groovy 脚本里的 if-else 返回不同 nodeId。


三、safety-api:请求链路与执行引擎

3.1 入口与处理链

唯一对外接口:DefenseApiV2Controller(safety-api/.../contoller/DefenseApiV2Controller.java:33)

1
2
POST /llmsec/api/defense/v2/{accessKey}
POST /llmsec/api/defenseResult/v2/{accessKey}   ← 主动拉异步结果

一次请求的处理链:

1
2
3
4
5
checkParams(参数校验)
 → rateLimited(Guava RateLimiter,按 accessKey+accessTarget 内存级限流)
 → verify(HMAC-SHA1 验签)
 → preProcess(OpenAI 格式解析、7000 字截断)
 → 按 responseMode 分派:sync / free_taxi / http / mq

3.2 鉴权:HMAC-SHA1

  • 签名字符串:accessKey=..&accessTarget=..&requestId=..&timestamp=..&plainText=..,用业务的 secretKey 做 HmacSHA1 → Base64(safety-basic/.../core/util/SignUtil.java:36)
  • secretKey 存在 admin 的 business_info.secret_key,api 通过配置轮询拿到
  • timestamp 超过业务配置的 expireSeconds 直接拒绝(防重放)
  • 特赦:accessKey 以 test 开头跳过验签(DefenseApiV2Controller.java:74)——这就是演示业务能免签名调通的原因

3.3 DAG 的构建与执行

构建(FunctionChainBuilderImpl.java:73):

1
2
3
4
5
YAML 节点数组 → 先建全部 Node → 逐个节点:
  ① FunctionUtil.doMerge()  合并 ref 引用的原子能力模板(map 递归深合并)
  ② 反射实例化 Function(查 FunctionRegistry.FUNC_MAP)
  ③ 按 Router.ROUTER_MAP 反射构造 Router(注入 nodeMap)
→ root 节点存 rootMap,按业务名索引

执行(FunctionExecutorImpl.doExecute():289):就是从 root 开始的 while 循环逐跳,每轮先检查整体超时,节点异常按 ignoreError 决定抛出或跳过,router 返回 null 即结束。router 脚本通常直接改写 ctx.curResult 后 return null 终结链路。

parallel 并行节点(ParallelFunction.java:41):

  • 静态线程池:200 线程 / 队列 1 / CallerRunsPolicy(满了让调用线程自己跑,变相背压)
  • doRun() 把各子 Function submit 后,按各自的 timeoutMilliseconds 逐个 Future.get(timeout) 收取,剩余预算递减——这就是笔记-4 坑 4"两层超时"里第①层的实现位置
  • 归约策略三选一:no_reduce / first_risk / groovy_script(脚本 bindings:ctx/conf/retMap)

3.4 三层超时体系(笔记-4 坑 4 的完整答案)

层位置默认值超时后果
整体 DAG业务配置的 userCheckConf/robotCheckConf.timeoutMilliseconds,主循环每节点前检查1000ms抛 exec_timeout
单 Function HTTPfunction 配置 timeoutMilliseconds → OkHttp callTimeout注册时定(2000)该节点结果丢弃
parallel 内部Future.get(剩余预算)同上该子项结果丢弃

Mac 上 BERT 推理 ~1.7s(并行争抢 CPU 时 3.3s),三层默认值全部不够——所以坑 4 要同时改两层。

3.5 四种 responseMode 与 free_taxi 详解

模式行为适用
sync同步跑完整 DAG 返回结果输入审核(发出去之前拦)
free_taxi投递异步任务后立即返回该会话之前累积的检测结果(“搭便车”)流式输出审核(不阻塞 token 输出)
http只投递不返回,客户端调 defenseResult 接口拉结果异步批量
mq预留,开源版是空实现 stub(MessageQueueServiceImpl.java:32)需自行实现

free_taxi 的完整机制(代码与 docs/ABOUT_FREE_TAXI.md 一致):

1
2
3
4
5
6
7
1. 投递:lpush 全局任务队列(sessionId) + lpush 该会话新消息队列(请求JSON)   [Redis pipeline 原子执行]
2. 分发:单分发线程 rpop 任务队列 → 提交到 200 线程池
3. 抢占:对该 sessionId 抢 Redis 分布式锁(setnx+expire 10s)
   - 抢到:循环批量 rpopN 取新消息(batch=checkNum,即"滑动窗口")→ 每批跑完整 DAG → 结果 lpush 进会话结果队列
   - 没抢到:直接放弃(等客户端下次请求再触发)→ 天然的多实例互斥
4. 取结果:下一次请求进来时 fetchResult() rpopN 取走之前的结果;
   会话结束发 sync 请求(content='$')收尾,等 200ms 取全量剩余结果

设计精髓:把"检测"从请求的同步路径上摘掉,用下一次请求顺带带回上一次的检测结果。代价是结果滞后一条消息,靠收尾请求兜底。会话历史同时写 Redis list,供 multi_turn_detect 取上下文。

3.6 配置加载与缓存(坑 5 的完整答案)

1
2
3
4
5
6
7
8
admin 侧:只有调 online/upgrade 接口才更新 lastChangeTimestamp
    │
    │  api 侧 DynamicConfigService:
    │    @PostConstruct 首载 + @Scheduled(fixedRate=60000) 每分钟轮询
    │    /config/defense/api/conf → 按 lastChangeTimestamp 判断变更
    ▼
变更后:全量替换 volatile 配置 Map → 回调 listener 重建所有业务的 DAG
  (重新编译 groovy 脚本、重建 OkHttp client、重新注册 warmup)

所以配置变更最长滞后约 60s;实践中原子能力超时这类变更重建不彻底,需要 docker restart safety-api(笔记-4 坑 5 的经验结论与此机制吻合)。

Redis 在 api 侧的四个用途:会话历史、free_taxi 任务/结果队列、分布式锁、识别结果缓存(user 且单条时按 业务+内容MD5 缓存 15 分钟——这就是笔记-4 说"重试要改动一两个字"的原因)。


四、safety-admin:配置中心与版本化状态机

4.1 管理域与接口

管理域路径前缀动作
原子能力 function/config/defense/manage/functionnew / get / newVersion / update / online / offline / upgrade / active / allActive
业务 business/config/defense/manage/business同上
DAG 策略/config/defense/manage/dag同上 + newDagWithYaml / dagUpdateYaml / activeYaml(YAML 导入)
配置下发/config/defense/api/conf给 api 轮询的全量 online 配置
敏感词/data/sensitive_wordsupsert / queryByWord / online / offline
红线知识库/data/redline_knowledgeCRUD
平台字典/platform/dictCRUD

4.2 版本化状态机(所有配置通用)

状态枚举 NormalStatus:init → edit → (audit / rejected 预留未用) → online ⇄ offline

核心规则(FunctionConfigServiceImpl.java 为代表):

  • 唯一键 (name, group, version),newVersion 基于当前 online 版本复制、版本号 +1
  • online 状态的记录禁止 update——只能复制出新版本改,保证线上配置不可变
  • upgrade = 旧版本置 offline + 新版本置 online,原子切换

这就是笔记-4 附录 C 那条流水线的代码实现:active → newVersion → update → upgrade。

4.3 数据表(6 张,safety-admin/docs/tables.sql)

表要点字段
function_confconf 为 JSON(url、超时、模型参数都在里面)
business_infosecret_key、access_targets、qps_limit、user/robot 检测配置
execute_dag_confroot_id + conf JSON 节点数组
sensitive_wordsmatch_type: contain/equal/regex/compound;handle_strategy: black/white/gray/exclude
red_line_knowledgequestion/answer/metadata
platform_dict字典

每张表都带 version + status + editor/auditor/release 审计字段。


五、safety-skills:四个原子能力服务

5.1 safety-keywords(Java,8005)——敏感词

  • 匹配算法:AC 自动机(org.ahocorasick.trie.Trie),不是 DFA
  • 数据源:MySQL sensitive_words 表(只取 online),每 60s 轮询,先查 max(update_time) 判断有无变更再全量重建
  • 双引擎(线程池并行,150ms 总预算):
    • contain/equal 词 → 直接进 AC Trie;命中后按业务场景过滤、按优先级排序;black 短路返回、exclude 忽略(白名单机制)
    • regex 词 → 正则倒排加速:正则按元字符切 token 建 Trie + token→规则倒排索引,query 先 AC 粗筛出候选正则再精匹配,避免全量正则扫文本(这个设计很值得单独写一篇)
  • all 业务下的敏感词对所有业务生效

5.2 safety-bert(Python/Tornado,8003)

  • Tornado bind_sockets + multiprocessing 多进程(进程数可配)
  • 模型加载:HuggingFace BertForSequenceClassification.from_pretrained(),CPU/GPU 可配,max_length=512,softmax argmax
  • 接口:POST /v1/bert/{model_name},body text_list,返回每条 riskCode/probability/全标签分布
  • 模型即配置:每个模型一个目录(config 声明 name/路径/labels 的 riskCode↔风险文案映射),现有三个:
    • bert_20250916:14 类内容风险(涉政/暴恐/涉黄/辱骂…)
    • bert_prompt_injection_20250828:提示词注入二分类
    • bert_prompt_injection_20251118:注入三分类(正常/明确攻击/疑似),红蓝军对抗训练的新版

5.3 safety-knowledge(Python/Tornado,8004,默认 fork 20 进程)——功能最重

路由:/knowledge/upsert|delete|search、/knowledge/answer、/dialog

  • 知识库检索:Vearch Python SDK(连 router :9001)+ 本地 bge-small-zh-v1.5 ONNX 模型生成 512 维向量(onnxruntime CPU,启动预热 10 次);_id=md5(text);支持标量 IN 过滤 + min_score 阈值
  • RAG 代答:检索 Top1 分数 ≥ same_threshold → 直接返回知识库 safeChat 字段;否则拼 prompt(参考答案+输入)调 OpenAI 兼容 LLM API,生成后再用 bge 余弦相似度校验答案与参考知识一致性(阈值 0.7),不一致则答案置空、进 backup——双保险防代答胡说
  • multi_turn_detect 多轮检测:把 history 拼进审核细则 prompt 发给 LLM,要求返回 {label, reason} JSON,正则抽取解析
  • 依赖:Vearch + OpenAI 兼容 LLM + 本地 ONNX embedding;不依赖 Redis

5.4 safety-fasttext(Flask+gunicorn,8002)

默认不在根 compose 里、初始化脚本也没注册它。定位是"轻量预筛"(对应 ABOUT_DAG.md 纵深防御里的第一层:keyword+fasttext 粗筛 → BERT 精检)。


六、example 初始化脚本:演示环境的装配线

safety-example-init 容器按序执行(对应笔记-4 的启动时序图):

脚本动作
vearch_manage.py建库 db0、建 space(HNSW 512 维 InnerProduct 向量索引 + 标量索引),自测 upsert/search
index_all.py直连 MySQL 分批读 red_line_knowledge → POST 到 knowledge /knowledge/upsert 向量化入库
skills_init.py注册 6 个原子能力(keyword、2 个 bert、kb_search、rag_answer、multi_turn_detect)并 online
business_init.py建 test 业务(secretKey=123456、qpsLimit=20)并 online
strategy_init.pystrategy_example.yml → dagUpdateYaml → online

演示 DAG 逐节点解读(strategy_example.yml,纵深防御教科书案例)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
start(dummy 节点,groovy router)
  │  按 ctx.curReq[0].fromRobot() 分流
  ├─ 机器人输出 → from_robot_test1:keyword → stupid_end(机器人侧只查敏感词)
  └─ 用户输入 → from_user_test1:parallel(keyword)
        │  groovy router:命中黑(bwgLabel=1)直接出风险;命中白(2)直接放行
        ▼
     from_user_test_bert2:parallel(bert_20250916, bert_prompt_injection_20250828)
        │  groovy router:按 riskCode 差异化概率阈值表判定
        │    (如 1003涉黄 0.85、1004涉赌 0.996、201注入 0.99)
        ▼
     都不达标 → MixedCheck.noRisk()

所有节点 ignoreError: true(单个能力挂了不阻断整体)。ABOUT_DAG.md 里还有更进阶的打法:parallel(rag_answer + bert) 红线代答与模型双证(riskCode 一致且概率 > 0.5 才判风险,防误杀)。


七、值得学习的设计点 vs 开源版的坑(博客素材库)

7.1 设计亮点

  1. 编排与能力彻底分离:skill 服务无状态、HTTP 解耦,新增能力 = 起一个服务 + admin 注册 + DAG 引用,不动一行 Java 代码
  2. 配置版本化 + 不可变线上配置:online 禁改、upgrade 原子切换,天然支持灰度回滚(AB 机制还能按 dagVersion 指定版本实时构建)
  3. free_taxi 模式:把检测从流式输出的同步路径上摘掉,业界少见的工程化解法
  4. 纵深防御策略:词库粗筛 → 轻模型 → 重模型 → 知识库双证,每层都有成本和准确率权衡
  5. groovy 脚本做 router:策略逻辑不用发版就能改(虽然要重启 api 才彻底生效)
  6. 正则倒排加速、RAG 代答一致性校验:细节处的工程功底

7.2 开源版的坑 / 局限

  1. mq responseMode 是空 stub,要用得自己实现 MessageQueueService
  2. 文档超前于代码:ABOUT_DAG.md 里 executedNodes/nodeCosts 等 ctx 字段实际不存在
  3. MySQL/Redis/Vearch 全都没挂数据卷(笔记-4 已踩:docker compose down 即丢全部配置)
  4. DAG 热更新不彻底:60s 轮询 + 部分变更需重启 api,离 README 宣称的"分钟级热更新"有差距
  5. groovy eval 全局 synchronized:ScriptEngine 非线程安全,所有脚本串行执行,高并发下是瓶颈
  6. 技术栈老旧:Spring Boot 2.0.9 / Jedis 2.5.2 / fastjson 1.x 2.x 混用
  7. “DAG” 实为链式逐跳:没有真正的图拓扑调度,并行只在 parallel 节点内部
  8. fasttext 默认缺席:README 能力清单里有,compose 和初始化脚本都没有

八、关键源码坐标速查

主题位置
DAG 主执行循环safety/safety-api/src/main/java/com/jd/security/llmsec/engine/FunctionExecutorImpl.java:289
并行节点safety/safety-api/.../engine/functions/ParallelFunction.java:41
Function 注册表safety/safety-api/.../engine/FunctionRegistry.java:20
groovy routersafety/safety-basic/.../core/engine/routers/GroovyRouter.java:27
SessionContext(ctx 结构)safety/safety-basic/.../core/session/SessionContext.java:21
审核入口safety/safety-api/.../contoller/DefenseApiV2Controller.java:33
HMAC-SHA1safety/safety-basic/.../core/util/SignUtil.java:36
free_taxi 任务执行safety/safety-api/.../task/TaskExecutorImpl.java:78
配置轮询safety/safety-api/.../service/DynamicConfigService.java:45
版本化状态机safety/safety-admin/.../service/business/FunctionConfigServiceImpl.java:236
AC 自动机敏感词safety/safety-skills/safety-keywords/.../service/data/SensitiveWordDetector.java
建表 SQLsafety/safety-admin/docs/tables.sql
演示策略docs/strategy_example.yml

注:Java 源码里 controller 目录拼写为 contoller(原仓库笔误),检索时注意。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计