状态:✅ 已完成(源码核实日期 2026-09-14) 前置阅读:02-Mac本地部署实操(容器清单)、03-技术架构与业务模块分析(两时期划分、DAG 引擎) 素材来源:safety/safety-skills/safety-knowledge 全量源码 + example/ 初始化脚本 + safety-admin 知识管理代码,所有类名/行号均经 Read 核实 读者定位:想搞懂"红线知识从录入到命中/代答"完整链路的读者;要在生产上接知识库能力的读者
〇、先建立全局认识
safety-knowledge 是整个框架里功能最重的 skill 服务(Python/Tornado,容器内端口 8004),一个进程组扛起三种能力:
| 能力 | 路由 | 对应 Java Function | 命中 riskCode | 干什么 |
|---|---|---|---|---|
| 红线检索 | /knowledge/search | KbSearchFunction(type=kb_search) | 110100「红线知识参考」 | 用户输入与红线问题语义相似 → 判风险 |
| RAG 代答 | /knowledge/answer | RagAnswerFunction(type=rag_answer) | 110101「红线代答」 | 命中红线 → 给出标准答案(安全话术) |
| 多轮检测 | /dialog | MultiTurnDetectFunction(type=multi_turn_detect) | label=1「恶意对话」 | 整段对话发 LLM 判恶意(防渐进式越狱) |
| 知识写入 | /knowledge/upsert /knowledge/delete | —(不给审核链路用) | — | 向量库管理接口 |
它对外有三个依赖:Vearch(向量库)、本地 bge-small-zh-v1.5 ONNX 模型(embedding,不依赖外部服务)、OpenAI 兼容 LLM(代答生成 + 多轮判定)。注意它不依赖 Redis/MySQL——知识内容不是它自己从数据库加载的,而是靠外部脚本灌进来(详见第二章,这是理解整个数据流的关键)。
| |
一、服务全景与启动链路
1.1 启动:非 debug 模式 fork 20 进程
入口 safety/safety-skills/safety-knowledge/main.py:
逐行批注:
- main.py:48-50:配置文件由环境变量
KG_CONFIG_FILE指定,没设置直接抛异常。容器里 compose 注入的是KG_CONFIG_FILE: /work/conf/config_example.py(docker-compose.yaml:184),挂载自example/safety-skills/safety-knowledge/conf/。 - main.py:53-57:
load_config()用importlib把配置文件当模块加载(app/util/config_helper.py:8-13),再转成 pydantic 的Conf对象;随后init()把三段子配置分别注入 knowledge/answer/dialog 三个模块的全局变量(main.py:38-42)。 - main.py:61-66:先
bind_sockets(8004)再 fork——Tornado 官方推荐姿势,父进程绑定 socket、20 个子进程共享监听 fd,内核做负载均衡,绕开 GIL。 - 两个名不副实的配置(核实结论,别被误导):
Conf.process_num(app/conf.py:32)是死配置,进程数在 main.py:66 写死 20;- demo 自带的
config_example.py:6里"debug": True——所以开源版默认部署实际是单进程跑的,“20 进程"只在 debug=False 时成立。
1.2 路由表(main.py:26-33)
所有 Handler 继承 BaseHandler(app/base.py:37-63):模板方法 post() 里统一做「解析 JSON → pydantic 校验 → do_post() → 包 Response{code,message,cost,data} → 异常兜底 code=1」,并全量打请求/响应日志。Request 基类(app/base.py:16-19)强制携带 request_id/business_id/session_id 三件套。
1.3 embedding:本地 ONNX,启动即预热
app/util/embedding.py 是整个服务的地基(检索、代答校验都要用它):
| |
- 模型文件直接打进镜像:
app/util/bge-small-zh-v1_5/model.onnx(DockerfileCOPY ./app /work/app),不需要外部模型仓库,这就是 compose 里 SAFETY_MODEL_DIR 挂了它也不用的原因——knowledge 的 embedding 是自给自足的。 SessionOptions开全部图优化、intra_op_num_threads=threads;模块级单例bge_zh_small = BgeZhSmall(..., threads=4)(embedding.py:66)——import 即加载+预热,配合 20 进程 fork,每个子进程一份(COW 共享内存页)。embed_query(embedding.py:50-58):tokenizer → numpy →session.run→ 取[:, 0](CLS 向量)→ L2 归一化。512 维。归一化是后面一切阈值能当"余弦相似度"用的前提。cosine()(embedding.py:60-63):两个归一化向量直接点积 = 余弦相似度,代答一致性校验用它。
二、知识两层管理模型(本篇核心认知)
2.1 两层分别是什么
| 层 | 存储 | 写入入口 | 读出方 | 角色类比 |
|---|---|---|---|---|
| ① 持久化层 | MySQL red_line_knowledge 表 | admin POST /data/redline_knowledge/upsert | index_all.py | 档案室(权威数据、版本化、审计字段) |
| ② 检索层 | Vearch space hnsw_bge512_20250826 | knowledge POST /knowledge/upsert | 审核链路 search/answer | 图书馆索引(为语义检索优化) |
为什么要两层? 直接用 MySQL 存向量不行吗?——不行,分工不同:
- MySQL 层管"治理”:
red_line_knowledge(safety-admin/docs/tables.sql:103-131)带version/status/editor/auditor/release审计字段和唯一键(uniq_id, business_scene, version),支持上线/下线状态机,这是"谁改了什么红线"的权威账本。 - Vearch 层管"检索":HNSW 近似最近邻索引 + 标量过滤,毫秒级语义召回,MySQL 做不到。
- 代价:两层之间没有自动同步。搜遍代码,没有任何监听 MySQL 变更自动更新 Vearch 的机制——同步靠
index_all.py手动/定时重跑(全量重灌,幂等靠固定_id)。这是开源版的一个明显工程缺口:admin 里 offline 一条知识,Vearch 里它还在,直到下次跑 index_all + 手动调/knowledge/delete。
2.2 持久化层:admin 侧代码
RedLineKnowledgeController(safety/safety-admin/.../contoller/data/RedLineKnowledgeController.java:25)暴露 /data/redline_knowledge 下的 upsert / getById / getByWord / update / online / offline 六个接口(API 文档:safety-admin/docs/api/knowledge_api.md)。
核心逻辑在 RedLineKnowledgeManageServiceImpl.java:
注意 uniqId 就是 question 的 MD5——同一个业务场景下同样的问题文案只能有一条(撞唯一键),这是天然去重。演示数据由 compose 挂进 MySQL 初始化:docker-compose.yaml:23 把 example/safety-admin/data/red_line_knowledge.sql(5 条示例:涉政/涉赌/涉黄/辱骂)作为 initdb 脚本。
2.3 检索层:建库建 space
example/safety-skills/safety-knowledge/vearch_manage.py(safety-example 初始化容器第一步执行):
| |
- master 走
:8817建库建表,router 走:9001读写数据(vearch_manage.py:111-116);库名db0,space 名hnsw_bge512_20250826。 - 向量索引用 InnerProduct 而非 Cosine——因为 embedding 已 L2 归一化,内积 ≡ 余弦,省一半计算。
partition_num=1, replica_num=1(vearch_manage.py:50-53),注释明说生产要改 3 副本——开源默认值是玩具配置。- HNSW 参数:nlinks=32、efConstruction=40(vearch_manage.py:64 的注释里有完整 space 元数据,efSearch=64)。
2.4 同步链路:index_all.py
example/safety-skills/safety-knowledge/index_all.py(初始化容器第二步):
| |
逐行批注:
- 游标式分页(
where id >= max_id+1)而非LIMIT offset——深分页不扫前 N 行,大表友好。 - 截头去尾保留首尾各 250 字:bge 模型 max_length 有限,红线问题往往"开头定性、结尾落点"最关键,中间案例描述可弃。
_id = business_scene_uniqId_version(如演示数据里的default_0e1b0af92..._1),重跑覆盖同一条,幂等。- 坑:SQL 里没有
status='online'过滤——offline 的知识也会被灌进向量库。两层模型 + 无状态过滤,意味着"下线"语义在检索层是失效的,生产上必须自己补。
2.5 写入与检索的代码实现
写入(app/util/vector_engine.py:28-46):
检索(vector_engine.py:59-89):
| |
批注:
- min_score 在 Vearch 引擎侧生效(不是拉回再过滤),少于阈值的候选直接不进结果,
max_score=10是个不设上限的摆设。 - 过滤语义:
{"type": ["aa","bb"], "source": ["x"]}=type IN (aa,bb) AND source IN (x)——只能表达"字段内 OR、字段间 AND",没有范围/否定。 - 结果按文本分组返回
KnowledgeResponseData{ids, scores, text_list, embeddings, metadatas}(app/knowledge/base.py:42-47),metadatas 剔除_id/_score/text/vector四个保留字段。 - 死配置提醒:
KnowledgeConf.min_score(app/conf.py:24)定义了但全仓库无人使用,实际阈值全走请求参数threshold。
三、kb_search:红线检索链路
3.1 完整请求流
| |
Java 侧 KbSearchFunction.java:
- doInit(:50-91):校验 url/topK/threshold 必填;OkHttp client 按
type+url维度进FunctionResourcePool复用;新 client 注册ScheduleWarmup预热任务(api 重启后先打一发"你好"暖暖连接和模型)。 - doRun(:95-141):从
SessionContext取当前审核文本,组装KbSearchConf.Req(request_id/business_id/session_id/top_k/threshold/text_list)POST 出去。 - 双重阈值:knowledge 服务已经按
threshold在引擎侧过滤过,Java 侧filter()(:143-145)再score > threshold过滤一遍——注意一边是>=(vector_engine min_score)一边是>,边界值行为略有差异,基本无实际影响。 - 命中即风险(:131-136):
riskCode=110100 / riskMessage="红线知识参考",docs 透传。注释// todo 外部传入定义的code(:132)说明 riskCode 目前写死。
3.2 注册配置与一个不匹配的坑
example/.../skills_init.py:50-62 注册 kb_search:topK=5, threshold=0.8, timeoutMilliseconds=2000。
坑(核实结论):skills_init.py:59 给 kb_search 配的 collection 是 red_kg_prod_hnsw_bge512_20250826,而 vearch_manage.py 建的 space 叫 hnsw_bge512_20250826——名字对不上,这个 collection 在 Vearch 里不存在。同时 demo 的 strategy_example.yml 根本没把 kb_search 编进 DAG。所以开箱环境下 kb_search 是个"注册了但永远不会被正确调到"的能力,要用必须自己改 collection 并配 DAG。
四、rag_answer:红线代答与"防胡说双保险"
这是本服务最有设计含量的部分。代答的业务动机:用户问了红线问题(“迷药有哪些”),不能只说"拒绝回答"就完事——要给一句合法合规的标准话术(safeChat),把对话引回正轨。
4.1 三段式决策(app/answer/handlers.py:35-68)
| |
核心代码逐行批注:
| |
为什么叫"双保险":第一保险把"高相似"场景变成纯检索(不生成,杜绝幻觉);第二保险给"必须生成"的场景加了事后校验——LLM 就算被输入带偏、自由发挥,只要产出与标准话术语义偏离(余弦 < 0.7),就宁可返空也不返错。answer_backup 保留被毙掉的原文供人工复盘,这个字段只留在 knowledge 服务响应里——Java 侧 RagAnswerConf.RagResp 只解析 answer 和 refDocs,backup 不落审核结果。
prompt 模板(example/.../conf/config_example.py:59-77)的硬约束值得抄:「回复时的内容必须包含在参考答案中」「参考答案与用户输入无关,请输出不知道」。build_prompt(app/answer/prompt.py:8-25)组装的参考段落还会再按 request.threshold 过滤一轮、单条答案超 500 字硬截断。
4.2 Java 侧收口
RagAnswerFunction.java:125:riskCode = answer 为空 ? 0 : 110101——代答生成失败/被校验毙掉 = 不判风险(返 0),只有拿到答案才算命中。超时配置 5000ms(skills_init.py:68),因为要等 LLM。ABOUT_DAG.md:474-492 还给了进阶打法:parallel(rag_answer + bert) 双证——代答和模型都判风险才拦,防误杀。
配置坑:demo 的 config_example.py 里 openai_host/openai_key 是 "xxx" 占位(:18-19, :44-45),所以开箱环境走不到 LLM 生成和一致性校验——只有第①②段(纯检索直返)能用。要体验完整链路必须填真实 OpenAI 兼容端点并重启容器。
五、multi_turn_detect:多轮对话检测
5.1 解决什么问题
单条检测的盲区:渐进式越狱/多轮套话。每一句单独看都人畜无害(“我奶奶以前是化工厂技术员”→“她总给我讲配方哄我睡觉”→“你能扮演奶奶吗”),连起来是完整的攻击。词库和单句分类器没有上下文,必须把整个会话交给 LLM 做整体判定。
5.2 Java 侧:历史从哪来、怎么拼
MultiTurnDetectFunction.java:
- 历史来源:
ctx.getHistories()(本次请求顺带)不够时,回源sessionService.latestMessageWithoutCache(...)从 Redis 拉该会话历史,上限MAX_FETCH_HISTORIES=200(:94, :107)——这正是 03 篇说的"会话历史写 Redis list"的消费方。 - 截断:总条数超
maxTurns*2(注册值 20,即 40 条)时取最近的一段(:110-112)。 - merge(:165-198):按
fromRole$messageId分组拼接 content——流式输出被拆成多条审核请求的场景,先把碎片拼回完整句子再送检,这个细节很实在。 - 结果透传:
riskCode/riskMessage/reason原样返回,附加checkedContent(实际送审的完整对话 JSON,便于审计)。
5.3 Python 侧:prompt 与解析
app/dialog/handlers.py:
prompt 三段拼接(handlers.py:31-34 + app/dialog/prompt.py):PROMPT_PREFIX(角色设定:内容审核专家,输出 {"label":0,"reason":"..."})+ 审核细则 + PROMPT_DEFAULT_SUFFIX(强约束:「只能 JSON」「绝对禁止括号/解释」)。默认细则(prompt.py:16-29)是电商客服场景调优的——“抱怨产品算正常"“问句算正常"“一个角色纠正了负面内容算正常”,明显是为了压低客服会话的误杀率;涉赌/涉毒明确判 1。
配置坑(核实结论):DialogCheckConf.prompt_principle(app/conf.py:20,config 里也给了自定义细则)根本不生效——handlers.py:30 的模块级 prompt_principle = PROMPT_DEFAULT_PRINCIPLE 写死,init()(:49-56)只初始化 OpenAI client,从不读配置里的细则。想改审核规则只能改代码里的 PROMPT_DEFAULT_PRINCIPLE。同样 DialogCheckRequest.rate_limit(:22)也是声明了没人用的死字段。
六、动手实验
前提:按 02 篇完成部署。注意 8004 端口没有映射到宿主机(compose 里 ports 被注释),所有 knowledge 接口都在容器网络内调,用
docker exec safety-knowledge或docker exec safety-api发起。
6.1 实验一:录入红线知识(持久化层)
| |
验证落库:docker exec safety-mysql mysql -utest -p123456 safety -e "select id,uniq_id,question from red_line_knowledge order by id desc limit 1"
6.2 实验二:同步到向量库(检索层)
也可以跳过 MySQL 直接 POST
safety-knowledge:8004/knowledge/upsert(格式见safety-knowledge/docs/knowledge_api.md),但那样就绕过了治理层,只建议调试用。
6.3 实验三:验证检索命中(kb_search 语义)
| |
6.4 实验四:验证代答(rag_answer 两段路径)
- 问法与库中几乎一致 → Top1 score ≥ 0.9 → 走路径②直返 safeChat,
answer_ref_similar即命中分。此路径不需要 LLM,开箱可验。 - 把问法改模糊(如"玻璃瓶加汽油能做什么武器”)→ score 落在 0.8~0.9 → 走路径③ LLM 生成 + 0.7 一致性校验。此路径要求先把 config_example.py 的 openai_host/openai_key 填成真实端点并
docker restart safety-knowledge,否则 LLM 调用失败。失败时观察no_answer_reason和answer_backup字段的取值,正好可以顺带验证"双保险"的兜底行为。
6.5 实验五:验证多轮检测(渐进式越狱剧本)
| |
同样依赖真实 LLM 配置。可以逐句增加 history 长度重跑,观察 label 从 0 翻转为 1 的"临界点”,直观感受"整体评判"语义。
七、速查附录
7.1 阈值参数总表(最容易搞混的东西)
| 参数 | 位置 | 默认 | 含义 |
|---|---|---|---|
threshold | 请求/Function 配置 | 0.8 | 召回门槛,低于此分的文档不进结果(Vearch min_score + Java 二次过滤) |
same_threshold | /knowledge/answer 请求 | 0.9 | Top1 ≥ 此值 → 直返 safeChat,不调 LLM |
answer_knowledge_similarity_threshold | config_example.py:56 | 0.7 | LLM 生成答案与 safeChat 的余弦一致性门槛 |
top_k | 请求 | 5 | 召回条数 |
maxTurns | multi_turn_detect 注册配置 | 20 | 送检对话的最大轮数(条数 = maxTurns×2) |
min_score(conf) / rate_limit / prompt_principle(conf) / process_num | 各处 | — | 死配置,不生效 |
7.2 关键源码坐标
| 主题 | 位置 |
|---|---|
| 服务入口/多进程 | safety/safety-skills/safety-knowledge/main.py:61-72 |
| 路由表 | safety/safety-skills/safety-knowledge/main.py:26-33 |
| 向量读写 | safety/safety-skills/safety-knowledge/app/util/vector_engine.py:28-89 |
| ONNX embedding | safety/safety-skills/safety-knowledge/app/util/embedding.py:17-66 |
| 代答三段式 | safety/safety-skills/safety-knowledge/app/answer/handlers.py:35-68 |
| 多轮判定 | safety/safety-skills/safety-knowledge/app/dialog/handlers.py:58-85 |
| 知识管理 admin | safety/safety-admin/.../contoller/data/RedLineKnowledgeController.java:25 |
| uniqId=md5(question) | safety/safety-admin/.../service/data/RedLineKnowledgeManageServiceImpl.java:71 |
| 建表 SQL | safety/safety-admin/docs/tables.sql:103-131 |
| Java kb_search | safety/safety-api/.../functions/KbSearchFunction.java:95-145(riskCode 110100 @:133) |
| Java rag_answer | safety/safety-api/.../functions/RagAnswerFunction.java:95-135(riskCode 110101 @:125) |
| Java multi_turn | safety/safety-api/.../functions/MultiTurnDetectFunction.java:94-163 |
| 建库建 space | example/safety-skills/safety-knowledge/vearch_manage.py:28-57 |
| 全量灌库 | example/safety-skills/safety-knowledge/index_all.py:56-100 |
| skill 注册 | example/safety-skills/safety-knowledge/skills_init.py:50-87 |
| 接口文档 | safety/safety-skills/safety-knowledge/docs/knowledge_api.md、answer_api.md |
7.3 本篇核实出的坑/不一致清单(生产化前必读)
- 两层无自动同步:admin offline ≠ Vearch 删除;index_all.py 的 SQL 连
status='online'过滤都没有(index_all.py:97-100) - kb_search 注册的 collection 名(
red_kg_prod_hnsw_bge512_...)与实际 space(hnsw_bge512_20250826)不匹配,且 demo DAG 未接入(skills_init.py:59 vs vearch_manage.py:116) - demo 配置 LLM 是占位符:rag 路径③与 /dialog 开箱不可用(config_example.py:18-19,44-45)
- demo 配置 debug=True:默认部署实为单进程;
process_num配置无效,非 debug 硬编码 fork 20(main.py:66) - prompt_principle / rate_limit / min_score 三处死配置(dialog/handlers.py:30,22;conf.py:24)
- Java 侧
RagResp丢弃answer_backup/no_answer_reason/answer_ref_similar,排障要看 knowledge 服务自己的日志 - Vearch space 默认
replica_num=1,生产改 3(vearch_manage.py:52-53 注释自认)
