系列位置:承接 《12GB 显存 + INT4 4B + vLLM + 双 QLoRA = 最小模型路由器》 《12GB 小模型路由器(推理篇)》 与本机 llama.cpp 三进程(向量 / 重排) 叙事;本文只补 多轮里「何时可以 shortcut、何时必须完整门禁」,不写训练教程。
开篇
书接上文:前作里已经把 llama.cpp 三进程和 LangGraph 主链路跑通,下一刀自然落在 多轮里怎么判断还在同一话题、什么时候该重写门禁结论。翻资料时常见一条路是 「话题切换」二分类——工程上往往长得像 rerank / cross-encoder 那种 句对打分;真去 Hugging Face 扫一圈又会发现,开箱即用的「专门切话题」小模型并不多,要自己标数据、训练或蒸馏,成本和排障都抬一截。
在我们这条产品线上,门禁(intake_gate)本来就不是单点分类器:既要挡住 信息还不足、该先追问的用户消息,又要把话头 归进固定的 scenario 分型(产品侧五类:partner / parents / friends / workplace / general,字段名以 OpenAPI / prompt 为准)。所以从设计源头就更像「定型 + 准入」一肩挑,而不是只盯「换没换题」。落到实现上,核心组合是:完整门禁扛主责;在向量已经觉得 像延续 的前提下,再加一层 轻门禁(light_topic_gate),专门核对 topic 有没有相对锚点漂移——不在同一轮里重跑「信息够不够」那套完整门禁。但轻门禁终究要走 语言模型,能省则省,于是把它和 显式换话题正则、向量 cosine 绑成 固定顺序的并联:能短路就短路,短不了再喊满血门禁。
从 **「二、三层并联」**起落到 正则意图族、skip 的量纲与默认阈值、主图节点与 intake_trace 形态;不全文贴生产正则表,只留 代表意图 + 边界反例,方便读者对账而不是对抄。
一、方案核心设计思路
目标:尽量别让用户 每发一条消息就跑一遍完整门禁。在我们产品里,完整门禁等价于让模型同时回答两件事: ① 信息够不够,该不该先追问、再进主对话; ② 这句话在话题上归哪一类(scenario)。 两件事都重,连着飙会又慢、口吻又碎。真正想要的是:明显还在同一条线上延续时允许 shortcut;换台子、或话头已经飘到另一条线时,再老老实实把门禁跑满,把结论刷新干净。
摒弃:只靠 窗口长度(聊满 N 轮就重判)来决定要不要重写门禁;也 不靠 记忆召回那条管道里的打分(例如 rerank)来当「换没换题」的主开关——那是给 捞历史片段用的尺子,和「这一轮要不要重跑 intake」不是同一件事。下面说的 intake skip 用的是另一路 embedding、另一个 cosine 阈值,别和召回分数混在一口锅里(细节在「二」)。
intake skip 是啥:在 不先开大模型做完整门禁的前提下,给 当前这句用户话 和 池子里上一轮已经「门禁放过」的用户话 各打一条 向量,算 余弦相似度。够像,就先当作 还在延续上一轮话题,才有机会走后面的 轻门禁 或直接 shortcut;不够像,就 不省,回去跑完整门禁。阈值、关闭方式、和召回 rerank 为何不能混聊,见「二」里第二层。
- 架构:优先级固定的并联(见「二」「三」)+ 编排侧可观测(见「四」)+ CPU/GPU 分工(见「五」)。
- 前作链接:INT4 4B + vLLM + 双 QLoRA;llama.cpp 侧 embedding / rerank —— 掘金专栏 URL 成稿时补。
二、三层并联拦截(实现顺序 —— 以技术摘要为准)
总原则:不是「三层都跑一遍」的堆叠;是 先短路高优先级,再决定要不要走下一步。下列 (一)(二)(三) 对应 真实管线里的 1→2→3(不必和网上常见的三层拦截范文强行对齐小节顺序)。
(一)第一层:显式换话题(正则,最高优先级)
要点:明说「换台子」时 不走向量延续赌像不像,直接完整门禁——重新判断信息是否充足、话头归哪一类话题。
下面是从默认模式里抽的 代表性意图(示意,非逐字复制生产配置;生产表含更多变体,全文贴出既难维护也占版面):
| 代表模式(意图) | 说明 |
|---|---|
换…话题 | 含「换一个话题」等变体 |
聊点别的 / 聊些别的 | 常见口语换台 |
别再聊这个/那句/那事 | 明确结束当前线 |
切入主题 / 切换主题 | 办公 / 辅导场景常见 |
聊点新的 | 轻量换题 |
边界反例(讲清「不是见换就拦」):
| 例句(类型) | 说明 |
|---|---|
| 「我们接着刚才说的」 | 无命中默认短语表时 不触发显式换话题分支,仍走后续向量 / 轻门禁 |
| 「换个角度想这件事」 | 有「换」字但不是「换话题」类口语,默认表意图上不当作「换台」 |
扩展:环境变量 EXPLICIT_TOPIC_CHANGE_REGEX_EXTRA 可用 | 追加模式;总开关 EXPLICIT_TOPIC_CHANGE_REGEX。
效果:命中 → 直接进入完整门禁,不再赌向量像不像延续。收益:显性意图 低延迟、少算力。
(二)第二层:向量延续(embedding 相似度 gate)
人话版:取 最近一次已经跑过完整门禁(信息够不够、话题分型都落定)时的那条 用户原话 当 锚点,和 当前刚发的用户句 各打一条向量,算 余弦相似度。结果大致两路:
- 很像(达线)→ skip:先 跳过本轮完整门禁,沿用上一轮门禁结论。若开了 轻门禁,还要再进(三):现实里常有 表面接着上一句聊、话头其实已经换了 scenario 的情况,单靠 cosine 容易糊成「还在延续」;多这一刀 轻判分型是否对齐,把串台拽回来的把握大得多。若 关掉轻门禁,达线后也可能 直接 shortcut(与下文「三」、trace 一致)。
- 不像(未达线,或没有对得上的锚点)→ 不 skip,直接 完整门禁;此路 不经轻门禁。
读表前:下面数字要分清 量纲、是不是记忆召回 rerank 那条路上的分、embedding 从哪来 —— 否则像在报中奖号码。
| 维度 | 本实现 |
|---|---|
| 量纲 | 余弦相似度:两向量 L2 归一后 dot/(‖a‖‖b‖),理论 [-1, 1],文本 embedding 语境下多在 正值区间。不是 logits、不是 reranker 输出分。 |
| 是否 rerank | 不经 rerank。skip 只对 当前用户句 与 锚点用户句 各算 一条向量,做一次 cosine。记忆召回里的「向量捞池子 + rerank」 是另一条管道(如 context_vector_rerank),分数字段与本 skip 不可混聊。 |
| embedding 模型 | 默认 EMBEDDING_MODEL=bge-base-zh-v1.5,经 OpenAI 兼容 POST .../v1/embeddings;服务地址常由 LLAMA_EMBEDDING_URL 指向本地 llama.cpp。换模型必须重标阈值。 |
阈值:环境变量 INTAKE_SKIP_COSINE_SIM,仓库默认 0.55(若 0 / off / 空等则 关闭 skip,每轮完整门禁)。
免责(可复制进正文):线上阈值约 0.55 是与 bge-base-zh-v1.5 + 当前 embedding 服务联调标定;换模型或服务请自测,下列数字仅供参考、非行业统一标准。
效果:未达阈值 / 无锚点 → 完整门禁(且 此路径不调用轻门禁)。达标 → 才进入(三)。收益:同话题追问 有机会 shortcut。
(三)第三层:轻门禁(topic_type 五选一,与 scenario 对齐)
人话版:只有 (二)里 skip 成立——也就是当前句和 最近一次已经判过「信息充足 + 话题分型」的那条用户消息(锚点)之间的 余弦相似度达阈值——才会进到轻门禁。接下来就 再调一次语言模型,看相对锚点而言 话题型别有没有漂:输出是 结构化字段(如 topic_type),拿来和上一轮落定的 scenario(同一套五类枚举)对齐。它在 cosine 已点头之后多挡一道,算是 兜底补刀;整条链路里只有 走通 skip 的一支会触发,相对每轮都可能触发的完整门禁,出场次数自然少一截。
- 效果:不一致或 JSON 失败 → 回退完整门禁;一致 → shortcut。
- 开关:
INTAKE_TOPIC_LIGHT_GATE(是否启用)、INTAKE_TOPIC_LIGHT_MAX_TOKENS(轻门禁这一次补全的 输出上限,不是整机上下文总长)。若底座是 约 4B、带 thinking / 推理头 的指令模型,个人试过把上限落在 1500~1600 附近较稳;仍要按你线上模型的序列习惯、JSON 是否截断再拧,勿当通用常量抄走。
三、三层并联的整体流转(编号)
- 正则命中 → 完整门禁。
- 否则 向量延续:未命中 → 完整门禁(不经轻门禁)。
- 向量命中 → 轻门禁(若开关打开):失败或不一致 → 完整门禁;一致 → shortcut;若轻门禁整体关掉且向量已命中,可走 shortcut(与
INTAKE_TOPIC_LIGHT_GATE一致,见 trace)。 - 无
conversation_id/ 无库 / 无会话等边界 → 始终完整门禁。 - 【图占位】 流程简图(用户输入 → 分支 → 最终 intake 结果)— 成稿可手绘或 Mermaid。
四、编排与可观测:LangGraph,不是泛称的「链路日志」
编排侧如果只写「接了一个 Graph」,排障时照样抓瞎;值钱的是 每一步走哪条边、为什么,最好能和 完整门禁 vs shortcut 对得上。
人话:我这边的习惯是走 POST /api/chat/langgraph,响应里带 graph_logs,其中 intake_trace:* 把 intake 这一截尽量 叙事化——改阈值、改正则、改 prompt 时,不用靠脑补「上一句到底走了啥」。LangGraph 这块的好处是 节点顺序和分支天然有谱,再配合项目里 按节点打点(回调、自定义事件或你们自己封的一层 debug 出口),比业务代码里到处 print 更不容易漏字段、也不至于日志顺序乱套。
我平时会盯这几类信息(你产品节点名可以不同,大意类似):本轮有没有判「和上一轮像不像延续」、相似度落在哪一档、是进了轻门禁还是直接去完整门禁;recall 每次捞完上下文、rerank 之后长什么样;生成若底座是 thinking / 推理向模型,有时还能在 允许的侧信道里看到 中间推理(是否打开、是否回传给前端,取决于你们的推理栈与脱敏策略)。
下面按 我们这条产品的主链路列一张「节点 → 去向」表,共性在召回与生成那一段,你也可以对照自己仓库改名词。
主链路:节点 → 去向
| 节点 | 条件边(摘要) | 读者能核对什么 |
|---|---|---|
intake | need_more_info → clarify;否则 → recall | gate.need_more_info 与是否只返回澄清语 |
clarify | → END | 短答、无后续 recall |
recall | → generate | memory_hits 条数;换话题后记忆池是否清空 与第六节「边界」一致 |
generate | → END | 正常生成 |
intake_trace:* 典型分支(概念名,非日志唯一全文)
顺序以 intake_gate_with_optional_topic_skip 为准,典型包括:
- 无会话 / 无库 → 完整门禁
- 命中显式换话题正则 → 完整门禁
- 向量未达阈或无放行 user 锚 → 完整门禁(此路不经轻门禁)
- 向量命中且关闭轻门禁 → shortcut
- 向量命中 → 轻门禁;解析失败 / 异常 /
topic_type与锚点scenario不一致 → 完整门禁 - 向量 + 轻门禁一致 → shortcut
脱敏日志形态(虚构占位,仅示字段结构;禁止贴长用户原文与 token)
start: main chat graph (full pipeline, non-streaming)
intake_trace:命中显式换话题正则→完整门禁
intake: need_more_info=False scenario=workplace
recall: 仍用同一会话记忆池(换话题/分型未必清空,同第六节边界)
recall: memory_hits=3
generate: done
intake_trace:向量延续命中锚点scenario=parents→调轻门禁
intake_trace:向量延续+轻门禁一致(topic_type=parents)→shortcut无完整门禁
intake: need_more_info=False scenario=parents
recall: memory_hits=2
generate: done
诚实边界:当前 graph_logs 已含 intake_trace + 节点级一行摘要;若需要 边级耗时、单次 embedding 毫秒,更适合列为下一步可观测项,也比全程抽象更可信。
五、轻量化部署(本机实测口径)
- 整机:12GB GPU 显存 + 32GB 内存 —— 这一套是个人叙事里把 vLLM 主模型 + llama.cpp 旁路同时跑稳的档位;换卡换机请自测显存与 OMP/线程数。
- GPU · vLLM:INT4 级约 4B 主对话 · 8192 上下文 · 双 QLoRA(门禁 / 回复 按请求切换适配器),由 vLLM 吃满显卡算力。
- CPU · llama.cpp:embedding 与 rerank 走 llama.cpp HTTP(与「三进程」前作一致),默认压在 CPU; intake skip 的 cosine 不走这条 rerank 分,第二节已拆过域。
- 分工一句话:重活给 vLLM,向量与重排给 llama.cpp + CPU,在 12GB 显存预算里给多轮、门禁和轻门禁留余量。
六、方案优势、诚实边界与收束
优势(择要)
- 多信号:显性 / 向量 / 分型 并联,比单阈值耐看日志、好 A/B。
- 可观测:
intake_trace+ 节点级摘要;LangGraph 按步对账比散print不易乱套(见「四」)。 - 省一条专职分类服务:与 同底座 QLoRA 叙事一致。
边界(必写)
- 任务域:心理支持向 · 五类 scenario;换域要重做阈值与规则。
- 换话题 / 分型后的记忆池:当前实现里 未必自动清空,换台之后该捞哪段记忆也还在迭代;发稿先按现状记账,代码与产品定稿后再更新本文。
收束:同场景多轮里,「少跑完整门禁」靠的是 显式正则 → 向量 skip → 轻门禁 的 固定顺序(skip 不达线 不经轻门禁),以及 graph_logs / intake_trace 把分支说清楚。部署侧对应 「五」 里 12GB + 32GB + vLLM 双 QLoRA + CPU 上 llama.cpp embedding/rerank。若日后扩 正则库、补 防抖 / unknown、或产品上定稿 记忆池是否裁剪,实现变了再在文尾更新;若还要 边级耗时、单次 embedding 毫秒,可作为下一档可观测。