Featured image of post 模型安全检测 05:Free-Taxi 异步检测模式源码走读

模型安全检测 05:Free-Taxi 异步检测模式源码走读

流式输出场景的工程化解法:Redis 队列、会话级分布式锁、滑动窗口批量识别与结果便车回流。

状态:✅ 已完成 前置阅读:03-技术架构与业务模块分析(DAG 引擎与模块全景);02-Mac 本地部署实操(环境可复现本篇实验) 素材来源:safety/safety-api 源码逐行核实 + docs/ABOUT_FREE_TAXI.md 读者定位:想搞懂"流式输出场景下检测结果怎么异步返回"的读者;准备在生产接入 free_taxi 的读者


〇、一句话认识 free_taxi

同步检测像"过安检才放行",free_taxi 像"先上车、后补票":请求来了不等检测结果,立即返回;检测在后台异步跑,结果攒在 Redis 队列里,搭下一次请求的"便车"捎回去。

它解决的是一个尖锐矛盾:大模型流式输出(SSE)场景下,业务方每收到一小段生成内容就调一次检测接口。如果用 sync 模式,每次调用都要等 DAG 跑完(关键词几十毫秒、BERT 几秒),首 token 延迟直接爆炸。free_taxi 把"检测"和"返回"解耦——代价是结果滞后一条消息,本篇把这个代价和它的适用边界讲透。


一、问题场景与四种 responseMode

1.1 流式检测的矛盾

1
2
3
4
同步模式下的流式检测:
业务方 ──分片1──► safety-api ──跑DAG(2s)──► 返回结果 ──► 业务方才敢输出分片1
       ──分片2──► safety-api ──跑DAG(2s)──► ...
首 token 延迟 = DAG 耗时,用户盯着空白屏幕等 2 秒,不可接受。

docs/ABOUT_FREE_TAXI.md 把核心问题归纳为两条:识别耗时叠加 RTT 造成性能瓶颈;检测完成前恶意内容可能已经暴露。free_taxi 的方案是:请求侧零等待,检测侧异步追,结果侧搭便车。

1.2 四种 responseMode 对比

枚举定义在 safety/safety-basic/src/main/java/com/jd/security/llmsec/core/session/ResponseMode.java:7-27:

模式语义(枚举注释原文)检测时机结果怎么拿适用
sync同步返回请求线程内同步跑 DAG本次响应直接带回非流式、短文本、强实时
free_taxi通过同一会话的后续请求顺带返回结果后台异步下一次请求捎回 / 收尾请求取全量流式 SSE、多轮对话
http通过防御系统的 api 获取结果后台异步主动调 /defenseResult/v2/{accessKey} 拉取不方便在同会话捎结果的场景
mq消息队列方式返回后台异步空 stub,需自行实现京东内部 JDQ,开源版未实现

mq 是空实现,必须点名:safety/safety-api/src/main/java/com/jd/security/llmsec/service/storage/MessageQueueServiceImpl.java:30-33——appendResponseData() 方法体为空,上面挂着 // todo 增加输出到mq。开源版本里 mq/http 模式在任务执行侧都会写 Redis 结果队列(见 3.6),但"推到消息队列"这一步是空的,生产要用 mq 模式得自己实现 MessageQueueService 接口。

1.3 入口分派:一个 switch 定生死

