Featured image of post 模型安全检测 09:自定义原子能力(起服务 → 注册 → DAG 引用)

模型安全检测 09:自定义原子能力(起服务 → 注册 → DAG 引用)

用 Python 写一个新检测服务接入 JoySafety 的全链路实操,本机真实环境跑通。

状态:✅ 已完成(2026-09-13,文中实验全部在本机真实环境跑通,返回为真实输出摘录) 前置阅读:04-DAG编排引擎深入(Function/Router/Node 三要素、ref 引用的入口);配置版本化流水线与坑 4/坑 5 见 02-Mac本地部署实操 附录 C 与第三章 读者定位:想把"自己的检测逻辑"接进 JoySafety 的人。读完能回答三个问题:我的服务要长成什么样?注册后配置怎么流到执行引擎?超时/降级行为由哪行代码决定?

学习目标

  • 完整走通 起服务 → admin 注册 → online → DAG ref 引用 → upgrade → 验证 的能力扩展全流程
  • 从源码层面证明"新增能力不改一行 Java 代码“的架构承诺,并掌握协议契约、type 选择、两层超时、连接池与 warmup 的行为细节

〇、一句话原理与全局认识

JoySafety 的"原子能力"本质是一份存在 MySQL 里的配置模板:type 决定由哪个 Java 类执行(框架内置、不可插拔),conf 里的 url 决定 HTTP 调谁。所以"自定义能力"的真实含义是——框架的 Java 代码一行不动,你只需:写一个满足统一协议的 HTTP 服务 + 在 admin 注册一份配置 + 在 DAG 里 ref 它:

1
2
3
4
5
6
7
8
配置期   你的脚本 ──POST──► safety-admin:8006 ──写入──► MySQL(function_conf 表)
                              ▲
                              │ ① api 每 60s 轮询 /config/defense/api/conf
执行期   safety-api:8007 ─────┘
           │ ② 配置变更 → 重建 DAG(doMerge 展开 ref → 按 type 反射实例化 Function)
           │ ③ 请求来了:Function 用 OkHttpClient POST 你的服务
           ▼
        你的检测服务(Flask/任意语言,无状态)

一、原子能力的契约:统一 HTTP 协议

1.1 type 注册表:能力的"类型"是写死的枚举

safety/safety-api/src/main/java/com/jd/security/llmsec/service/execute/FunctionRegistry.java:20:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static final Map<RiskCheckType, Class<? extends AbstractFunction>> FUNC_MAP = ... {{
    put(RiskCheckType.dummy, DummyFunction.class);
    put(RiskCheckType.keyword, KeywordFunction.class);
    put(RiskCheckType.single_label_pred, SingleLabelPredFunction.class);
    put(RiskCheckType.multi_label_pred, MultiLablePreFunction.class);
    put(RiskCheckType.kb_search, KbSearchFunction.class);
    put(RiskCheckType.rag_answer, RagAnswerFunction.class);
    put(RiskCheckType.multi_turn_detect, MultiTurnDetectFunction.class);
    put(RiskCheckType.parallel, ParallelFunction.class);
    put(RiskCheckType.async, AsyncFunction.class);
}};

类比:type 是"电器插头的制式”,url 是"插在哪个插座上"。你只能选现有制式,不能发明新插头——除非改这段 Java 代码并重新构建镜像。

