前置阅读:安全编排框架-4(Mac 本地部署实操笔记)——部署视角的容器清单和两个时期划分 日期:2026-09-13 素材来源:JoySafety 源码通读(safety/ 目录全模块) 读者定位:已部署成功、想理解"它到底是怎么跑起来的"的读者;后续要写自定义策略、自定义能力的读者
〇、一张图看懂全局
| |
三条最重要的架构认知(后面所有细节都围绕这三条):
- admin 不在请求链路上。它只负责"配置期"写库和下发配置,挂了对线上审核零影响。
- api 不直连 MySQL。safety-api 通过 HTTP 轮询 admin 的
/config/defense/api/conf接口拿全量 online 配置(默认 60s 一次),这是"改配置不生效、重启 api 才行"(笔记-4 坑 5)的根因。 - skill 服务全部无状态、HTTP 解耦。bert/keywords/knowledge 只是"会干活的工具人",编排逻辑全在 safety-api 的 DAG 引擎里。
一、模块全景表
| 模块 | 语言/框架 | 角色 | 一句话职责 |
|---|---|---|---|
| safety-basic | 纯 Java 库(无 Spring) | 核心 jar | DAG 引擎抽象(Function/Router/Node)、会话上下文、签名工具,被 api/admin 依赖 |
| safety-api | Java / Spring Boot 2.0.9 | 执行层 | 对外审核接口、DAG 构建与执行、free_taxi 异步任务体系、配置动态加载 |
| safety-admin | Java / Spring Boot + MyBatis | 管理层 | 配置 CRUD + 版本化状态机 + 配置下发 API |
| safety-keywords | Java / Spring Boot | 能力层 | 敏感词检测(AC 自动机 + 正则倒排加速) |
| safety-bert | Python / Tornado 多进程 | 能力层 | BERT 分类模型推理(HF transformers) |
| safety-fasttext | Python / Flask+gunicorn | 能力层 | FastText 分类(默认不启用,不在根 compose 里) |
| safety-knowledge | Python / Tornado(20 进程) | 能力层 | 知识库向量检索、RAG 代答、多轮对话检测 |
| safety-demo | Python/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,是整个框架的"定义层":
| 子包 | 关键类 | 职责 |
|---|---|---|
engine | Function 接口、AbstractFunction(模板方法)、Node、Router 接口 | DAG 的三要素抽象 |
engine.routers | 7 种 Router 实现 | groovy / stupid_end / simple_next / keyword / risk_end / user_end / robot_end |
session | SessionContext、ResponseMode、CheckConf | 一次审核的会话上下文(groovy 脚本里的 ctx 就是它) |
check | RiskCheckType 枚举 + 各 RiskCheckResult 子类 | 检测结果类型体系(keyword/single_label_pred/rag_answer/parallel…) |
conf | NodeConfig、FunctionConfig、RouterConfig | DAG 节点的配置 POJO(YAML 反序列化目标) |
auth | AuthRequest.basicCheck()、AuthControlConf | 鉴权模型 |
util | SignUtil(HMAC-SHA1)、GroovyHelper | 签名、groovy 引擎工厂 |
核心抽象就三个:
关键认知:它名义上叫 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)
一次请求的处理链:
3.2 鉴权:HMAC-SHA1
- 签名字符串:
accessKey=..&accessTarget=..&requestId=..×tamp=..&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):
执行(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 HTTP | function 配置 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 一致):
| |
设计精髓:把"检测"从请求的同步路径上摘掉,用下一次请求顺带带回上一次的检测结果。代价是结果滞后一条消息,靠收尾请求兜底。会话历史同时写 Redis list,供 multi_turn_detect 取上下文。
3.6 配置加载与缓存(坑 5 的完整答案)
| |
所以配置变更最长滞后约 60s;实践中原子能力超时这类变更重建不彻底,需要 docker restart safety-api(笔记-4 坑 5 的经验结论与此机制吻合)。
Redis 在 api 侧的四个用途:会话历史、free_taxi 任务/结果队列、分布式锁、识别结果缓存(user 且单条时按 业务+内容MD5 缓存 15 分钟——这就是笔记-4 说"重试要改动一两个字"的原因)。
四、safety-admin:配置中心与版本化状态机
4.1 管理域与接口
| 管理域 | 路径前缀 | 动作 |
|---|---|---|
| 原子能力 function | /config/defense/manage/function | new / 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_words | upsert / queryByWord / online / offline |
| 红线知识库 | /data/redline_knowledge | CRUD |
| 平台字典 | /platform/dict | CRUD |
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_conf | conf 为 JSON(url、超时、模型参数都在里面) |
business_info | secret_key、access_targets、qps_limit、user/robot 检测配置 |
execute_dag_conf | root_id + conf JSON 节点数组 |
sensitive_words | match_type: contain/equal/regex/compound;handle_strategy: black/white/gray/exclude |
red_line_knowledge | question/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 粗筛出候选正则再精匹配,避免全量正则扫文本(这个设计很值得单独写一篇)
- contain/equal 词 → 直接进 AC Trie;命中后按业务场景过滤、按优先级排序;
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},bodytext_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.py | strategy_example.yml → dagUpdateYaml → online |
演示 DAG 逐节点解读(strategy_example.yml,纵深防御教科书案例)
| |
所有节点 ignoreError: true(单个能力挂了不阻断整体)。ABOUT_DAG.md 里还有更进阶的打法:parallel(rag_answer + bert) 红线代答与模型双证(riskCode 一致且概率 > 0.5 才判风险,防误杀)。
七、值得学习的设计点 vs 开源版的坑(博客素材库)
7.1 设计亮点
- 编排与能力彻底分离:skill 服务无状态、HTTP 解耦,新增能力 = 起一个服务 + admin 注册 + DAG 引用,不动一行 Java 代码
- 配置版本化 + 不可变线上配置:online 禁改、upgrade 原子切换,天然支持灰度回滚(AB 机制还能按 dagVersion 指定版本实时构建)
- free_taxi 模式:把检测从流式输出的同步路径上摘掉,业界少见的工程化解法
- 纵深防御策略:词库粗筛 → 轻模型 → 重模型 → 知识库双证,每层都有成本和准确率权衡
- groovy 脚本做 router:策略逻辑不用发版就能改(虽然要重启 api 才彻底生效)
- 正则倒排加速、RAG 代答一致性校验:细节处的工程功底
7.2 开源版的坑 / 局限
- mq responseMode 是空 stub,要用得自己实现
MessageQueueService - 文档超前于代码:ABOUT_DAG.md 里
executedNodes/nodeCosts等 ctx 字段实际不存在 - MySQL/Redis/Vearch 全都没挂数据卷(笔记-4 已踩:
docker compose down即丢全部配置) - DAG 热更新不彻底:60s 轮询 + 部分变更需重启 api,离 README 宣称的"分钟级热更新"有差距
- groovy eval 全局 synchronized:ScriptEngine 非线程安全,所有脚本串行执行,高并发下是瓶颈
- 技术栈老旧:Spring Boot 2.0.9 / Jedis 2.5.2 / fastjson 1.x 2.x 混用
- “DAG” 实为链式逐跳:没有真正的图拓扑调度,并行只在 parallel 节点内部
- 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 router | safety/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-SHA1 | safety/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 |
| 建表 SQL | safety/safety-admin/docs/tables.sql |
| 演示策略 | docs/strategy_example.yml |
注:Java 源码里 controller 目录拼写为
contoller(原仓库笔误),检索时注意。