DefenseApiV2Controller.defenseV2() 在过完参数校验、限流、签名鉴权、预处理之后,按 responseMode 分派(safety/safety-api/.../contoller/DefenseApiV2Controller.java:97-127,注意包名是 contoller,官方拼写错误):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
switch (mode) {
    case sync:                                    // :98  同步:直接跑DAG拿结果
        responseData = defenseService.process(request);
        ...
        break;
    case free_taxi:                               // :106 先投递,再捞历史结果
        defenseService.submit(request);
        responseData = defenseService.fetchResult(request.getAccessKey(),
                request.getMessageInfo().getSessionId(), DefenseResultFetchRequest.Type.all);
        if (CollectionUtils.isNotEmpty(responseData) && request.getMessageInfo().getSliceId() != null) {
            responseData = responseData.stream().filter(x ->         // :109 分片场景按messageId过滤
                            Objects.equals(request.getMessageInfo().getMessageId(), x.getRequests().get(0).getMessageId()))
                    .collect(Collectors.toList());
        }
        break;
    case mq:                                      // :119 mq/http 只投递,本次返回空
    case http:
        defenseService.submit(request);
        responseData = Lists.newArrayList();
        break;

逐行批注:

  • free_taxi 的顺序是"先 submit 后 fetchResult"(:107-108):先把自己的消息投进异步队列,再把之前积攒的结果全捞出来。这意味着本次请求自己的检测结果绝不可能出现在本次响应里——滞后一条是机制性的,不是 bug。
  • sliceId 过滤(:109-114):流式分片场景(同一条 message 切成多个 slice 上报)下,只返回与本次 messageId 匹配的结果,避免把上一条消息的结论张冠李戴。
  • mq/http 本次响应恒为空列表(:121-122),结果要么走 /defenseResult/v2/{accessKey} 接口拉(controller :159-181),要么(理论上的)走 MQ 推。
  • checkParams(DefenseServiceImpl.java:70-72)规定:非 sync 模式 sessionId 必填——没有会话就没有"便车"可搭。

二、free_taxi 全景:队列地图与完整时序

2.1 Redis 键地图(先认路再走源码)

所有键由 safety/safety-api/.../utils/ContentHelper.java 统一拼装,前缀是 GlobalConf.CACHE_PREFIX:

Redis 键类型内容过期定义位置
{prefix}task:queuelist全局任务队列,元素是全局会话 ID不过期TaskService.java:15
{prefix}session:new:{gSessionId}list该会话待处理的新消息(请求 JSON)300sTaskService.java:16-17
{prefix}session:lock:{gSessionId}string会话分布式锁,值是 lockId10sTaskExecutor.java:16
{prefix}session:resp:{gSessionId}list该会话已出未取的结果300sTaskExecutor.java:14-15
{prefix}session_history:{gSessionId}list会话历史(供多轮检测取上文)1800sSessionService.java:17
{prefix}{business}:{md5(content)}string识别结果缓存15min+抖动ContentHelper.java:27-29

其中 gSessionId = accessKey + ":" + sessionId(ContentHelper.globalSessionKey() :55-57),把业务方 sessionId 包一层业务前缀,避免不同业务撞号(TaskServiceImpl.java:75-76 的注释明说"避免相同业务sessionId冲突")。

2.2 “结果搭便车"完整时序

 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
业务方              safety-api(Web线程)        Redis                 分发线程        执行线程池
  │ POST free_taxi(msg1)      │                │                       │               │
  │──────────────────────────►│ submit()       │                       │               │
  │                           │─pipeline: lpush task:queue, gSid       │               │
  │                           │           lpush session:new:gSid, msg1►│               │
  │                           │ fetchResult()  │                       │               │
  │                           │─rpopN session:resp:gSid(空)───────────►│               │
  │  立即返回, data=[]        │                │  rpop task:queue ◄────│ 每10ms轮询    │
  │◄──────────────────────────│                │─────── gSid ─────────►│ execute(gSid) │
  │                           │                │                       │──────────────►│ setnx session:lock:gSid ✔
  │                           │                │                       │               │ rpopN session:new:gSid → [msg1]
  │      ★ msg1 此刻才被检测   │                │                       │               │ expire lock 续约10s
  │                           │                │                       │               │ 跑DAG(批量) → lpush session:resp:gSid
  │                           │                │                       │               │ del lock 释放
  │ POST free_taxi(msg2)      │                │                       │               │
  │──────────────────────────►│ submit(msg2) → 入队(同上)              │               │
  │                           │ fetchResult()  │                       │               │
  │                           │─rpopN session:resp:gSid ──────────────►│               │
  │  返回 msg1 的检测结果!     │◄──── [result(msg1)] ───────────────────│               │
  │◄──────────────────────────│  ★ 便车:msg2 的请求捎回 msg1 的结果    │               │
  │ ...                       │                │  (msg2 在后台被检测)    │               │
  │ POST sync(content='$')    │                │                       │               │
  │──────────────────────────►│ process(): 抢锁等异步落盘(最多~300ms)   │               │
  │  返回全部剩余结果          │  + fetchResult 全量取回                  │               │
  │◄──────────────────────────│                │                       │               │

三个关键认知:

  1. 第一次请求永远拿不到结果——它提交时结果队列必是空的,它的结果要等下一次请求来取。
  2. 收尾请求(sync + content=’$’)是必需的。最后一条消息没有"下一班车”,必须发一个 sync 请求把尾包结果取走(详见 3.7)。'$' 只是占位符:代码里没有任何对 $ 的特殊处理(已全仓 grep 核实),它只要能过 checkParams 的"content 不能为空"校验即可(DefenseServiceImpl.java:62)。
  3. 检测永远追在消息后面跑,业务方全程零阻塞。

三、源码走读:投递 → 分发 → 抢占 → 批量识别 → 结果回流

3.1 投递:Redis pipeline 一次写入两个队列

TaskServiceImpl.addTask()(safety/safety-api/.../service/task/TaskServiceImpl.java:74-94):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
String sessionId = ContentHelper.globalSessionKey(request.getAccessKey(),  request.getMessageInfo().getSessionId()); // :76 业务前缀+会话ID
String newMsgKey = ContentHelper.newTaskKey(sessionId);                          // :77 → session:new:{gSid}
boolean success = ExceptionHelper.doWithRetry(3, () -> storageService.execute(jedis -> {  // :78 失败重试3次
    Pipeline pipelined = jedis.pipelined();
    pipelined.lpush(ContentHelper.taskQueueKey(), sessionId);                    // :80 全局任务队列 +1
    pipelined.lpush(newMsgKey, JSON.toJSONString(request));                      // :81 会话新消息队列 +1
    pipelined.expire(newMsgKey, NEW_MESG_EXPIRE_SECONDS);                        // :82 300s 兜底过期
    pipelined.sync();                                                            // :83 一个RTT全部发出
    return null;
}));

批注:

  • 一次 pipeline 写完"全局任务队列"和"会话新消息队列",保证两个队列不会一个写一个没写(严格说 pipeline 只是单 RTT 批量发命令、非事务,但这两个 lpush 之间不会被该连接的其他命令插队,实际足够)。
  • 每次请求都往全局队列 lpush 一个 sessionId,同一会话可能重复入队——没关系,下游抢到锁后会把会话消息队列一次性榨干(见 3.4),重复元素只是空转一次锁争抢。
  • submit() 本身吞掉所有异常只打日志(DefenseServiceImpl.java:132-138),投递失败不影响业务方收到成功响应——这是"检测绝不能拖垮业务"的设计哲学,代价是消息可能丢失(监控指标 add_task_error 兜底)。

3.2 分发:每个 api 实例一条"拉活"线程

TaskServiceImpl.init()(:40-65)用 @PostConstruct 起一个名为 task-local-dispatcher 的常驻线程:

1
2
3
4
5
6
7
8
while (running) {
    String sessionId = storageService.rpop(ContentHelper.taskQueueKey(), String.class); // :48 全局队列取会话
    if (StringUtils.isEmpty(sessionId)) {
        TimeUnit.MILLISECONDS.sleep(10);                                                // :50 空转sleep 10ms
        continue;
    }
    taskExecutor.execute(sessionId);                                                    // :54 扔进线程池
}

批注:队列在 Redis 里,是全局唯一的;每个 api 实例各跑一条分发线程去 rpop。多实例部署时天然竞争消费、天然负载均衡——谁 rpop 到谁处理。实例挂了也不丢任务(还在 Redis list 里),这就是 docs/ABOUT_FREE_TAXI.md 说的"容错机制:处理实例故障不影响整体服务"的实现出处。另外注意 TaskService.java:20-25 的注释说"待处理会话队列使用zset",实际实现是 list,注释过时了,以代码为准。

3.3 执行容器:200 线程池 + CallerRunsPolicy

TaskExecutorImpl.init()(:54-64):

1
2
3
4
pool = new ThreadPoolExecutor(200, 200, 5, TimeUnit.MINUTES,
        new LinkedBlockingQueue<>(10),                    // 等待队列只有10
        r -> { ... t.setName("task-executor-" + ...); },
        new ThreadPoolExecutor.CallerRunsPolicy());       // :63 满了由分发线程亲自跑

批注:200 个执行线程意味着单实例最多并行处理 200 个会话。队列只有 10、拒绝策略是 CallerRunsPolicy——线程池爆了就让分发线程自己下场干活,既提供了背压(分发线程被占住就暂停 rpop),又不会抛异常丢任务。这是背压设计里很典型的" caller 兜底"手法。

3.4 抢占与滑动窗口批量识别:本篇最核心的一段

TaskExecutorImpl.execute()(:78-188)核心逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
boolean lockSuccess = storageService.lock(ContentHelper.lockKey(sessionId), MAX_LOCK_SECONDS); // :88 抢会话锁, TTL=10s
if (!lockSuccess) {
    TimeUnit.MILLISECONDS.sleep(1 + System.currentTimeMillis() % 5);  // :103 没抢到睡1~5ms
    continue;                                                          //      TASK_WAIT_LOOP=1 → 实际直接放弃
}
try {
    while (true) {                                                     // :113 榨干会话消息队列
        requests = storageService.rpopN(ContentHelper.newTaskKey(sessionId),
                batchSize < 0 ? DEFAULT_BATCH_PROCESS_SIZE : batchSize, DefenseApiRequest.class); // :116 首批10条
        if (CollectionUtils.isEmpty(requests)) { break; }              // :122 取空=处理完了
        if (batchSize < 0) {
            checkConf = ... businessConf.getRobotCheckConf()/getUserCheckConf();
            batchSize = checkConf.getCheckNum() <= 0 ? DEFAULT_BATCH_PROCESS_SIZE : checkConf.getCheckNum(); // :130 滑动窗口=batch=checkNum
        }
        storageService.execute(jedis -> jedis.expire(ContentHelper.lockKey(sessionId), MAX_LOCK_SECONDS));   // :135 每批前续约锁
        doExecute(requests, sessionId);                                // :136 批量跑完整DAG
        TimeUnit.MILLISECONDS.sleep(checkConf.getAdaptivePauseMilliseconds() + System.currentTimeMillis() % 10); // :148 批间随机sleep
    }
} finally {
    storageService.unlock(ContentHelper.lockKey(sessionId));           // :156 释放锁
    ...
}

逐行批注:

  • 抢锁只试一次(TASK_WAIT_LOOP = 1,:73)。抢不到就睡 1~5ms 然后退出循环、任务结束。看起来粗暴,实则精妙:锁的持有者正在 while(true) 循环里榨干同一个会话的消息队列——后来者刚 lpush 进去的消息会被持有者一并处理掉,根本不需要第二个线程。这就是"抢占语义":谁抢到谁包办,抢不到的人放心走人。
  • 多实例互斥的天然性:锁是 Redis setnx(见 3.5),与实例无关。A 实例的线程拿着锁,B 实例分发到同一会话也只会空抢一次然后放弃。
  • 滑动窗口 = checkNum:第一批固定取 10 条(DEFAULT_BATCH_PROCESS_SIZE,:71),随后把批大小校准为业务配置的 checkConf.checkNum(:127-131),之后每批按 checkNum 条rpopN 凑成一组跑完整 DAG。checkNum 就是 CheckConf.java:13 的字段(默认 1),对应 admin 后台"多轮检测条数"配置。
  • 批间续约(:134-135):每批处理前把锁 expire 回 10s。注释原话"续约,避免单个线程长期占用时时间无法估计的问题"——会话持续有消息时持有者会一直干下去,靠续约防止锁到期被别人抢。
  • 批间随机 sleep(:144-149):adaptivePauseMilliseconds(默认 50ms,CheckConf.java:15)+ 0~9ms 随机抖动。注释原话"降低cpu使用率,提高整体吞吐"——主动让出 CPU 也放慢消费速度,给上游消息攒批的机会,提高每批的满载率。
  • 抢到锁后还初始化了三个 ThreadLocal(threadLocalService.sessionId/histories/lastUserReq,:90-92),供多轮检测在同一执行线程内复用会话历史缓存,finally 里逐一 remove(:157-159)。

3.5 锁实现:setnx+expire,以及它的两个小瑕疵

RedisStorageService.lock()(safety/safety-api/.../service/storage/RedisStorageService.java:102-113):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public boolean lock(String key, int expireSeconds) throws Exception {
    String value = GlobalConf.lockId();                                // 值 = IP/线程名/时间戳
    Long rawValue = (Long) this.execute(jedis -> jedis.setnx(key, value));
    boolean returnOk = Objects.equals(1L, rawValue);
    if (returnOk) {
        this.execute(jedis -> jedis.expire(key, expireSeconds));       // :108 第二步才设过期
        return true;
    } else {
        return false;
    }
}

如实指出两个生产注意点(代码如此,不是猜测):

  1. setnx 和 expire 是两条命令,非原子。若在两条命令之间进程崩溃,锁会永远存在——但 3.4 的持有者异常路径靠 finally 里的 unlock 兜底,且消息队列 300s 过期,实际泄漏窗口可控。更稳的写法是 SET NX EX 一条命令(缓存写入处 :253-254 恰恰就是这么写的)。
  2. unlock() 是无条件的 del(:116-118),不校验值是不是自己写的 lockId。极端时序下(持锁者卡顿超 10s → 锁过期 → 别人抢到 → 原持锁者 finally 里 del)可能误删别人的锁。好在续约机制 + 抢占语义(重复处理无副作用,只是多跑一次 DAG)让后果不严重。这是一个"够用就行"的锁,不是严格分布式锁。

3.6 结果回流:lpush 会话结果队列

TaskExecutorImpl.doExecute()(:197-227):批量请求先按 fromRole 分组(FunctionExecutorImpl.splitByRole() :82-100,user/robot 分开跑 DAG),每组跑完得到一个 DefenseApiResponse,然后:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
case free_taxi:
case http:
case mq:
    boolean success = ExceptionHelper.doWithRetry(3, () -> {                 // :209 重试3次
        String responseKey = ContentHelper.responseKey(sessionId);          //      → session:resp:{gSid}
        storageService.execute(jedis -> {
            Pipeline pipelined = jedis.pipelined();
            pipelined.lpush(responseKey, JSON.toJSONString(response));      // :213 结果入队
            pipelined.expire(responseKey, RESP_EXPIRE_SECONDS);             // :214 300s 过期
            return null;
        });
    });
    messageQueueService.appendResponseData(...);                            // :222 mq推送(空stub)

注意 sync 模式在异步链路里不写结果队列(:204-205 直接 break)——sync 的结果由请求线程自己带回。取数逻辑在 TaskExecutorImpl.fetchResult()(:230-257):Type.all 用 rpopN(responseKey, 50) 一次最多取 50 条,Type.latest 只 rpop 一条。rpop 即消费,取走就没了,所以"便车"只能搭一次。

3.7 sync(content=’$’) 收尾:先抢锁等异步结果落盘

收尾请求走 sync 模式,精华在 FunctionExecutorImpl.invoke(单条)(safety/safety-api/.../service/execute/FunctionExecutorImpl.java:109-161):

1
2
3
4
5
6
7
8
boolean shouldLock = StringUtils.isNotEmpty(sessionId) && messageId != null;   // :113 有会话才需要等
if (shouldLock) {
    for (int i = 0; i < 6; i++) {                                              // :119 最多试6次
        lockSuccess = storageService.lock(ContentHelper.lockKey(sessionId), 2);// :121 抢同一把会话锁, TTL=2s
        if (lockSuccess) { break; }
        TimeUnit.MILLISECONDS.sleep(50);                                       // :128 每次睡50ms
    }
}

为什么抢锁等于"等异步结果落盘":异步执行线程处理会话期间持有同一把 session:lock:{gSid}(3.4)。sync 请求抢到锁 ⇔ 异步线程已经处理完并释放 ⇔ 该会话的结果已全部 lpush 进结果队列。等锁的循环 6 次 × 50ms ≈ 最多等 300ms。这里要如实指出:docs/ABOUT_FREE_TAXI.md 说"无结果时等待200ms",代码实际是 6×50ms=300ms 上限,文档与实现略有出入,以代码为准。

拿到自己的 DAG 结果后,DefenseServiceImpl.process() 再把便车结果合并进来(DefenseServiceImpl.java:149-160):

1
2
3
4
5
6
case sync:
    ...
    if (checkConf != null && checkConf.getCheckNum() > 1 && StringUtils.isNotEmpty(messageInfo.getSessionId()) && messageId != null) {
        preResults = this.fetchResult(..., DefenseResultFetchRequest.Type.all);  // :158 全量取回攒下的异步结果
        results.addAll(preResults);
    }

注意这个 checkNum > 1 的门槛(:157)——旁边注释(:153-156)明说了设计意图:“checkNum > 1时一般都会伴随着free_taxi(也就是异步处理),这个时候需要获取历史识别内容”。也就是说 sync 收尾能取回异步结果的前提是业务配置了多轮检测(checkNum>1);checkNum=1 的纯同步业务走 sync 时不会去捞结果队列。

3.8 会话历史:多轮检测的上文来源

异步 DAG 里做多轮检测(checkNum>1)需要上文,来源是 SessionServiceImpl.addHistory()(safety/safety-api/.../service/session/SessionServiceImpl.java:314-339):每次 DAG 跑完把请求历史 rpush 进 session_history:{gSid}(list,1800s 过期,SessionService.java:17),多轮节点用 lrange 倒数取最近 N 条(:277)。细节:sync 模式的写历史走 3 线程小池异步提交(:325-326),异步模式则就地同步写(:328)——因为异步线程反正不在请求链路上,不怕慢。

3.9 识别结果缓存:MD5 内容缓存 15 分钟

FunctionExecutorImpl 里的结果缓存(:247-287):

  • 开关:execute.cache.enable(:272),官方示例配置默认 false(safety-api/docs/application_example.properties:16)。
  • 生效条件(shouldCache() :275-287):开关开 + 仅 user 角色 + userCheckConf.checkNum == 1(多轮检测不缓存,因为结果依赖上文,同文不同命)。
  • 键:{prefix}{businessName}:{MD5(content)}(ContentHelper.java:27-29),值写入用 SET NX EX 带 0~300s 随机抖动(:253-254),基础 TTL RESP_CACHE_SECONDS = 15 * 60(:247)。

这就呼应了 02 篇的实操经验"重试时改动一两个字避开结果缓存"(02 篇坑 6/常见过渡态):缓存键是内容 MD5,改一个字就是另一个键。做功能验证时如果开着缓存,原样重试会秒回旧结果,无法验证"模型服务是否恢复"。


四、业务链路:配置怎么流转,请求怎么流转

4.1 配置流(与 03 篇呼应,此处落到 free_taxi 相关项)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
运维/admin页面 ──► safety-admin ──写──► MySQL(版本化状态机)
                                        │ ① safety-api 不直连MySQL
                                        ▼ ② 每60s HTTP轮询 /config/defense/api/conf
                  DynamicConfigService.loadConf()  @Scheduled(fixedRate=60000)  (DynamicConfigService.java:65)
                                        │ 全量对比 lastChangeTimestamp,变了才更新
                                        ▼ ③ 内存缓存 name2BusinessConf / nodeConfMap (volatile 整体替换)
                  FunctionChainBuilderImpl 注册为配置变更监听器 (FunctionChainBuilderImpl.java:48-50)
                                        ▼ ④ 重建每个业务的 DAG 根节点 rootMap (FunctionChainBuilderImpl.java:64)
                  异步链路消费:TaskExecutorImpl.execute() 首批消息触发 configService.businessConf() (:128)
                              读到 checkConf.checkNum → 决定滑动窗口批大小

free_taxi 视角的重点:checkNum、adaptivePauseMilliseconds 都是 BusinessConf 里的配置项,admin 改完最多 60s 后 api 内存刷新,下一批异步任务生效(已在跑的线程的 batchSize 是首次取数时固化下来的,:127-131)。这也解释了 02 篇坑 5"改了配置不生效"——60s 轮询 + 内存缓存是设计使然。

4.2 请求流(free_taxi 一条消息的完整旅程)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
业务方 ──► POST /llmsec/api/defense/v2/{accessKey}
  ① checkParams     非sync必须有sessionId            (DefenseServiceImpl.java:60-77)
  ② rateLimited     业务级限流                        (:82-89)
  ③ verify          HMAC-SHA1 签名(test开头key跳过,Controller :74)
  ④ preProcess      流式业务解析OpenAI响应/截断超长    (:107-129)
  ⑤ submit          pipeline写 task:queue + session:new  (TaskServiceImpl.java:74-94)
  ⑥ fetchResult     rpopN session:resp 捞历史结果     (TaskExecutorImpl.java:230-257)
  ◄── 立即响应(携带历史结果,无本次结果)
后台:dispatcher rpop task:queue ─► 线程池 execute ─► 抢锁/rpopN批量/DAG(skill: keywords→bert→...)
  ─► lpush session:resp + rpush session_history ─► 等下一班"便车"

五、动手实验:模拟 3 轮会话观察便车

复现环境见 02 篇(docker compose 全套已起、test 业务已初始化)。以下脚本保存为 quickstart/free_taxi_test.py 自行运行即可(不改动仓库原有文件;api_test.py 里 request() 的签名逻辑直接复用其写法)。

 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
# coding=utf-8
"""free_taxi 3轮会话实验:观察结果滞后一条 + sync('$') 收尾"""
import json, time, random, string, requests

URL = 'http://127.0.0.1:8007/llmsec/api/defense/v2/test'   # test 开头 accessKey 免签名(Controller:74)

def call(content, msg_id, mode='free_taxi', session='ft-session-001'):
    body = {
        "businessType": "toC", "content": content, "contentType": "text",
        "requestId": ''.join(random.choices(string.ascii_letters + string.digits, k=20)),
        "accessKey": "test", "accessTarget": "defenseV2",
        "timestamp": int(time.time() * 1000), "plainText": content,
        "messageInfo": {"fromId": "user123456", "fromRole": "user",
                        "sessionId": session, "messageId": msg_id},
        "responseMode": mode,
    }
    r = json.loads(requests.post(URL, json=body).text)
    data = r.get('data') or []
    risks = [(d.get('riskCode'), d.get('riskMessage')) for d in data]
    print(f'msg{msg_id} [{mode}] content={content!r:30} -> 本次返回: {risks or "空"}')
    return r

if __name__ == '__main__':
    call("你好", 1)                     # 预期:返回空(第一次请求无便车可搭)
    time.sleep(3)                       # 等后台异步检测完(本地BERT在CPU上约3.4s,见02篇)
    call("你是王八蛋", 2)               # 预期:捎回 msg1 的结果(正常)
    time.sleep(3)
    call("今天天气怎么样", 3)            # 预期:捎回 msg2 的结果(riskCode=1006 辱骂)
    time.sleep(3)
    call("$", 4, mode='sync')           # 收尾:预期返回 msg3 结果 + 本次自身结果

预期观察(与源码一一对应):

  1. msg1 返回空——它提交时结果队列必空(3.1/3.6)。
  2. msg2 捎回 msg1 的结论——便车机制(Controller :107-108 先 submit 后 fetchResult)。
  3. msg3 捎回 msg2 的辱骂命中——注意滞后的是整整一条消息,如果 msg2 是恶意内容,业务方在 msg2 当次是拿不到拦截结论的。
  4. 收尾 sync(’$’) 抢锁等异步落盘后全量取回(3.7)。若 checkNum=1 的 test 业务收尾响应里没有历史结果,别惊讶——重读 3.7 末尾的 checkNum > 1 门槛。
  5. 想看队列实况可以另开终端:docker exec -it safety-redis redis-cli 后 LRANGE task:queue 0 -1、KEYS *session:resp*。

六、生产注意点(代价与适用边界)

事项机制事实(出处)建议
结果滞后一条先 submit 后 fetchResult(Controller :107-108)只用于"事后补拦截"可接受的场景(撤回消息、标记会话、惩罚账号);不适用于必须当次拦截的强实时场景,那种用 sync
尾包丢失风险最后一条消息没有下一班车必须发 sync 收尾请求;或轮询 /defenseResult/v2/{accessKey}(Controller :159-181)
结果只保留 300sRESP_EXPIRE_SECONDS=300(TaskExecutor.java:15)便车间隔超过 5 分钟结果就没了;长间隔会话用 latest/all 主动拉
消息丢失不阻塞业务submit 吞异常仅监控(DefenseServiceImpl.java:132-138)盯 add_task_error 监控指标;强保障需求自行加固
锁是"够用就行"setnx+expire 非原子、unlock 无值校验(RedisStorageService.java:102-118)水平扩容没问题(抢占语义保证幂等);不要把这个锁模式照抄到资金类场景
mq 是空 stubMessageQueueServiceImpl.java:30-33生产要用 mq 模式需自行实现 MessageQueueService 两个方法
结果缓存默认关execute.cache.enable=false(application_example.properties:16)开启后注意:仅 user 单轮生效;验证问题时"重试改一两个字"(呼应 02 篇)
checkNum>1 才有 sync 收尾合并DefenseServiceImpl.java:157free_taxi 业务建议在 admin 配好多轮检测条数,否则收尾请求捞不回历史结果

一句话总结:free_taxi 用"Redis 全局队列 + 会话级分布式锁 + 滑动窗口批量"三件套,把检测从请求链路上整个摘下来;它的所有设计妥协(滞后一条、吞异常、简化锁)都指向同一个目标——检测可以慢、可以丢,但绝不能挡业务的路。


参考坐标(全部经 Read/Grep 亲自核实)

机制位置
responseMode 分派safety/safety-api/.../contoller/DefenseApiV2Controller.java:97-127
结果拉取接口同上 :159-181
process/submit/fetchResultsafety/safety-api/.../service/DefenseServiceImpl.java:132-194
投递 pipelinesafety/safety-api/.../service/task/TaskServiceImpl.java:74-94
分发线程同上 :40-65
线程池/抢锁/批量/续约/回流safety/safety-api/.../service/task/TaskExecutorImpl.java:54-257
分布式锁safety/safety-api/.../service/storage/RedisStorageService.java:102-118
会话历史写 Redissafety/safety-api/.../service/session/SessionServiceImpl.java:314-356
sync 收尾等锁safety/safety-api/.../service/execute/FunctionExecutorImpl.java:109-161
结果缓存同上 :247-287 + utils/ContentHelper.java:27-29
ResponseMode 枚举safety/safety-basic/.../core/session/ResponseMode.java:7-27
mq 空 stubsafety/safety-api/.../service/storage/MessageQueueServiceImpl.java:30-37
配置轮询/DAG重建DynamicConfigService.java:65、FunctionChainBuilderImpl.java:48-64
官方设计文档docs/ABOUT_FREE_TAXI.md(“等200ms"与代码 300ms 上限有出入,以代码为准)
使用 Hugo 构建
主题 Stack 由 Jimmy 设计