9 个 type 里真正适合自定义业务能力的只有 6 个远程调用型(keyword / single_label_pred / kb_search / rag_answer / multi_turn_detect / multi_label_pred),其中 multi_label_pred 是个坑:注册了但 MultiLablePreFunction.doRun() 是空实现(.../functions/MultiLablePreFunction.java:33,方法体只有 // todo 后续支持; return null),注册它不会报错,但运行时永远返回 null。dummy/parallel/async/mixed 是编排内部类型,不对外。

1.2 single_label_pred 协议:一个协议吃掉四种模型

SingleLabelPredFunction 用 modelType 区分四种模型(safety-basic/.../core/conf/funcs/ModelType.java:8:bert/fasttext/textcnn/llm),但看代码会发现四种 modelType 走的是同一段 HTTP 调用逻辑——doInit(第 5963 行)和 doRun(第 105110 行)的 switch 都是四分支贯通(fall-through),全部落到同一个 runFasttext()。modelType 目前只是元数据标签,不影响请求格式。

请求方(.../service/execute/functions/SingleLabelPredFunction.java:116 起):

1
2
3
4
5
6
SingleLabelPredCheck.SkillsReq req = new SingleLabelPredCheck.SkillsReq();
req.setBusiness_id(context.getBusinessConf().getName());       // 业务名
req.setRequest_id(firstReq.getRequestId());                    // 请求ID
req.setSession_id(firstReq.getMessageInfo().getSessionId());   // 会话ID
req.setText_list(Lists.newArrayList(context.getCheckContent()));// 待检文本,定长1
// POST extra.url,body 即上面的 req

即你的服务会收到(字段定义见 safety-basic/.../core/check/SingleLabelPredCheck.java:61 的 SkillsReq):

1
{"business_id": "test", "request_id": "xxx", "session_id": "yyy", "text_list": ["待检测文本"]}

响应方必须返回(ResponseMessage<List<SkillsResp>>,safety-basic/.../core/ResponseMessage.java:11):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "code": 0,              // 必须 0,否则 api 抛 inner_error
  "message": "success",
  "data": [{              // 数组非空,api 只取 data[0]
    "riskCode": 9001,     // 必须显式返回;0=正常,非0=有风险(hasRisk() 唯一判据)
    "riskMessage": "文本超长",
    "probability": 0.99,  // 置信度,供 groovy 阈值表判定,框架本身不判
    "text": "原文",        // 可选,回显
    "detail": [{"riskCode": 9001, "riskMessage": "文本超长", "probability": 0.99}]  // 可选,全标签分布
  }]
}

逐字段语义(都有代码依据):

字段语义代码依据
code必须是 0,否则 api 侧抛 inner_error,该节点按异常处理SingleLabelPredFunction.java:131 的 respMessage.success()
data数组且非空,只取 data[0]SingleLabelPredFunction.java:136/141
riskCode必须显式返回。默认/缺失时不是"正常"而是"有风险"RiskCheckResult.java:16 默认 -1;hasRisk() 仅判 riskCode != 0(RiskCheckResult.java:28)
probability置信度。策略层阈值判定用;框架本身不判groovy 脚本里的 thresholds_xxx[riskCode] 表(演示策略)
detail全标签概率分布,调试用;演示策略用它做"正常置信度太低也拦截"的反查SingleLabelPredCheck.java:27

参考实现:Python 侧 BERT 服务的组包代码 safety/safety-skills/safety-bert/app/model/bert_infer.py:55(riskCode/riskMessage/probability/text/detail 五件套),FastText 服务的 Flask 返回 safety/safety-skills/safety-fasttext/app/model/models.py:42({"code":0,"message":"ok","cost":...,"data":[...]})。

1.3 两个内建的后处理机关

注册配置里还有两个容易忽略的字段(safety-basic/.../core/conf/funcs/SingleLabelPredConf.java:20):ignoreRiskCode 是 api 侧后处理——你的服务返回的 riskCode 若在这个列表里,会被改写成 defaultRisk(默认 (0,"正常文本"),LabelUtil.java:13),见 SingleLabelPredFunction.java:157 的 doIgnore();defaultRisk 则可自定义"忽略后改成什么"。演示策略里 bert 节点挂 ignoreRiskCode: [1006] 就是这个用途:模型判"辱骂"时这个节点当作没看见(辱骂由关键词节点负责)。

另外注意:最终返回给业务方的 riskMessage 会被 api 拼接上 riskCode 前缀——FunctionExecutorImpl.java:341:若 riskMessage 首字符不是数字,则改写为 riskCode + riskMessage。所以服务返回"文本超长",业务方收到的是 "9001文本超长"(本文实验实测如此)。

1.4 type 选择策略:什么时候复用,什么时候才需要新类型

