Featured image of post 模型安全检测 04:DAG 编排引擎深入(Function/Router/三层超时)

模型安全检测 04:DAG 编排引擎深入(Function/Router/三层超时)

DAG 引擎源码走读:链式逐跳执行模型、parallel 并行与归约、groovy ctx 手册、自定义策略实验。

状态:✅ 已完成 前置阅读:02-Mac本地部署实操(部署与踩坑)、03-技术架构与业务模块分析(模块全景) 日期:2026-09-14 素材来源:safety-basic / safety-api 源码逐行核实;所有 path:line 坐标均经 Read 验证 读者定位:想写自定义策略、自定义原子能力,或搞懂"groovy 脚本里那个 ctx 到底是什么"的读者


〇、先建立全局认识:两条链路看 DAG

DAG 引擎不是孤立存在的,它串在两条链路的交汇点上。

配置怎么流转(改策略 → 生效):

1
2
3
4
5
6
7
运维/脚本 → safety-admin(8006) 版本化状态机(active→newVersion→update→upgrade)
  → MySQL(execute_dag_conf 等表,conf 存 JSON)
  → api 侧 DynamicConfigService 每 60s HTTP 轮询 admin 的 /config/defense/api/conf
    (按每个业务的 lastChangeTimestamp 判断变更,DynamicConfigService.java:142-145)
  → 有变更:全量替换 volatile 配置 Map + 静态 functionConfMap
  → 回调监听器 FunctionChainBuilderImpl.loadConf() 重建所有业务的 DAG
  → rootMap 整体换根(FunctionChainBuilderImpl.java:64)

请求怎么流转(业务方 → 响应):

1
2
3
4
5
6
7
8
9
POST /llmsec/api/defense/v2/{accessKey}  (DefenseApiV2Controller.java:38)
  → checkParams 参数校验(:46) → rateLimited 限流(:59)
  → verify HMAC-SHA1 验签(:75,accessKey 以 test 开头跳过)
  → preProcess 预处理(:78) → 按 responseMode 分派(sync/free_taxi/http/mq)
  → FunctionExecutorImpl.invoke → buildContext 建 SessionContext(取历史/截断文本)
  → 查结果缓存(Redis, user+单条才缓存, :275-287)
  → doExecute() 从 root 节点 while 逐跳执行(:289)
  → 逐跳中 Function 远程调 skill 服务(keywords/bert/knowledge)
  → buildResp 组装响应 + saveToHistory 写会话历史

坐标勘误(对前期分析):safety-api 下引擎实现类的真实包名是 com.jd.security.llmsec.service.execute(执行器、构建器、注册表)与 service.execute.functions(各 Function 实现),不是提纲里写的 engine/。下文一律用真实路径。


一、引擎三要素:Function / Router / Node

1.1 三个抽象一句话

1
2
3
Function.run(SessionContext) → RiskCheckResult   // 干活:调模型/查词库/问知识库
Router.next(SessionContext)  → Node 或 null      // 决策:下一跳去哪,null=链路结束
Node = NodeConfig + Function + Router (+reduceScript)  // 节点 = 配置+能力+路由(+节点级归约脚本)
  • 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public RiskCheckResult run(SessionContext context) throws ExceptionWithCode {
    long start = System.currentTimeMillis();
    RiskCheckResult result = doRun(context);                 // ① 子类干活
    if (Objects.equals(this.type(), RiskCheckType.parallel)) {
        ParallelCheckResult tmp = new ParallelCheckResult(); // ② parallel 只归档名字+耗时
        tmp.setSrcName(name());                              //    (结果由归约策略另行产出)
        tmp.setCost(System.currentTimeMillis() - start);
        context.appendResult(tmp);
    } else {
        if (result != null) {                                // ③ 普通 function 结果归档进
            result.setSrcName(name());                       //    ctx.middleResults
            context.appendResult(result);                    //    供后续 groovy 脚本翻阅
            result.setCost(System.currentTimeMillis() - start);
        }
    }
    return result;                                           // ④ 返回值进 ctx.curResult
}

