12GB 小路由器续篇:三信号并联换话题,少跑完整门禁

22 阅读12分钟

系列位置:承接 《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)

人话版:取 最近一次已经跑过完整门禁(信息够不够、话题分型都落定)时的那条 用户原话锚点,和 当前刚发的用户句 各打一条向量,算 余弦相似度。结果大致两路:

  1. 很像(达线)→ skip:先 跳过本轮完整门禁,沿用上一轮门禁结论。若开了 轻门禁,还要再进(三):现实里常有 表面接着上一句聊、话头其实已经换了 scenario 的情况,单靠 cosine 容易糊成「还在延续」;多这一刀 轻判分型是否对齐,把串台拽回来的把握大得多。若 关掉轻门禁,达线后也可能 直接 shortcut(与下文「三」、trace 一致)。
  2. 不像(未达线,或没有对得上的锚点)→ 不 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 是否截断再拧,勿当通用常量抄走。

三、三层并联的整体流转(编号)

  1. 正则命中 → 完整门禁
  2. 否则 向量延续:未命中 → 完整门禁不经轻门禁)。
  3. 向量命中 → 轻门禁(若开关打开):失败或不一致 → 完整门禁;一致 → shortcut;若轻门禁整体关掉且向量已命中,可走 shortcut(与 INTAKE_TOPIC_LIGHT_GATE 一致,见 trace)。
  4. conversation_id / 无库 / 无会话等边界 → 始终完整门禁
  5. 【图占位】 流程简图(用户输入 → 分支 → 最终 intake 结果)— 成稿可手绘或 Mermaid

四、编排与可观测:LangGraph,不是泛称的「链路日志」

编排侧如果只写「接了一个 Graph」,排障时照样抓瞎;值钱的是 每一步走哪条边、为什么,最好能和 完整门禁 vs shortcut 对得上。

人话:我这边的习惯是走 POST /api/chat/langgraph,响应里带 graph_logs,其中 intake_trace:* 把 intake 这一截尽量 叙事化——改阈值、改正则、改 prompt 时,不用靠脑补「上一句到底走了啥」。LangGraph 这块的好处是 节点顺序和分支天然有谱,再配合项目里 按节点打点(回调、自定义事件或你们自己封的一层 debug 出口),比业务代码里到处 print 更不容易漏字段、也不至于日志顺序乱套

我平时会盯这几类信息(你产品节点名可以不同,大意类似):本轮有没有判「和上一轮像不像延续」、相似度落在哪一档、是进了轻门禁还是直接去完整门禁recall 每次捞完上下文、rerank 之后长什么样;生成若底座是 thinking / 推理向模型,有时还能在 允许的侧信道里看到 中间推理(是否打开、是否回传给前端,取决于你们的推理栈与脱敏策略)。

下面按 我们这条产品的主链路列一张「节点 → 去向」表,共性在召回与生成那一段,你也可以对照自己仓库改名词。

主链路:节点 → 去向

节点条件边(摘要)读者能核对什么
intakeneed_more_infoclarify;否则 → recallgate.need_more_info 与是否只返回澄清语
clarifyEND短答、无后续 recall
recallgeneratememory_hits 条数;换话题后记忆池是否清空 与第六节「边界」一致
generateEND正常生成

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 · vLLMINT4 级约 4B 主对话 · 8192 上下文 · 双 QLoRA(门禁 / 回复 按请求切换适配器),由 vLLM 吃满显卡算力。
  • CPU · llama.cppembeddingrerankllama.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 毫秒,可作为下一档可观测。