你的能力形态选择
给文本打一个标签 + 置信度(分类器、规则打分、调外部 API 后的单结论)single_label_pred(首选,协议最简单)
黑白名单/正则词匹配keyword(但通常直接用官方 keywords 服务即可)
需要向量检索返回命中文档kb_search;需要检索+代答
需要会话历史的多轮判定multi_turn_detect
要给一条文本打多个标签开源版没有可用实现(multi_label_pred 是空壳),两个选择:拆成多个 single_label_pred 能力并行,或改 Java
协议字段完全不够用(如要传图片、要流式)只能改 Java:加一个 RiskCheckType + 一个 AbstractFunction 子类 + 注册表一行,重新构建 safety-api 镜像

结论:90% 的自定义检测逻辑都能塞进 single_label_pred——它的 riskCode 是开放整数空间,你的服务自定一套风险码(避开官方的 0/201/1001~1008/2001/3002/4002/5001/5002 即可),判定逻辑全部下沉到你的服务里,框架只负责调度与透传。


二、注册链路源码解剖:配置怎么流转

2.1 全链路时序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
注册请求 POST /function/new + online
  ▼
safety-admin ──► MySQL function_conf 表(版本化状态机 edit → online)
  ▲                ▲ admin 每 60s 聚合 online 配置到内存(DefenseConfigServiceImpl.refreshConf)
  │ ① api 每 60s 轮询 POST /config/defense/api/conf {group:"default"}
DynamicConfigService.loadConf()  @Scheduled(fixedRate=60000, initialDelay=60000)
  │ ② shouldUpdate:逐业务比对 lastChangeTimestamp,变了才继续
  ▼ 全量替换内存 name2BusinessConf / nodeConfMap / functionConfMap
  │ ③ 回调监听器 → FunctionChainBuilderImpl.loadConf() 逐业务重建 DAG:
  ▼    YAML节点 → doMerge(ref展开) → 反射new Function → 构建Router → rootMap
  │ ④ Function 构造函数里:建/复用 OkHttpClient + 注册 warmup
  ▼ 执行期请求按新 DAG 流转

2.2 admin 侧:版本化状态机与字段校验

接口文档 safety/safety-admin/docs/api/function_api.md,控制器 safety-admin/.../contoller/business/FunctionManageController.java:24。核心实现 FunctionConfigServiceImpl.java,几个会咬人的细节:

行为代码后果
new 强制 status=editFunctionConfigServiceImpl.java:45skills_init.py 里传的 "status":"online" 其实被忽略,必须再调一次 online
desc 必填第 60 行 checkArgument(desc非空, "desc为空")文档没写但缺了报 400
同 group+name 已有 online 则拒绝 new第 47~50 行重复注册报错"function已经存在,可以更新已有的"
online 状态禁止 update第 171~173 行 cannotUpdateOnline改配置必须走 newVersion→update→upgrade
newVersion 只能从 online 版本复制第 236 行流水线的入口固定是 active
upgrade 原子切换:旧 offline + 新 online,双双刷新 update_time第 279~283 行 offlineAndOnline这就是触发 api 感知的"信号量"(见下)

single_label_pred 类型的 conf 校验在第 133~147 行:modelType、name 必填,四种 modelType 都要求 extra.url 非空。

2.3 变更感知:lastChangeTimestamp 的精确计算

api 不是无条件全量重载。admin 组装配置时(DefenseConfigServiceImpl.java:274~291),每个业务的 lastChangeTimestamp = 业务记录、DAG 记录、该业务 DAG 所引用 function 记录三者的 update_time 最大值。api 侧 DynamicConfigService.java:142~146 逐业务比对这个时间戳,变了才触发重载。

由此推出两个重要结论:

  1. 只注册+online 一个新能力但不改 DAG,api 永远感知不到——新能力不在任何业务的引用集合里,所有业务的 lastChangeTimestamp 都不变。必须配合 DAG 变更(或重启 api)。
  2. 反过来说,改了被引用能力的配置(如 timeout),对应业务的 DAG 会被自动重建——那 02 笔记坑 5 为什么还要重启?答案在下面的连接池机制里。

2.4 ref 引用与深合并:模板+覆盖

