状态:✅ 已完成 前置阅读:02-Mac本地部署实操(部署与踩坑)、03-技术架构与业务模块分析(模块全景) 日期:2026-09-14 素材来源:safety-basic / safety-api 源码逐行核实;所有
path:line坐标均经 Read 验证 读者定位:想写自定义策略、自定义原子能力,或搞懂"groovy 脚本里那个 ctx 到底是什么"的读者
〇、先建立全局认识:两条链路看 DAG
DAG 引擎不是孤立存在的,它串在两条链路的交汇点上。
配置怎么流转(改策略 → 生效):
| |
请求怎么流转(业务方 → 响应):
| |
坐标勘误(对前期分析):safety-api 下引擎实现类的真实包名是
com.jd.security.llmsec.service.execute(执行器、构建器、注册表)与service.execute.functions(各 Function 实现),不是提纲里写的engine/。下文一律用真实路径。
一、引擎三要素:Function / Router / Node
1.1 三个抽象一句话
Function接口:safety/safety-basic/.../core/engine/Function.java:14,只有 conf()/type()/name()/run() 四个方法。Router接口:core/engine/Router.java:17,自带静态注册表ROUTER_MAP(:23,7 种实现,详见第四节)。Node类:core/engine/Node.java:24,@Data 四个字段:conf / function / reduceEngine / router(:25-28)。
1.2 AbstractFunction:模板方法,结果自动归档
所有 Function 实现都继承 AbstractFunction(core/engine/AbstractFunction.java:20)。构造时校验 type/name/timeoutMilliseconds 非空(:25-28)并调 doInit(conf) 让子类初始化(:30)。核心是 run() 模板方法(:41-59):
| |
批注:这里把"每个能力的检测结果"自动存进 ctx.middleResults(按 RiskCheckType 分桶),并打上 srcName(function 名)和 cost。groovy router 里 ctx.middleResults[RiskCheckType.single_label_pred] 能翻到历史所有 BERT 结果,靠的就是这两行。注意 parallel 类型归档的是空壳(只有 srcName+cost),真正的子结果在归约后才产生。
1.3 Node.run():还有一层节点级 reduceScript
Node.run()(Node.java:43-61)不只是调 Function:
| |
reduceScript 在 Node 构造时经 GroovyHelper.engin() 预热编译(Node.java:34-41)。bindings 里有 ctx/curNode/curResult 三个变量,可以在不离开节点的情况下改写结果。脚本抛异常不阻断链路(④),这点和 GroovyRouter 的"异常即结束"(见 4.2)形成对照。
1.4 名为 DAG、实为链式逐跳
全框架没有 Edge 实体类。边 = Router 返回的 nodeId 字符串,经 nodeMap.get() 换成下一个 Node。执行主循环在 FunctionExecutorImpl.doExecute()(safety-api/.../service/execute/FunctionExecutorImpl.java:289):
| |
(原文 :296-333,略有删减)逐行批注:
- ① 整体超时按跳检查,不是按函数中断——一个已经发起 HTTP 的 function 不会被这层超时打断,只是跑完后结果被丢弃(链路直接抛异常)。
- ② 每跳开始清空
curResult——所以 groovy 脚本里读ctx.curResult拿到的永远是上一跳的结果。 - ④⑤
ignoreError默认 true(NodeConfig.java:17),单个能力挂了链路继续,这就是"演示策略所有节点 ignoreError: true"的含义。 - ⑥⑦
Node.next()(Node.java:63-69):router==null或 router 返回 null 都结束;router 返回的 nodeId 在 nodeMap 里查不到也是静默结束(nodeMap.get()返回 null),写错 nodeId 不会报错,只会提前收尾——新手高发坑。
二、DAG 的构建:配置怎么变成内存里的链
构建入口 FunctionChainBuilderImpl.build()(safety-api/.../service/execute/FunctionChainBuilderImpl.java:73),两遍扫描:
| |
- ① ref 深合并(
FunctionUtil.doMerge(),FunctionUtil.java:39):节点只写ref: keyword时,从静态注册表ConfigService.functionConfMap(:44,由 DynamicConfigService.loadFunctions() 在轮询时全量刷新,DynamicConfigService.java:153-173)取出 admin 里注册的原子能力模板,type/target/name/desc/timeout 整份拷贝(copyProperties, :108-115),conf部分做 map 递归深合并(mergeProperties, :58-94)——节点自写的 key 覆盖模板,模板独有的 key 保留,value 是 Map 时递归,是 List/标量时整体替换。这就是"DAG 里conf.ignoreRiskCode能局部覆盖原子能力注册参数"的机制。 - ② 反射建 Function:
FunctionUtil.buildFunction()(:26-36)查FunctionRegistry.FUNC_MAP拿实现类,调第一个构造器。校验 timeoutMilliseconds 必须 >0(:33)。 - ③ 反射建 Router(:101-106):查
Router.ROUTER_MAP,构造器统一签名(RouterConfig, nodeMap)——nodeMap 此时已含全部节点,所以 Router 可以引用排在自己后面的节点(前向引用合法)。
构建产物存 rootMap(:46,业务名 → root Node)。触发时机两个:@PostConstruct 首载(:47-49)+ 注册为配置变更监听器(:50),轮询发现变更后由 DynamicConfigService 提交线程池回调(:106-110)。重建是全量的:所有业务全部重新 doMerge、重新 new OkHttpClient、重新预热 groovy。
AB 按 dagVersion 构建(:109-125):请求带 ab.dagVersion>0 时不查 rootMap,而是实时调 admin 按版本取配置、现场 build() 一份新 DAG——每次请求都重建(无缓存),只适用于灰度验证,别拿去压测。
三、Function 类型体系
3.1 注册表:9 种
FunctionRegistry.FUNC_MAP(safety-api/.../service/execute/FunctionRegistry.java:20):
| type | 实现类 | 干什么 |
|---|---|---|
| dummy | DummyFunction | 空操作,直接 MixedCheck.noRisk()(DummyFunction.java:27-28),做起始节点 |
| keyword | KeywordFunction | HTTP 调 safety-keywords 敏感词服务 |
| single_label_pred | SingleLabelPredFunction | HTTP 调 bert/fasttext 单标签分类 |
| multi_label_pred | MultiLablePreFunction(原文拼写如此) | 多标签分类 |
| kb_search | KbSearchFunction | 知识库向量检索 |
| rag_answer | RagAnswerFunction | RAG 红线代答 |
| multi_turn_detect | MultiTurnDetectFunction | 多轮对话检测(LLM) |
| parallel | ParallelFunction | 节点内并行(第六节) |
| async | AsyncFunction | 异步包一层(见 3.3) |
3.2 HTTP 远程 function 的统一套路
以 KeywordFunction 为例(functions/KeywordFunction.java:46-66):
| |
三个要点:
- 连接池按
type$url全局共享(FunctionResourcePool.java:15-28)——同一个 skill 地址只建一个 OkHttpClient,Keep-Alive 头在 doRun 里显式带(:80-82)。 - 超时"首建者生效":client 是共享的,后来者的 timeoutMilliseconds 不会重建 client。改原子能力超时后必须重建 DAG(配置变更触发)甚至重启 api 才彻底生效——这正是 02 篇坑 5 的代码级根因之一。
- warmup:SingleLabelPredFunction 在"自己亲手建了 client"时挂一个
ScheduleWarmup.appendTask,启动后发一条假请求预热连接和模型(SingleLabelPredFunction.java:76-92);kb_search/rag_answer/multi_turn_detect 同理(各自 :85 附近的 warmup 日志)。
3.3 async:把任意 function 包成异步
AsyncFunction(functions/AsyncFunction.java:36)doInit 时把 conf 解析成内层 FunctionConfig 再 buildFunction 出真实能力(:45-49)。doRun(:63-103):往独立线程池(20 线程/队列 50/DiscardOldestPolicy,:55-60)提交任务——复制一份 ctx(重置 middleResults 和计时),异步跑真实 function,结果包成 AsyncCheckResult 经 appendResponseData 写结果队列;同步侧立即返回当前 curResult 原样放行。队列满时丢最老任务,典型的"检测可以丢、业务不能堵"。
四、Router 详解(7 种)
Router.ROUTER_MAP(Router.java:23-31)总览:
| type | 实现 | 语义 | 关键代码 |
|---|---|---|---|
| groovy | GroovyRouter | 脚本返回 nodeId/null | GroovyRouter.java:39-60 |
| stupid_end | StupidEnd | 无条件结束 | StupidEnd.java:24-26 |
| simple_next | SimpleNextRouter | 固定去配置的 nodeId(构建期校验节点存在,:41-47) | SimpleNextRouter.java:26-28 |
| keyword | KeywordRouter | 命中黑(1)/白(2)则结束,否则走下一节点;当前节点失败(curResult==null)也继续 | KeywordRouter.java:28-44 |
| risk_end | RiskEnd | curResult.hasRisk() 则结束,否则继续 | RiskEnd.java:22-30 |
| user_end | UserEndRouter | fromUser() 则结束 | UserEndRouter.java:22-24 |
| robot_end | RobotEndRouter | fromRobot() 则结束 | RobotEndRouter.java:22-24 |
后四种都继承 SimpleNextRouter,“结束”= 返回 null,“继续”= super.next() 去配置好的固定节点。配置里要给它们配 conf.nodeId。
4.1 groovy router:策略逻辑的主战场
GroovyRouter(core/engine/routers/GroovyRouter.java:27):
- 构建期:doInit(:76-82)把 conf 解析出 script,调
GroovyHelper.engin(script)建引擎并预热——GroovyHelper.doWarmup()(core/util/GroovyHelper.java:24-31)拿空 bindings 先 eval 一遍,触发 Groovy 编译,异常吞掉。所以第一个真实请求不吃编译冷启动。 - 运行期(next, :39-60):
| |
4.2 groovy 的两个并发/容错事实(重要)
- 全局 synchronized 串行 eval(①)。safety-api 内所有请求的 groovy 路由/归约脚本都排队执行——脚本里写复杂逻辑(循环翻 middleResults、字符串拼接)会直接拉低整机吞吐。策略脚本务必保持"读几个字段、比几个数、return"的体量。
- 异常即结束链路:
ScriptException被 catch 后 log 并return null(:56-59)——脚本报错 = 链路当场终止,且ctx.curResult保持上一跳的结果原样返回给业务方。脚本报错不报警、不抛 500,线上排查靠日志关键字执行groovy脚本失败。
源码勘误:
RobotEndRouter.type()返回的是RouterType.user_end(RobotEndRouter.java:28),应为 robot_end,是复制粘贴笔误。功能不受影响(type() 仅用于声明),但按 type 反查实现时会拿错类。
五、SessionContext(ctx)实战手册
5.1 真实字段清单(SessionContext.java:21-34)
| 字段 | 类型 | 说明 |
|---|---|---|
| businessConf | BusinessConf | 业务配置,内含 userCheckConf/robotCheckConf(BusinessConf.java:19-20) |
| histories | List<History> | 会话历史(Redis 取回,multi_turn_detect 的上下文来源) |
| checkContent | String | 待检测文本,超长已截断(见 5.2) |
| curReq | List<DefenseApiRequest> | 当前请求列表;ctx.curReq[0].fromRobot() 分流必用 |
| lastUserReq | DefenseApiRequest | 预留(相关代码已注释,FunctionExecutorImpl.java:492-497) |
| curResult | RiskCheckResult | 上一跳的结果(每跳开始被清空) |
| curFunctionName | String | 上一跳的 function 名 |
| middleResults | Map<RiskCheckType, List<RiskCheckResult>> | 所有已归档中间结果 |
| executeStartTime / executeEndTime / executeCost / recvExecuteCost / endReason | 计时与收尾标记 | timeout() 的依据 |
两个方法:timeout()(:36-39)按 fromRobot 选 robotCheckConf/userCheckConf.timeoutMilliseconds(CheckConf 默认 1000ms,CheckConf.java:14)与 executeStartTime 比;appendResult()(:41-48)synchronized 往 middleResults 分桶追加。
5.2 checkContent 截断:坐标更正
提纲称截断在 SessionContext——实际在 FunctionExecutorImpl.buildContext():MAX_CHECK_LEN = 2048(:398),超长时取头 1024 + _c_ + 尾 1024(:428-431)。头尾保留、中间掐断,兼顾"开头攻击"与"结尾诱导"两类注入位置。
5.3 常用写法(照抄即可)
| |
六、parallel 并行节点:唯一的并行点
ParallelFunction(safety-api/.../service/execute/functions/ParallelFunction.java:41)——DAG 图上没有并行分支,全部并行都发生在这一个节点内部。
6.1 线程池
(:87-92)队列长度 1 + CallerRunsPolicy 是刻意的背压设计:200 个线程全忙时,第 202 个任务由提交它的业务请求线程自己同步执行——等于自动降并发,宁可慢也不堆积。
6.2 doRun:提交 → 逐级超时收取
| |
(:101-137,有删减)注意 ④:实际等待时长取的是子 function 自己的 timeoutMilliseconds,remainCost 只在"已超总预算"时把后续收取压成 1ms(⑤)。这就是 02 篇坑 4"两层超时都要改"的第①层实现位置。子项超时/异常只是结果不进 resultMap,不影响其他子项(:132-135)。
6.3 归约策略(ParallelConf.ReduceType)
| reduceType | 行为 | 代码 |
|---|---|---|
| no_reduce(默认) | 直接返回 ParallelCheckResult(含完整 resultMap),交给 router 脚本自己拆 | :143-144, :175 |
| first_risk | 取 resultMap 里第一个 hasRisk() 的结果;都没有 → noRisk | :145-151 |
| groovy_script | 跑归约脚本,bindings = ctx/conf/retMap,脚本返回 RiskCheckResult;返回 null 视为 noRisk;脚本异常抛 inner_error | :152-171 |
groovy_script 的引擎同样是构建期预热(doInit :60-66)、运行期 synchronized(engine) eval(:155)。脚本位置在 conf.extra.script(GroovyConf),不是 docs/ABOUT_DAG.md 示例里写的 conf.reduceScript——那是文档超前于代码的一例(见第九节)。
七、三层超时体系(02 篇坑 4 的完整答案)
| 层 | 配置位置 | 代码实现 | 默认/演示值 | 超时后果 |
|---|---|---|---|---|
| ① 整体 DAG | 业务 userCheckConf/robotCheckConf.timeoutMilliseconds | ctx.timeout(),主循环每跳前检查(FunctionExecutorImpl.java:297) | 1000ms | 抛 exec_timeout,整个请求失败 |
| ② 单 function HTTP | 原子能力注册 timeoutMilliseconds | OkHttp callTimeout,烙在按 URL 共享的 client 上(KeywordFunction.java:62) | keyword 1000 / bert 2000 | IOException,节点结果丢弃(ignoreError 时链路继续) |
| ③ parallel 内部 | 节点 timeoutMilliseconds(总预算)+ 各子 function 自身超时 | Future.get(子超时),超预算后 1ms 空转(ParallelFunction.java:119-137) | 演示策略 1000/2000 | 该子项结果不进 resultMap |
Mac 本地 BERT CPU 推理 ~1.7s(并行抢 CPU 3.3s),①③的演示值和②的注册值全部不够——所以 02 篇附录 A(改 DAG 节点)+ 附录 B(改原子能力注册)两个脚本要一起跑。
八、动手实验:复制演示策略改出自定义 DAG
目标:把提示词注入阈值 201 从 0.99 降到 0.5,并给机器人侧链路加一层"命中即放行白词短路"之外的节点(演示加节点套路)。沿用 02 篇附录 A 的脚本骨架(dag/active → newVersion → 改 confArray → dagUpdateYaml → upgrade)。
| |
| |
验证点:api 日志里 加载配置结束,cost=xxms(FunctionChainBuilderImpl.java:70)出现即说明 DAG 已重建;脚本改错(如 return 了不存在的 nodeId)不会报错,只会链路提前结束——用风险样本回归确认。
九、文档超前于代码清单(读 ABOUT_DAG.md 必看)
| docs/ABOUT_DAG.md 的说法 | 代码实况 |
|---|---|
SessionContext 有 executedNodes/nodeCosts/businessName/sessionId/currentNode/requestMetadata 字段(文档 :336-361) | 均不存在,真实字段见本文 5.1(SessionContext.java:22-34) |
| AbstractFunction.run() 有 preProcess/postProcess/handleException 三段式(文档 :219-246) | 不存在,真实实现只有 doRun+归档(AbstractFunction.java:41-59) |
parallel 归约脚本写 conf.reduceScript(文档 :158) | 实际读 conf.extra.script(ParallelFunction.java:61-65) |
| Function 表列了 BertFunction/FastTextFunction/KnowledgeSearchFunction(文档 :83-87) | 真实类名 SingleLabelPredFunction/KbSearchFunction(FunctionRegistry.java:20-30) |
| Router 表只列 5 种(文档 :254-260) | 实际 7 种(Router.java:23-31,漏 simple_next/risk_end) |
| docs/strategy_example.yml 引用 fast20250710、bert_20250819、bert_prompt_injection_20250623 | 实际部署的是 example/safety-skills/safety-knowledge/strategy_example.yml:无 fasttext 节点,用 bert_20250916 + bert_prompt_injection_20250828,节点超时 1000/2000 |
十、源码坐标速查
| 主题 | 位置 |
|---|---|
| 主执行循环 while 逐跳 | safety/safety-api/src/main/java/com/jd/security/llmsec/service/execute/FunctionExecutorImpl.java:289 |
| checkContent 头尾截断(2048) | 同上 :398, :428-431 |
| 结果缓存(user+单条, 15min) | 同上 :247, :275-287 |
| DAG 构建(两遍扫描) | .../service/execute/FunctionChainBuilderImpl.java:73 |
| AB 按 dagVersion 实时构建 | 同上 :109-125 |
| ref 深合并 doMerge | .../utils/FunctionUtil.java:39(mergeProperties :58) |
| Function 注册表(9 种) | .../service/execute/FunctionRegistry.java:20 |
| parallel 线程池/收取/归约 | .../service/execute/functions/ParallelFunction.java:87 / :119 / :142 |
| async 包装 | .../service/execute/functions/AsyncFunction.java:36 |
| HTTP client 按 URL 共享 | .../service/execute/functions/FunctionResourcePool.java:15 |
| 模板方法 run+归档 | safety/safety-basic/.../core/engine/AbstractFunction.java:41 |
| Node(含节点级 reduceScript) | safety/safety-basic/.../core/engine/Node.java:24(run :43) |
| Router 注册表(7 种) | safety/safety-basic/.../core/engine/Router.java:23 |
| groovy router 串行 eval | .../core/engine/routers/GroovyRouter.java:42 |
| groovy 引擎预热 | .../core/util/GroovyHelper.java:18-31 |
| ctx 全字段 | .../core/session/SessionContext.java:21(timeout() :36) |
| 整体超时默认 1000ms | .../core/session/CheckConf.java:14 |
| 配置 60s 轮询+变更判断 | .../service/DynamicConfigService.java:65, :113-151 |
| 演示策略(实际部署版) | example/safety-skills/safety-knowledge/strategy_example.yml |