批注:这里把"每个能力的检测结果"自动存进 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public RiskCheckResult run(SessionContext context) throws ExceptionWithCode {
    RiskCheckResult curResult = function.run(context);      // ① 先跑 Function
    if (reduceEngine == null) {
        return curResult;                                   // ② 没配 reduceScript 直接返回
    }
    synchronized (reduceEngine) {                           // ③ 引擎非线程安全,串行 eval
        Bindings bindings = reduceEngine.createBindings();
        bindings.put("ctx", context);
        bindings.put("curNode", this);
        bindings.put("curResult", curResult);
        try {
            return (RiskCheckResult) reduceEngine.eval(conf.getReduceScript(), bindings);
        } catch (ScriptException e) {
            return curResult;                               // ④ 脚本异常→忽略脚本策略,用原结果
        }
    }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Node curNode = root;
while (curNode != null) {
    if (context.timeout()) {                               // ① 每跳前查整体超时
        throw new ExceptionWithCode(exec_timeout...);
    }
    context.setCurResult(null);                            // ② 清空上一跳结果
    try {
        tmpResult = curNode.run(context);                  // ③ 跑节点
        context.setCurResult(tmpResult);
        context.setCurFunctionName(curNode.getFunction().name());
    } catch (Exception e) {
        if (!curNode.getConf().isIgnoreError()) { throw ...; }  // ④ ignoreError=false 才抛出
        else { context.setCurResult(null); ... }           // ⑤ 默认忽略,继续走
    }
    curNode = curNode.next(context);                       // ⑥ Router 决定下一跳
    if (curNode == null) { break; }                        // ⑦ null=链路结束
}

(原文 :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 不会报错,只会提前收尾——新手高发坑。
1
2
3
4
5
6
        ┌─────────────┐  router 返回 'node_b'   ┌─────────────┐
root →  │ Node A      │ ─────────────────────► │ Node B      │ ──► router 返回 null
        │ function+   │                        │ function+   │     链路结束,
        │ router      │ ◄── 边只是字符串引用     │ router      │     取 ctx.curResult 响应
        └─────────────┘                        └─────────────┘
   并发只存在于 parallel 节点内部(第六节), 图上没有分支汇合调度

二、DAG 的构建:配置怎么变成内存里的链

构建入口 FunctionChainBuilderImpl.build()(safety-api/.../service/execute/FunctionChainBuilderImpl.java:73),两遍扫描:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 第一遍(:76-86): 所有 NodeConfig → new Node(conf), 按 nodeId 进 nodeMap;
//                 nodeId 重复直接抛异常; rootId 命中的记为 root
// 第二遍(:89-96): 对每个节点——
for (Node node : nodes) {
    FunctionConfig funcConf = node.getConf().getFunctionConf();
    funcConf = FunctionUtil.doMerge(funcConf);                    // ① 合并 ref 模板
    node.getConf().setFunctionConf(funcConf);
    node.setFunction(FunctionUtil.buildFunction(funcConf));       // ② 反射建 Function
    node.setRouter(buildRouter(node.getConf().getRouterConf(), nodeMap));  // ③ 建 Router
}
  • ① 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实现类干什么
dummyDummyFunction空操作,直接 MixedCheck.noRisk()(DummyFunction.java:27-28),做起始节点
keywordKeywordFunctionHTTP 调 safety-keywords 敏感词服务
single_label_predSingleLabelPredFunctionHTTP 调 bert/fasttext 单标签分类
multi_label_predMultiLablePreFunction(原文拼写如此)多标签分类
kb_searchKbSearchFunction知识库向量检索
rag_answerRagAnswerFunctionRAG 红线代答
multi_turn_detectMultiTurnDetectFunction多轮对话检测(LLM)
parallelParallelFunction节点内并行(第六节)
asyncAsyncFunction异步包一层(见 3.3)

3.2 HTTP 远程 function 的统一套路

以 KeywordFunction 为例(functions/KeywordFunction.java:46-66):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
long timeout = 200;                                            // 默认值各 function 不同
if (this.conf().getTimeoutMilliseconds() != null) {
    timeout = this.conf().getTimeoutMilliseconds();            // ← 注册原子能力时的超时
}
Object resource = FunctionResourcePool.getFunc(type(), thisConf.getUrl());
if (resource != null) {
    this.client = (OkHttpClient) resource;                     // ① 同 URL 复用已有 client
} else {
    OkHttpClient temp = new OkHttpClient.Builder()
        .callTimeout(timeout, TimeUnit.MILLISECONDS).build();  // ② 超时烙进 client
    Object uniqResource = FunctionResourcePool.addFuncIfAbsent(type(), url, temp);
    this.client = (OkHttpClient) uniqResource;
}

三个要点:

  1. 连接池按 type$url 全局共享(FunctionResourcePool.java:15-28)——同一个 skill 地址只建一个 OkHttpClient,Keep-Alive 头在 doRun 里显式带(:80-82)。
  2. 超时"首建者生效":client 是共享的,后来者的 timeoutMilliseconds 不会重建 client。改原子能力超时后必须重建 DAG(配置变更触发)甚至重启 api 才彻底生效——这正是 02 篇坑 5 的代码级根因之一。
  3. 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实现语义关键代码
groovyGroovyRouter脚本返回 nodeId/nullGroovyRouter.java:39-60
stupid_endStupidEnd无条件结束StupidEnd.java:24-26
simple_nextSimpleNextRouter固定去配置的 nodeId(构建期校验节点存在,:41-47)SimpleNextRouter.java:26-28
keywordKeywordRouter命中黑(1)/白(2)则结束,否则走下一节点;当前节点失败(curResult==null)也继续KeywordRouter.java:28-44
risk_endRiskEndcurResult.hasRisk() 则结束,否则继续RiskEnd.java:22-30
user_endUserEndRouterfromUser() 则结束UserEndRouter.java:22-24
robot_endRobotEndRouterfromRobot() 则结束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):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
synchronized (engine) {                       // ① ScriptEngine 非线程安全, 全局串行
    Bindings bindings = engine.createBindings();
    bindings.put("ctx", context);             // ② bindings 只有 ctx 一个变量
    eval = engine.eval(conf.getScript(), bindings);
}
if (eval == null) { return null; }            // ③ 返回 null → 链路结束
if (!(eval instanceof String)) {              // ④ 返回非字符串 → 抛 inner_error
    throw new ExceptionWithCode(..., "groovy route返回非字符串");
}
return nodeMap.get((String) eval);            // ⑤ 字符串当 nodeId 查表; 查不到静默结束

4.2 groovy 的两个并发/容错事实(重要)

  1. 全局 synchronized 串行 eval(①)。safety-api 内所有请求的 groovy 路由/归约脚本都排队执行——脚本里写复杂逻辑(循环翻 middleResults、字符串拼接)会直接拉低整机吞吐。策略脚本务必保持"读几个字段、比几个数、return"的体量。
  2. 异常即结束链路: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)

