状态:✅ 已完成 前置阅读: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 流式检测的矛盾
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,官方拼写错误):
| |
逐行批注:
- 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:queue | list | 全局任务队列,元素是全局会话 ID | 不过期 | TaskService.java:15 |
{prefix}session:new:{gSessionId} | list | 该会话待处理的新消息(请求 JSON) | 300s | TaskService.java:16-17 |
{prefix}session:lock:{gSessionId} | string | 会话分布式锁,值是 lockId | 10s | TaskExecutor.java:16 |
{prefix}session:resp:{gSessionId} | list | 该会话已出未取的结果 | 300s | TaskExecutor.java:14-15 |
{prefix}session_history:{gSessionId} | list | 会话历史(供多轮检测取上文) | 1800s | SessionService.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 “结果搭便车"完整时序
| |
三个关键认知:
- 第一次请求永远拿不到结果——它提交时结果队列必是空的,它的结果要等下一次请求来取。
- 收尾请求(sync + content=’$’)是必需的。最后一条消息没有"下一班车”,必须发一个 sync 请求把尾包结果取走(详见 3.7)。
'$'只是占位符:代码里没有任何对$的特殊处理(已全仓 grep 核实),它只要能过checkParams的"content 不能为空"校验即可(DefenseServiceImpl.java:62)。 - 检测永远追在消息后面跑,业务方全程零阻塞。
三、源码走读:投递 → 分发 → 抢占 → 批量识别 → 结果回流
3.1 投递:Redis pipeline 一次写入两个队列
TaskServiceImpl.addTask()(safety/safety-api/.../service/task/TaskServiceImpl.java:74-94):
| |
批注:
- 一次 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 的常驻线程:
批注:队列在 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):
批注:200 个执行线程意味着单实例最多并行处理 200 个会话。队列只有 10、拒绝策略是 CallerRunsPolicy——线程池爆了就让分发线程自己下场干活,既提供了背压(分发线程被占住就暂停 rpop),又不会抛异常丢任务。这是背压设计里很典型的" caller 兜底"手法。
3.4 抢占与滑动窗口批量识别:本篇最核心的一段
TaskExecutorImpl.execute()(:78-188)核心逻辑:
| |
逐行批注:
- 抢锁只试一次(
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):
| |
如实指出两个生产注意点(代码如此,不是猜测):
- setnx 和 expire 是两条命令,非原子。若在两条命令之间进程崩溃,锁会永远存在——但 3.4 的持有者异常路径靠
finally里的 unlock 兜底,且消息队列 300s 过期,实际泄漏窗口可控。更稳的写法是SET NX EX一条命令(缓存写入处 :253-254 恰恰就是这么写的)。 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,然后:
| |
注意 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):
| |
为什么抢锁等于"等异步结果落盘":异步执行线程处理会话期间持有同一把 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):
注意这个 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),基础 TTLRESP_CACHE_SECONDS = 15 * 60(:247)。
这就呼应了 02 篇的实操经验"重试时改动一两个字避开结果缓存"(02 篇坑 6/常见过渡态):缓存键是内容 MD5,改一个字就是另一个键。做功能验证时如果开着缓存,原样重试会秒回旧结果,无法验证"模型服务是否恢复"。
四、业务链路:配置怎么流转,请求怎么流转
4.1 配置流(与 03 篇呼应,此处落到 free_taxi 相关项)
| |
free_taxi 视角的重点:checkNum、adaptivePauseMilliseconds 都是 BusinessConf 里的配置项,admin 改完最多 60s 后 api 内存刷新,下一批异步任务生效(已在跑的线程的 batchSize 是首次取数时固化下来的,:127-131)。这也解释了 02 篇坑 5"改了配置不生效"——60s 轮询 + 内存缓存是设计使然。
4.2 请求流(free_taxi 一条消息的完整旅程)
| |
五、动手实验:模拟 3 轮会话观察便车
复现环境见 02 篇(docker compose 全套已起、test 业务已初始化)。以下脚本保存为
quickstart/free_taxi_test.py自行运行即可(不改动仓库原有文件;api_test.py里request()的签名逻辑直接复用其写法)。
| |
预期观察(与源码一一对应):
- msg1 返回空——它提交时结果队列必空(3.1/3.6)。
- msg2 捎回 msg1 的结论——便车机制(Controller :107-108 先 submit 后 fetchResult)。
- msg3 捎回 msg2 的辱骂命中——注意滞后的是整整一条消息,如果 msg2 是恶意内容,业务方在 msg2 当次是拿不到拦截结论的。
- 收尾 sync(’$’) 抢锁等异步落盘后全量取回(3.7)。若 checkNum=1 的 test 业务收尾响应里没有历史结果,别惊讶——重读 3.7 末尾的
checkNum > 1门槛。 - 想看队列实况可以另开终端:
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) |
| 结果只保留 300s | RESP_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 是空 stub | MessageQueueServiceImpl.java:30-33 | 生产要用 mq 模式需自行实现 MessageQueueService 两个方法 |
| 结果缓存默认关 | execute.cache.enable=false(application_example.properties:16) | 开启后注意:仅 user 单轮生效;验证问题时"重试改一两个字"(呼应 02 篇) |
| checkNum>1 才有 sync 收尾合并 | DefenseServiceImpl.java:157 | free_taxi 业务建议在 admin 配好多轮检测条数,否则收尾请求捞不回历史结果 |
一句话总结:free_taxi 用"Redis 全局队列 + 会话级分布式锁 + 滑动窗口批量"三件套,把检测从请求链路上整个摘下来;它的所有设计妥协(滞后一条、吞异常、简化锁)都指向同一个目标——检测可以慢、可以丢,但绝不能挡业务的路。
参考坐标(全部经 Read/Grep 亲自核实)
| 机制 | 位置 |
|---|---|
| responseMode 分派 | safety/safety-api/.../contoller/DefenseApiV2Controller.java:97-127 |
| 结果拉取接口 | 同上 :159-181 |
| process/submit/fetchResult | safety/safety-api/.../service/DefenseServiceImpl.java:132-194 |
| 投递 pipeline | safety/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 |
| 会话历史写 Redis | safety/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 空 stub | safety/safety-api/.../service/storage/MessageQueueServiceImpl.java:30-37 |
| 配置轮询/DAG重建 | DynamicConfigService.java:65、FunctionChainBuilderImpl.java:48-64 |
| 官方设计文档 | docs/ABOUT_FREE_TAXI.md(“等200ms"与代码 300ms 上限有出入,以代码为准) |
