状态:✅ 已完成(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 它:
一、原子能力的契约:统一 HTTP 协议
1.1 type 注册表:能力的"类型"是写死的枚举
safety/safety-api/src/main/java/com/jd/security/llmsec/service/execute/FunctionRegistry.java:20:
| |
类比: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 行)和 110 行)的 switch 都是四分支贯通(fall-through),全部落到同一个 doRun(第 105runFasttext()。modelType 目前只是元数据标签,不影响请求格式。
请求方(.../service/execute/functions/SingleLabelPredFunction.java:116 起):
| |
即你的服务会收到(字段定义见 safety-basic/.../core/check/SingleLabelPredCheck.java:61 的 SkillsReq):
| |
响应方必须返回(ResponseMessage<List<SkillsResp>>,safety-basic/.../core/ResponseMessage.java:11):
| |
逐字段语义(都有代码依据):
| 字段 | 语义 | 代码依据 |
|---|---|---|
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 全链路时序
| |
2.2 admin 侧:版本化状态机与字段校验
接口文档 safety/safety-admin/docs/api/function_api.md,控制器 safety-admin/.../contoller/business/FunctionManageController.java:24。核心实现 FunctionConfigServiceImpl.java,几个会咬人的细节:
| 行为 | 代码 | 后果 |
|---|---|---|
new 强制 status=edit | FunctionConfigServiceImpl.java:45 | skills_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 逐业务比对这个时间戳,变了才触发重载。
由此推出两个重要结论:
- 只注册+online 一个新能力但不改 DAG,api 永远感知不到——新能力不在任何业务的引用集合里,所有业务的 lastChangeTimestamp 都不变。必须配合 DAG 变更(或重启 api)。
- 反过来说,改了被引用能力的配置(如 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)逐行批注:
| |
mergeProperties(第 58~94 行)的规则:引用处(custome)覆盖模板(template);双方都有的 key 且 value 是 Map 时递归下钻;否则 custome 直接赢。
注意一个边界:合并只对 Map 递归,List 是整体替换(第 88 行 else 分支)。所以 ignoreRiskCode: [1006] 会把模板的 [] 整个换掉,不存在"追加"。
2.5 连接池复用与 warmup:热连接的实现
每个远程 Function 初始化时(SingleLabelPredFunction.java:54~93):
| |
- 池与超时归属:
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 节点的 timeoutMilliseconds | ParallelFunction.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):
| |
宿主机裸跑(本文采用的方式):
| |
接入方式二选一(URL 是注册时唯一关心的事):
| 方式 | 注册时 url 写法 | 说明 |
|---|---|---|
| 宿主机裸跑(本文) | http://host.docker.internal:9000/guard/length | Docker Desktop on Mac 内置该域名指向宿主机,容器内直连 |
| 容器化 | http://text-guard:9000/guard/length | 进 joysafety_joy_safety 网络,用容器名互访 |
容器化方式(同样实测通过;镜像用 02 笔记同款的华为云代理源):
| |
3.2 admin 注册并上线
照 example/safety-skills/safety-knowledge/skills_init.py(注册 6 个官方能力的脚本,new + online 两段式)改造:
| |
真实返回(注意 status 是 edit,请求里传 online 也没用——2.2 节讲过 new 强制 edit)。拿到 id=9 后上线:
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:
| |
执行(放 safety-knowledge 容器里跑,自带 PyYAML;宿主机跑则把 ADMIN 换成 http://localhost:8006/...):
变更后的 DAG 结构:
| |
3.4 观察配置同步与 warmup(不用重启!)
DAG 的 upgrade 刷新了 dag 记录的 update_time → 该业务 lastChangeTimestamp 变化 → api 下一轮轮询感知(2.3 节)。api 日志(宿主机 example/safety-api/logs/app.log)真实记录:
| |
Flask 侧日志同步看到(能清楚区分 warmup 与真实流量,注意 warmup 的字段名是 businessName):[req] biz=warmup text_list=['warmup'] 每 60s 一发,真实审核流量则是 biz=test。
本次实验是"新增能力 + 新增 ref",不需要重启 api(连接池里没有旧 client 占位)。什么时候必须重启,见 2.5 节末尾与第四章速查。
3.5 验证:命中与不命中两条路径
两条路径的真实输出对比(截取关键字段):
| |
3856ms vs 28ms 的对比就是纵深防御"便宜的能力放前面"的成本收益。对照 1.3 节:riskMessage 被 api 拼成了 "9001文本超长";detail 原样透传;srcName 是注册的能力名。
3.6 降级实验:服务挂掉时框架的行为
完全符合 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)
附录 C:常见症状自查
| 症状 | 原因 | 解法 |
|---|---|---|
DAG 构建失败日志 function ref 不存在 | ref 名字打错 / 能力没 online | FunctionUtil.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 |
| 请求/响应/配置 POJO | safety-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)经核实与当前代码一致。