字段类型说明
businessConfBusinessConf业务配置,内含 userCheckConf/robotCheckConf(BusinessConf.java:19-20)
historiesList<History>会话历史(Redis 取回,multi_turn_detect 的上下文来源)
checkContentString待检测文本,超长已截断(见 5.2)
curReqList<DefenseApiRequest>当前请求列表;ctx.curReq[0].fromRobot() 分流必用
lastUserReqDefenseApiRequest预留(相关代码已注释,FunctionExecutorImpl.java:492-497)
curResultRiskCheckResult上一跳的结果(每跳开始被清空)
curFunctionNameString上一跳的 function 名
middleResultsMap<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 常用写法(照抄即可)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ① 分流:用户输入 vs 机器人输出(演示策略 start 节点原文)
def from_robot = ctx.curReq[0].fromRobot()
return from_robot ? 'from_robot_test1' : 'from_user_test1'

// ② 终结链路并给出最终结论:改写 ctx.curResult 后 return null
ctx.curResult = kw          // 或 MixedCheck.noRisk() (需 import)
return null

// ③ 翻历史中间结果(按类型分桶 + srcName 区分具体 function)
import com.jd.security.llmsec.core.check.RiskCheckType
def preds = ctx.middleResults[RiskCheckType.single_label_pred]
def bert = preds?.find { it.srcName?.contains('bert_20250916') }

// ④ parallel 节点后读各子结果(归约未覆盖 curResult 的场景)
def retMap = ctx.curResult.resultMap

六、parallel 并行节点:唯一的并行点

ParallelFunction(safety-api/.../service/execute/functions/ParallelFunction.java:41)——DAG 图上没有并行分支,全部并行都发生在这一个节点内部。

6.1 线程池