DAG 节点里 - ref: text_length_guard 是个"引用",构建时被展开。入口 FunctionChainBuilderImpl.java:92 调 FunctionUtil.doMerge();parallel 节点的子能力同样在 ParallelFunction.java:71 逐个 doMerge。

doMerge(safety-api/.../utils/FunctionUtil.java:39)逐行批注:

1
2
3
FunctionConfig copyFrom = ConfigService.functionConfMap.get(cur.getRef()); // 44: 按名字查注册的能力模板
if (copyFrom == null) { throw new Exception("function ref 不存在: " + cur.getRef()); } // 46: ref 打错字=DAG 构建失败
Map<String, Object> mergedConf = mergeProperties(copyTo.getConf(), cur.getConf());   // 51: 深合并

mergeProperties(第 58~94 行)的规则:引用处(custome)覆盖模板(template);双方都有的 key 且 value 是 Map 时递归下钻;否则 custome 直接赢。

1
2
3
4
5
模板(注册时)          引用处(DAG 里)            合并结果
conf:                conf:                     conf:
  extra.url: A         ignoreRiskCode: [1006]      extra.url: A
  modelType: bert                                  modelType: bert
  ignoreRiskCode: []                               ignoreRiskCode: [1006]  ← 引用处赢

注意一个边界:合并只对 Map 递归,List 是整体替换(第 88 行 else 分支)。所以 ignoreRiskCode: [1006] 会把模板的 [] 整个换掉,不存在"追加"。

2.5 连接池复用与 warmup:热连接的实现

每个远程 Function 初始化时(SingleLabelPredFunction.java:54~93):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
long timeout = 150;                                              // 54: 兜底的默认超时
if (this.conf().getTimeoutMilliseconds() != null)
    timeout = this.conf().getTimeoutMilliseconds();              // 56: 注册时的 timeoutMilliseconds
Object resource = FunctionResourcePool.getFunc(type(), this.fasttextConf.getUrl()); // 68: 按 type+url 查池
if (resource != null) {
    this.fasttextClient = (OkHttpClient) resource;               // 70: 命中直接复用
} else {
    Object temp = new OkHttpClient.Builder().callTimeout(timeout, MILLISECONDS).build(); // 73: 新建
    Object uniqResource = FunctionResourcePool.addFuncIfAbsent(type(), url, temp);
    if (temp == uniqResource) {                                  // 76: 确认自己是"首创者"
        ScheduleWarmup.appendTask(...)                           // 77: 注册周期性预热任务
    }
}
  • 池与超时归属:FunctionResourcePool.java:14 是静态 ConcurrentMap,key = type + "$" + url(第 16/21 行),同一 URL 的多个能力共享同一个 OkHttpClient,且池子全生命周期不清空(类里没有 remove 方法)。OkHttp 的 callTimeout 在 client 创建时定死——谁先在池里注册了这个 URL,就用谁的 timeoutMilliseconds,后续同 URL 能力改超时无效。
  • warmup:ScheduleWarmup.java:40 的 @Scheduled(initialDelay=5000, fixedRate=60000)——从应用启动 5 秒后开始,每 60 秒把 tasks 里所有任务各跑一遍(3 线程池,线程名 warm-up-N)。预热请求是真实的 HTTP POST:{"text_list": ["warmup"], "businessName": "warmup"}(SingleLabelPredFunction.java:81~83)。配合 HttpHelper.keepaliveHeader()(HttpHelper.java:87:Connection: Keep-Alive、Keep-Alive: timeout=20, max=5000),保持到能力服务的长连接不断、远端服务不退磁(对有 JIT/惰性加载的模型服务尤其重要)。

你的服务必须容忍 warmup 请求:text_list=["warmup"],且注意 warmup 的 body 里业务字段叫 businessName,而真实请求里叫 business_id(源码两处字段名不一致,实测如此)——别对字段做强校验,宽松解析即可。