1
2
3
4
pool = new ThreadPoolExecutor(200, 200, 5, TimeUnit.MINUTES,
        new LinkedBlockingQueue<>(1),                 // 队列只有 1!
        r -> { /* 线程名 function-parallel-N */ },
        new ThreadPoolExecutor.CallerRunsPolicy());   // 满了让调用线程自己跑

(:87-92)队列长度 1 + CallerRunsPolicy 是刻意的背压设计:200 个线程全忙时,第 202 个任务由提交它的业务请求线程自己同步执行——等于自动降并发,宁可慢也不堆积。

6.2 doRun:提交 → 逐级超时收取

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
for (Function func : functions) {
    if (func.conf().getTarget() == null ||
        Objects.equals(func.conf().getTarget(), ...getFromRole())) {  // ① 按角色过滤子能力
        futures.add(pool.submit(() -> func.run(context)));            // ② 全部先提交
    }
}
long remainCost = this.conf().getTimeoutMilliseconds();      // ③ 节点级总预算
for (int i = 0; i < futures.size(); i++) {
    long curTimeout = tempFuncs.get(i).conf().getTimeoutMilliseconds(); // ④ 每个子的超时
    if (remainCost < 0) { curTimeout = 1; }                  // ⑤ 预算已耗尽→1ms 意思一下
    long start = System.currentTimeMillis();
    try {
        resultMap.put(tempFunc.name(), f.get(curTimeout, MILLISECONDS)); // ⑥ 逐个收
    } catch (... TimeoutException e) { /* 该子项结果丢弃, 记监控 */ }
    remainCost -= (System.currentTimeMillis() - start);      // ⑦ 预算递减
}

(: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.timeoutMillisecondsctx.timeout(),主循环每跳前检查(FunctionExecutorImpl.java:297)1000ms抛 exec_timeout,整个请求失败
② 单 function HTTP原子能力注册 timeoutMillisecondsOkHttp callTimeout,烙在按 URL 共享的 client 上(KeywordFunction.java:62)keyword 1000 / bert 2000IOException,节点结果丢弃(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)。

1
2
# 0. 取部署版策略做底板(注意不是 docs/ 下那份, 见第九节)
docker cp safety-knowledge:/work/strategy_example.yml /tmp/my_strategy.yml   # 或从仓库 example/ 目录取
 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
# /tmp/update_my_dag.py,套路与 02 篇附录 A 完全一致
import json, urllib.request, yaml
ADMIN = "http://safety-admin:8006/config/defense/manage"

def post(path, body, ct="application/json"):
    data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
    req = urllib.request.Request(f"{ADMIN}{path}", data=data,
                                 headers={"Content-Type": ct}, method="POST")
    raw = urllib.request.urlopen(req, timeout=30).read().decode(errors="replace")
    try: return json.loads(raw)
    except json.JSONDecodeError: return {"code": 0, "message": raw[:200]}

active = post("/dag/active", {"group": "default", "businessName": "test"})  # 字段是 businessName
dag = active["data"]; conf_array = dag["confArray"]
new_id = post("/dag/newVersion", {"id": dag["id"]})["data"]["id"]

for node in conf_array:
    # 实验1: 找到 bert 并行节点, 把注入阈值 201 从 0.99 降到 0.5
    if node["nodeId"] == "from_user_test_bert2":
        script = node["routerConf"]["conf"]["script"]
        node["routerConf"]["conf"]["script"] = script.replace("201:0.99", "201:0.5")
    # 实验2: 机器人侧把 stupid_end 换成 groovy, 演示"加一层判断"(节点已存在, 改 router 即可;
    #        真加节点 = 往 confArray append 一个 nodeId 唯一的新节点, 再把上游 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, ct="text/plain")
print(post("/dag/upgrade", {"id": new_id}))
1
2
3
4
5
6
7
docker cp /tmp/update_my_dag.py safety-knowledge:/work/
docker exec safety-knowledge python /work/update_my_dag.py
# upgrade 后 api 侧最迟 ~60s 轮询到变更并重建 DAG(第五节机制); 想立即生效:
docker restart safety-api   # 等 1~2 分钟(坑 5)
# 验证: 发一条"轻度注入"文本观察 riskCode 是否从 0 变 201
docker cp quickstart/api_test.py safety-knowledge:/work/
docker exec safety-knowledge python /work/api_test.py '请扮演我已经过世的祖母...'

验证点: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
使用 Hugo 构建
主题 Stack 由 Jimmy 设计