这就是 02 笔记坑 5 的源码级根因:修改已存在能力的 timeoutMilliseconds → admin 侧 upgrade 成功 → api 60s 内感知、DAG 重建、Function 重新实例化——但 doInit 里 getFunc(type, url) 命中了池里的旧 client(旧超时),新超时根本没生效。只有 docker restart safety-api 让 JVM 重启、静态池清零,新 client 才会带着新超时被创建。改 URL(哪怕只改路径)可以绕过,因为新 URL 会建一个新 client。

2.6 两层超时的完整图景(02 笔记坑 4 的代码答案)

层配置位置生效代码超时后果
① 整体 DAG业务配置 user/robotCheckConf.timeoutMilliseconds(默认 1000ms,CheckConf.java:14)FunctionExecutorImpl.java:297 每节点前 context.timeout()抛 exec_timeout,链路中断
② DAG 节点(parallel)策略 YAML 节点的 timeoutMillisecondsParallelFunction.java:119 起:总预算递减,remainCost < 0 时退化为 1ms剩余子项结果被丢弃
③ 原子能力 HTTP注册能力的 timeoutMilliseconds创建 OkHttpClient 的 callTimeout(SingleLabelPredFunction.java:73);parallel 内还经过 Future.get(该function超时)(ParallelFunction.java:123~130)双重生效该能力结果被丢弃(异常→resultMap 缺该项)

02 笔记踩坑时改的是 ② 和 ③;接慢能力时三层都要心里有数。

2.7 失败语义:能力挂掉 = 弃权,不是拦截

parallel 节点里子 function 抛异常时(ParallelFunction.java:132~135)只记日志和监控埋点,该 function 的结果不进 resultMap。groovy router 里拿到的是 null,通常写法 lenGuard != null && lenGuard.hasRisk() 直接放过。加上节点级 ignoreError: true(NodeConfig.java:17 默认就是 true,FunctionExecutorImpl.java:312),单个能力宕机不阻断整体链路。

这是安全系统的取舍:可用性优先于拦截率。生产化时需要用监控埋点(MonitorAlarmService.parallelIncr)把"弃权率"管起来,否则能力全挂了审核还是"正常放行",形同虚设。


三、动手实验:一个 Flask 能力服务的全链路

实验环境即 02 笔记部署的本机环境(全部容器 Up,网络 joysafety_joy_safety)。本节每一条命令都真实执行过,返回为真实输出摘录。

3.1 写演示服务:文本长度检测

/tmp/joysafety-demo/text_guard.py(完整可运行,依赖只有 flask):

 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
28
29
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
MAX_LEN = 10          # 演示阈值:超过 10 个字符判风险
RISK_CODE = 9001      # 自定义风险码(避开框架已有的 0/201/1001~1008 等)

@app.route('/guard/length', methods=['POST'])
def guard_length():
    start = time.time()
    req = request.get_json(force=True)
    text_list = req.get('text_list') or []
    print(f"[req] biz={req.get('business_id') or req.get('businessName')} "
          f"text_list={text_list}", flush=True)
    data = []
    for text in text_list:
        if len(text) > MAX_LEN:
            item = {"riskCode": RISK_CODE, "riskMessage": "文本超长", "probability": 0.99, "text": text}
            item["detail"] = [{"riskCode": RISK_CODE, "riskMessage": "文本超长", "probability": 0.99},
                              {"riskCode": 0, "riskMessage": "正常文本", "probability": 0.01}]
        else:
            item = {"riskCode": 0, "riskMessage": "正常文本", "probability": 1.0, "text": text}
            item["detail"] = [{"riskCode": 0, "riskMessage": "正常文本", "probability": 1.0}]
        data.append(item)
    return jsonify({"code": 0, "message": "success",
                    "cost": (time.time() - start) * 1000, "data": data})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9000)

宿主机裸跑(本文采用的方式):

1
2
3
4
5
6
7
mkdir -p /tmp/joysafety-demo && cd /tmp/joysafety-demo
python3 -m venv venv && ./venv/bin/pip install flask
./venv/bin/python text_guard.py &     # 监听 0.0.0.0:9000

# 自测(真实输出):短文本 riskCode=0,长文本 riskCode=9001
curl -s -X POST http://127.0.0.1:9000/guard/length -H 'Content-Type: application/json' \
  -d '{"request_id":"r1","business_id":"test","session_id":"s1","text_list":["你好"]}'

接入方式二选一(URL 是注册时唯一关心的事):

方式注册时 url 写法说明
宿主机裸跑(本文)http://host.docker.internal:9000/guard/lengthDocker Desktop on Mac 内置该域名指向宿主机,容器内直连
容器化http://text-guard:9000/guard/length进 joysafety_joy_safety 网络,用容器名互访

容器化方式(同样实测通过;镜像用 02 笔记同款的华为云代理源):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
cd /tmp/joysafety-demo
cat > Dockerfile <<'EOF'
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask -i https://mirrors.aliyun.com/pypi/simple/
COPY text_guard.py .
CMD ["python", "text_guard.py"]
EOF
docker build -t text-guard:demo .
docker run -d --name text-guard --network joysafety_joy_safety text-guard:demo
# 从 safety-api 容器内验证连通性(真实输出:长文本返回 9001):
docker exec safety-api sh -c 'curl -s -m 3 -X POST http://text-guard:9000/guard/length \
  -H "Content-Type: application/json" -d "{\"text_list\":[\"容器网络内的连通性测试文本\"]}"'
# 验证完清理:docker rm -f text-guard

3.2 admin 注册并上线

照 example/safety-skills/safety-knowledge/skills_init.py(注册 6 个官方能力的脚本,new + online 两段式)改造:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
curl -s -X POST http://localhost:8006/config/defense/manage/function/new \
  -H 'Content-Type: application/json' -d '{
    "name": "text_length_guard",  "group": "default",  "desc": "文本长度检测(演示)",
    "type": "single_label_pred",  "timeoutMilliseconds": 500,
    "confObj": {
      "name": "text_length_guard",  "modelType": "fasttext",
      "extra": {"url": "http://host.docker.internal:9000/guard/length"},
      "ignoreRiskCode": []
    }
  }'

真实返回(注意 status 是 edit,请求里传 online 也没用——2.2 节讲过 new 强制 edit)。拿到 id=9 后上线:

1
2
3
curl -s -X POST http://localhost:8006/config/defense/manage/function/online \
  -H 'Content-Type: application/json' -d '{"id": 9}'
# → 真实返回:code=0, data.status=online(version=1)

modelType 填的是 fasttext——填 bert 完全一样(1.2 节:四分支贯通同一段代码),它只是元数据标签。

3.3 DAG 里 ref 引用并 upgrade

走 02 笔记附录 C 的流水线:active → newVersion → update → upgrade。计划改动:在 from_user_test1 并行节点里加 - ref: text_length_guard,并在它的 groovy router 最前面加短路判定。保存为 update_dag_add_guard.py:

 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
28
29
30
31
32
import json, urllib.request, yaml

ADMIN = "http://safety-admin:8006/config/defense/manage"
# post(path, body) 辅助函数与 02 笔记附录 A 相同,略

active = post("/dag/active", {"group": "default", "businessName": "test"})
dag = active["data"]
new_id = post("/dag/newVersion", {"id": dag["id"]})["data"]["id"]

NEW_ROUTER = """import com.jd.security.llmsec.core.check.MixedCheck
def retMap=ctx.curResult.resultMap
def next="from_user_test_bert2"
def kw = retMap.keyword
def lenGuard = retMap.text_length_guard
if (lenGuard != null && lenGuard.hasRisk()) {
    ctx.curResult = lenGuard
    return null
}
if (kw != null && (kw.bwgLabel == 1 || kw.bwgLabel == 2 )) { ctx.curResult = kw; return null } else { return next }
"""

conf_array = dag["confArray"]
for node in conf_array:
    if node.get("nodeId") == "from_user_test1":
        node["functionConf"]["conf"]["functionConfs"].append({"ref": "text_length_guard"})
        node["routerConf"]["conf"]["script"] = NEW_ROUTER

yaml_body = yaml.safe_dump({"id": new_id, "businessName": "test", "group": "default",
    "desc": dag.get("desc", "test"), "rootId": dag["rootId"], "confArray": conf_array},
    allow_unicode=True)
post("/dag/dagUpdateYaml", yaml_body, content_type="text/plain")
print(post("/dag/upgrade", {"id": new_id}))

执行(放 safety-knowledge 容器里跑,自带 PyYAML;宿主机跑则把 ADMIN 换成 http://localhost:8006/...):

1
2
3
docker cp update_dag_add_guard.py safety-knowledge:/work/
docker exec safety-knowledge python /work/update_dag_add_guard.py
# 真实输出:当前线上 DAG id=3 version=3;新版本 id=5;upgrade 后 version=4, status=online

变更后的 DAG 结构:

1
2
3
4
5
6
start(按 fromRobot 分流)
  ├─ robot → from_robot_test1: ref keyword → stupid_end
  └─ user  → from_user_test1: parallel(keyword, text_length_guard)   ← 新能力在这里
               │ router:lenGuard 有风险 → 短路返回 9001
               │         关键词命中黑/白 → 短路返回
               └────────→ from_user_test_bert2: parallel(bert_20250916, bert_prompt_injection_20250828)

3.4 观察配置同步与 warmup(不用重启!)

DAG 的 upgrade 刷新了 dag 记录的 update_time → 该业务 lastChangeTimestamp 变化 → api 下一轮轮询感知(2.3 节)。api 日志(宿主机 example/safety-api/logs/app.log)真实记录:

1
2
3
16:41:05 DynamicConfigService:97  | 配置更新成功,function={..., "text_length_guard":{"conf":{"extra":{"url":"http://host.docker.internal:9000/guard/length"}, ...}}, ...}
16:41:10 SingleLabelPredFunction:77 | warm up fasttext, url=http://host.docker.internal:9000/guard/length   ← 首次预热
16:42:10 SingleLabelPredFunction:77 | warm up fasttext, url=http://host.docker.internal:9000/guard/length   ← 每 60s 一次

Flask 侧日志同步看到(能清楚区分 warmup 与真实流量,注意 warmup 的字段名是 businessName):[req] biz=warmup text_list=['warmup'] 每 60s 一发,真实审核流量则是 biz=test。

本次实验是"新增能力 + 新增 ref",不需要重启 api(连接池里没有旧 client 占位)。什么时候必须重启,见 2.5 节末尾与第四章速查。

3.5 验证:命中与不命中两条路径

1
2
3
4
# 路径一:不命中(2 个字符 ≤ 阈值 10)
docker exec safety-knowledge python /work/api_test.py '你好'
# 路径二:命中(长度 22 > 阈值 10)
docker exec safety-knowledge python /work/api_test.py '这段文本长度明显超过十个字符用来触发自定义能力'

两条路径的真实输出对比(截取关键字段):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 不命中:cost=3856ms —— 长度检测和关键词都放行,继续走完两个 BERT 才判正常
{"code": 0, "cost": 3856,
 "data": [{"checkedContent": "你好", "riskCode": 0, "riskMessage": "0正常文本",
           "riskCheckType": "mixed", "riskCheckName": "none"}]}

// 命中:cost=28ms —— 在第一个并行节点被短路返回,根本没走到 BERT
{"code": 0, "cost": 28,
 "data": [{"checkedContent": "这段文本长度明显超过十个字符用来触发自定义能力",
   "riskCode": 9001, "riskMessage": "9001文本超长",
   "riskCheckType": "single_label_pred", "riskCheckName": "text_length_guard",
   "riskCheckResult": {"type": "single_label_pred", "riskCode": 9001,
     "srcName": "text_length_guard", "cost": 14, "probability": 0.99,
     "detail": [{"riskCode": 9001, "riskMessage": "文本超长", "probability": 0.99}, {"riskCode": 0, ...}]}}]}

3856ms vs 28ms 的对比就是纵深防御"便宜的能力放前面"的成本收益。对照 1.3 节:riskMessage 被 api 拼成了 "9001文本超长";detail 原样透传;srcName 是注册的能力名。

3.6 降级实验:服务挂掉时框架的行为

1
2
3
pkill -f text_guard.py    # 模拟能力宕机
docker exec safety-knowledge python /work/api_test.py '服务挂掉时这段超过十个字符的文本会被放行吗'
# 真实输出:riskCode=0, riskCheckType=mixed —— 放行了!

完全符合 2.7 节的源码结论:parallel 节点内 HTTP 调用失败 → 该 function 结果不进 resultMap → groovy 里 lenGuard == null → 继续走 BERT → 正常放行。自定义能力上线后,务必把"该能力最近一分钟有没有返回"纳入监控,否则它挂了你看到的是"天下太平"。实验完恢复服务:cd /tmp/joysafety-demo && ./venv/bin/python text_guard.py &。


四、速查附录

附录 A:原子能力管理接口速查(admin,8006)

接口用途关键入参
/config/defense/manage/function/new注册(强制 edit 态,desc 必填)name/group/type/timeoutMilliseconds/confObj
/online /offline上/下线id
/active查线上版本group + name
/newVersion → /update → /upgrade改配置三连:复制新版本 → 改内容 → 原子切换上线id(upgrade 用新版本的 id)

附录 B:注册 confObj 模板(single_label_pred)

1
2
{"name": "你的能力名", "modelType": "fasttext",
 "extra": {"url": "http://你的服务地址/路径"}, "ignoreRiskCode": []}

附录 C:常见症状自查

症状原因解法
DAG 构建失败日志 function ref 不存在ref 名字打错 / 能力没 onlineFunctionUtil.java:46;先 /function/active 确认
注册了新能力但 api 像没看见没有 DAG 变更,lastChangeTimestamp 不动(2.3 节)配合 DAG upgrade,或重启 api
改了能力的 timeoutMilliseconds 不生效OkHttpClient 按 type+url 复用旧实例(2.5 节)docker restart safety-api(等 1~2 分钟),或改 url 路径绕过
本该命中的没命中、耗时卡在 2s两层超时丢结果(02 笔记坑 4)DAG 节点层 + 能力注册层都调大
报"调用接口失败",或没返回 riskCode 的文本全被判风险code != 0 / data 空数组;riskCode 缺省 -1 → hasRisk() 为 true协议见 1.2 节:code 必须为 0、riskCode 必须显式返回
能力挂掉后审核全放行ignoreError 容错语义(2.7 节)加监控,不是改框架

附录 D:本文引用的源码坐标

主题位置
type 注册表safety/safety-api/src/main/java/com/jd/security/llmsec/service/execute/FunctionRegistry.java:20
统一远程调用safety/safety-api/src/main/java/com/jd/security/llmsec/service/execute/functions/SingleLabelPredFunction.java:116(doInit 在 :49)
ref 深合并safety/safety-api/src/main/java/com/jd/security/llmsec/utils/FunctionUtil.java:39
连接池 / warmup.../service/execute/functions/FunctionResourcePool.java:13 / .../service/ScheduleWarmup.java:40
请求/响应/配置 POJOsafety-basic/.../core/check/SingleLabelPredCheck.java:61 / .../core/conf/funcs/SingleLabelPredConf.java:20
admin 注册状态机safety/safety-admin/src/main/java/com/jd/security/llmsec/service/business/FunctionConfigServiceImpl.java:41
lastChangeTimestamp 计算safety/safety-admin/src/main/java/com/jd/security/llmsec/service/business/DefenseConfigServiceImpl.java:274
api 配置轮询safety/safety-api/src/main/java/com/jd/security/llmsec/service/DynamicConfigService.java:65
接口文档 / 官方脚本safety/safety-admin/docs/api/function_api.md / example/safety-skills/safety-knowledge/skills_init.py

坐标勘误声明(相对本篇原提纲,以实际代码为准):提纲中 engine/FunctionRegistry.java 实为 service/execute/FunctionRegistry.java;engine/functions/ 实为 service/execute/functions/;util/FunctionUtil.java 实为 utils/FunctionUtil.java;ScheduleWarmup.java 在 service/ 下;example/safety-skills/skills_init.py 实为 example/safety-skills/safety-knowledge/skills_init.py。提纲给的行号(:20/:116/:39)经核实与当前代码一致。

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