万字长文|LLM Wiki Agent 与知识图谱是怎么做出来的
本文来源: 徐小夕(公众号:徐小夕)
原文链接: https://mp.weixin.qq.com/s/sX8W1GR4lqVTfZMpYFeL8A
发布时间: 2026-09-10 08:20

作者: 徐小夕 发布时间: 2026-09-10 08:20
往期精彩:
攻克 Web 文档难题:JitWord 如何实现业界顶尖的 Word 高精度解析与渲染
对比实测:为什么 JitWord 是目前最适配商用的 Web Word 编辑器?
上一篇我把 LLM Wiki 的范式思想讲清楚了。这一篇不聊概念,我花了3天时间,直接把AI小册里正在跑的这套 Agent 方案拆开摊在桌上:


它们就是 Alex Agent 智能学习助手和知识图谱。
体验地址:https://aibook.mvtable.com/agent
从内容摄入、中文检索、权限分级,到图谱建边、前端力导布局、模型降级,每一层怎么想、怎么写、实测多少毫秒,我都会在这篇文章中详细和大家分享。
文章略长,1.4万字,建议先收藏,再看。
先说为什么要做这件事
AI小册现在有 34 个模块、161 个精品章节,内容体量已经过了「靠目录能翻完」的临界点:

读者的真实困境是这样的:想找「上下文工程和提示词工程有什么区别」,得先猜它在哪一章;想问「独立开发者怎么变现」,搜索框只能匹配字面词,换个说法就什么都搜不到。
更麻烦的是跨模块的问题。AI小册的知识是有关联的——MCP 网关那一章用到的鉴权思路,和前面 Agent 记忆机制那一章是连着的,但目录树是层级结构,天然表达不了这种横向关联。
所以我做了两件事:
Alex Agent 负责「你问,我带着原文出处答」;
知识图谱负责「你不问,也能看见知识之间怎么连的」。
同一份底层数据,两个视图:

而整套实现里最反常识的一点是:我一个重型依赖都没引。
没有向量数据库,没有 Elasticsearch,没有 Neo4j,没有 d3-force,没有 langchain。中文分词是我自己设计的 60 行核心代码,图谱建边是自己写的 TF-IDF,前端力导布局是自己设计的 Canvas 2D。
具体的实现模块如下:
1. Alex Agent 智能问答助手

基于 SQLite FTS5 + 自实现中文 bigram 分词,把 161 章内容编译成 1,432 个带原文地址的知识块。提问后平均 4.0ms 完成检索,回答自带可点击的原文出处。
2. 知识图谱可视化

用 TF-IDF 余弦相似度算出 639 条跨模块语义关联边,前端 1,385 行单文件手写力导向布局与 Canvas 渲染,零第三方图形库。支持模块/章节两级视图、知识块按需展开、节点深链跳转。
基于上面的技术方案,我做出了完整高性能的知识图谱方案,如下图所示:

下面按数据流的顺序,一层一层拆。
01|指导原则:LLM Wiki 的「摄入时编译、请求时只读」
上一篇讲过,传统 RAG 是「解释执行」——每次提问都现场检索、现场拼接、现场让模型理解一遍。LLM Wiki 反过来,主张像编译代码一样编译知识。
这句话落到工程上,就是一条很硬的边界:凡是能在内容变更时算好的,绝不留到用户提问时再算。
我把这条边界画成了整个系统的骨架。内容文件变更时跑一次编译,产出三样东西常驻在 SQLite 里:切好块的知识正文、建好倒排的全文索引、算好权重的图谱关联边。
用户提问时,链路里没有任何一步需要 LLM 去「理解知识库结构」,全部是本地只读查询。
我画了一张架构流程图,供大家参考:

图 1 编译期与查询期的责任划分:查询链路里没有任何一步需要模型去理解知识库结构
这个划分带来三个很实在的好处。
第一,成本可控。查询期不烧 Token 去理解结构,Token 只花在最后的回答生成上。没配 API Key 的读者也能用——降级模式直接把检索结果聚合成带出处的答案。
第二,结果稳定。同一个问题今天问和三个月后问,召回的知识块完全一致,因为召回是确定性的 SQL 查询,不含任何模型采样。这一点传统 RAG 做不到。
第三,知识真的沉淀下来了。图谱的 639 条关联边是编译期算好存库的,它是一份可以反复查、可以可视化、可以随内容增长而增长的资产,不是每次提问临时拼出来的碎片。
一句话总结这一节:我把「理解知识」这件贵的事挪到了编译期,让查询期只剩下便宜的只读检索。后面所有技术选型,都是在为这条边界服务。
02|整体架构:五层分离,两个数据库物理隔离
先看全景。整个系统 8 个源文件、约 4,600 行,按职责单向依赖,没有任何环形引用。

图 2 五层架构与文件职责。行数为实测值,合计约 4,600 行
这张图里最值得单独拎出来讲的,是一个看起来平平无奇的决定:知识库和业务库是两个物理隔离的 SQLite 文件。
AI小册的用户、订单、激活码、佣金全在 store.db 里,那是真金白银的数据。而 Agent 的知识索引、图谱、会话记录全在独立的 agent.db(7.3 MB,WAL 模式)。
为什么必须分开?因为知识库是可以被随时全量销毁重建的派生数据——它的唯一真相来源是内容文件。重建索引是一个 448ms 的破坏性操作(清空表再灌),如果和业务数据同库,一次误操作或者一个事务死锁就可能波及付费用户的订单记录。物理隔离之后,最坏情况也只是知识库需要重建一次,用户数据一根汗毛都碰不到。

这里得说句实在话:「零依赖」不是目的,是结果。每一处我都是先算了成本才决定自己写的。
AI小册的部署环境是一台跑着 5 个应用的服务器,多引一个需要 node-gyp 编译的原生模块,就多一处部署失败的可能;多起一个 Elasticsearch 容器,就多一份常驻内存。
而我的内容体量是 1,432 个知识块——这个量级下,上面那些重型方案带来的收益,覆盖不了它们的运维成本。
但这条路有代价,我会在第 9 节会把天花板如实摊开讲。
03|知识摄入:把「渲染用的 JS」编译成知识库
这一层的第一个难点,很多人会忽略:AI小册的内容根本不是 Markdown 文件。
它是 25 个 JS 文件,每个文件往 window.CHAPTERS_* 这个全局变量上挂一个数组,官网加载时按顺序执行这些脚本、再由 app.js 聚合成目录树渲染出来。也就是说,内容的真相存在于「脚本执行之后的运行时状态」里,而不是文件文本里。
所以正则去扒源码是行不通的——那样只能拿到字符串字面量,拿不到拼接、条件注入和跨文件聚合的结果。

我的做法是在 Node 里开一个 vm 沙箱,把这些脚本原样执行一遍,然后读它挂到 window 上的结果。等于在服务端复刻了一次浏览器的加载过程,内容怎么渲染给读者,就怎么进知识库,两边天然一致。
沙箱里还得补上浏览器才有、Node 没有的那些全局对象(window、document 等),否则脚本一执行就抛 ReferenceError。这是个不起眼的坑,踩过才知道。摄入流程:从内容文件到可检索的知识块(实测全量 448 ms)

图 3 摄入流程。mtime 判定让服务重启不必每次都重建,ensureGraph() 兜住「索引已就绪但图谱表为空」的升级场景
切块策略也值得一说。我没有按固定字数硬切,而是按 Markdown 标题层级切——遇到 ## 或 ### 就断开,单块超过 1800 字符再往下切。这样每个知识块都是一个语义完整的单元,不会出现「半句话被切在两块里」的情况。
实测下来 1,432 个块平均 429 字符,最长 1,879、最短 35。每个块都带三样东西:原文地址(/docs/#moduleId/sectionId,点进去直达官网对应章节)、标题路径(模块 > 章节 > 小节,用于生成引用胶囊)、权限标记(plusOnly,模块级或章节级)。
「回答必须带原文地址」这个产品要求,其实是在摄入期就落进数据结构的——不是回答时再想办法拼出来的。这也是编译范式的典型体现。
为什么索引和图谱必须同事务?图谱的边是从 kb_chunks 派生出来的。如果分两个事务写,中间任何一次崩溃都会留下「索引是新的、图谱是旧的」这种脏状态,而且极难排查——因为两边单看都是自洽的。放进同一个事务,要么全成功要么全回滚,一致性由 SQLite 保证,我不用写任何补偿逻辑。
04|中文检索:用 60 行 bigram 换掉一个分词库
这是整套方案里最朴素、也最容易被低估的一环。
问题出在哪:SQLite FTS5 默认分词器 unicode61 是按空格和标点切词的。英文没问题,中文没有空格——一整句话会被当成一个 token 塞进索引。结果就是除了完全一致的整句,什么都搜不到。中文全文检索的第一道坎,永远是分词。
常规解法是接 jieba。但 nodejieba 是原生模块,需要 node-gyp 现场编译——这意味着部署机得有完整的编译工具链,每次 Node 大版本升级都可能要重编,跨平台部署直接变成一场赌博。我们的服务器上还跑着另外 4 个应用,不想为了一个分词功能引入这种不确定性。
所以我自己写了 60 行 CJK bigram。原理朴素到有点土:中文既按单字切、也按相邻两字切,英文和数字的连续串整体保留。
function tokenize(text) {
const s = String(text).toLowerCase();
const out = [];
let ascii = '';
for (let i = 0; i < s.length; i++) {
const ch = s[i], code = s.charCodeAt(i);
if (isCjk(code)) { // 中文 + 扩展A + 兼容汉字 + 假名 + 谚文
if (ascii) { out.push(ascii); ascii = ''; }
out.push(ch); // 单字也留,保证「一字查询」能命中
if (isCjk(s.charCodeAt(i + 1))) {
out.push(ch + s[i + 1]); // 相邻双字,这才是检索的主力
}
} else if (/[a-z0-9]/.test(ch)) {
ascii += ch; // ASCII 连续串整体保留:RAG / FTS5 / LLMOps
} else if (ascii) { out.push(ascii); ascii = ''; }
}
if (ascii) out.push(ascii);
return [...new Set(out)].join(' '); // 去重,控制索引体积
}节选自 db.js,isCjk() 的完整判定覆盖五个 Unicode 区段
三个设计决定,每一个都是被现实逼出来的。
单字为什么也要留?因为读者会搜「图」「券」「码」这种单字。纯 bigram 的话,单字查询永远命中不了——索引里根本没有长度为 1 的中文 token。留着单字,查询才有兜底。
ASCII 为什么整体保留?AI小册里全是 RAG、FTS5、MCP、LLMOps 这类术语。如果把英文也按 bigram 拆成 ra ag,搜 RAG 会命中一大堆 ragdoll 式的无关内容,术语检索直接废掉。
末尾为什么去重?纯粹为了压索引体积。「的」「是」这类字在一篇文章里出现几百次,不去重的话 tokens 列会爆炸。
代价我也如实和大家分享一下:分词膨胀率 1.455×。tokens 列 894,120 字符(873 KB),比 content 列的正文 614,503 字符(600 KB)还大 45%。这是 bigram 的固有开销——我用磁盘换了部署确定性。整个 agent.db 主库 7.3 MB,这个代价完全付得起。
存储:正文和索引分开,靠触发器同步
kb_chunks 存正文和元数据,kb_fts 是 FTS5 的 external content 表,两者之间用 SQLite 触发器同步。这样写入只有一处入口,不可能出现「正文改了索引没改」的不一致,也不需要应用层维护双写。
查询:AND 优先,OR 兜底,双引号包裹
function buildMatchExpr(query) {
const toks = tokenize(query).split(' ').filter(Boolean);
if (!toks.length) return null;
// 每个 token 用双引号包裹:既过滤掉 FTS5 的特殊语法
// (NEAR / NOT / * / ^),也杜绝查询串注入
const esc = toks.map(t => '"' + t.replace(/"/g, '') + '"');
return { and: esc.join(' AND '), or: esc.join(' OR '), tokens: toks };
}注意它一次返回两个表达式。检索时先试 AND(要求所有词都命中,精度最高),命中数为 0 就退到 OR(任一词命中即可),再用 BM25 排序把最相关的顶上来。
这里有个我们实测出来的、必须诚实告诉你的结论:在自然语言长问句下,AND 分支基本命中不了。「知识图谱怎么构建」拆出来的 token 有七八个,要求它们全部出现在同一个知识块里,概率极低——多加一个「怎么」,AND 命中数就归零了。AND 真正生效的场景是关键词式短查询,比如「RAG 分块」「MCP Server」。
所以实际承载检索质量的是 OR 召回 + BM25 排序这一条路。AND 更像是一个精度加成项,不是主力。这个认知很重要——如果你也打算用 FTS5 做中文问答,别把希望寄托在 AND 上,排序才是决定体验的地方。
还有一个细节:我没用 FTS5 自带的 snippet() 函数。因为它作用在 bigram 的 tokens 列上,返回的摘要会带分词噪声(形如「知 识 图 谱」这种被切碎的文本)。改成自己在原文 content 列里定位查询词、生成摘要窗口,展示效果干净得多。

05|检索链路:召回不是终点,「挑哪几块」才是
很多人以为 RAG 的核心在召回。真做过就知道,召回只是拿到一个候选池,真正决定体验的是从池子里挑哪几块、以及怎么组装。
我在召回之后加了三道关卡,每一道都对应一个真实踩过的坑。
第一道:权限分流必须在 SQL 层,不能在前端
AI小册有 225 个 Plus 专享知识块,占总量 15.7%。对非 Plus 用户,这些块不进 sources,只进 lockedHits,而且 lockedHits 里只有标题和 url。
这个区别很关键:正文在 SQL 查询里就没被选出来,不是查出来再在前端遮住。前端遮罩是可以被 DevTools 一行指令绕过的,付费内容的分级必须做在数据出口。lockedHits 上限 3 条,刚好够生成一条不让人反感的升级引导。
第二道:两轮挑选,先保多样性再保相关度
直接按 BM25 分数取 top-6 会出什么事?实测单个章节最多被切成 37 块。一个大章节里往往多块都含查询词,分数也都高——结果就是 6 个引用胶囊全部指向同一个原文地址,用户看到的是六份重复信息。
// 第一轮每章节只取最相关的 1 块 —— 最大化来源多样性,
// 避免 6 个引用胶囊全指向同一个原文地址;
// 不足 limit 时第二轮回补已选章节的次优块,每章节上限 2 块。
const MAX_SOURCES = 6;
for (const maxPerSection of [1, 2]) {
for (let i = 0; i < usable.length && sources.length < limit; i++) {
if (picked.has(i)) continue;
const { row, secKey } = usable[i];
const total = perSection.get(secKey) || 0;
if (total >= maxPerSection) continue; // 本章节配额用完
picked.add(i);
perSection.set(secKey, total + 1);
sources.push({ url: row.url, headingPath: row.heading_path,
plusOnly: row.plus_only === 1, score: row.score, ... });
}
if (sources.length >= limit) break; // 第一轮就够了,不进第二轮
}一个 [1, 2] 的数组就把两轮逻辑写完了,没有分支嵌套。第一轮保证来源尽量分散,第二轮只在不够数时才回补,且每章节最多 2 块——既不会让一个大章节吐完所有名额,也不会为了凑数硬塞不相关的内容。
第三道:上下文预算,超了就停
const MAX_CONTEXT_LEN = 9000;
for (const block of sources) {
if (ctx.length + block.length > MAX_CONTEXT_LEN) break; // 整块装不下就停
ctx.push(block);
}
// 单块过长时在内部摘录,且按句子边界切——绝不切半句
function cutBySentence(text, budget) {
const flat = text.replace(/\s+/g, ' ');
if (flat.length <= budget) return flat;
const window = flat.slice(0, budget);
// 在窗口内从后往前找最后一个句末标点,在那里断开
...为什么用字符数而不是 token 数做预算?因为算 token 就要再引一个 tokenizer 依赖,而中文场景下字符数与 token 数的比例相当稳定,用字符数做上限完全够用。能不多引一个依赖就不多引,这是贯穿全文的原则。
切句子边界这个细节看着小,影响很大。把一句代码说明从中间截断,模型很可能就着半句话编出错误结论。
所以我设计了一个更加靠谱的方案,流程如下:检索链路:只有最后一步可能调用 LLM,前面全是本地只读查询

图 4 完整检索时序。左右两个出口共享同一套溯源结构,所以降级模式也能给出带原文地址的回答
降级回答也要有结构,不能硬编
buildFallbackAnswer() 分三种情况处理,而不是统一返回一句「没找到」:
有 sources 时,把检索到的知识块聚合成结构化摘要,后面附原文列表——这条路不开模型也能用,是我们上线第一天的主力形态。
只有 lockedHits、没有 sources 时,说明答案在 Plus 专享内容里——这时输出升级引导,并把命中的章节标题列出来,让用户知道订阅后能得到什么,而不是一句干巴巴的「无权访问」。
两者都为空时,明确告诉用户「AI小册里没有这部分内容」。这一条看似简单,却是整个系统可信度的地基——宁可说不知道,也不能拿不相关的内容凑一个看似合理的答案。这一点我做得还不够好,第 09 节会接着说。
06|知识图谱:不用 Neo4j,两张表算出 639 条语义边
图谱要解决的问题是目录树解决不了的:知识之间的横向关联。MCP 网关那一章的鉴权思路,和前面 Agent 记忆机制那一章是连着的,但在目录树上它们隔了六个模块。
建图之前先建模。这里有两个反直觉的决定,它们直接决定了图谱能不能看。
建模决定一:chunk 不建节点
只把 34 个模块 + 161 个章节 = 195 个建成图节点,1,432 个知识块一个也不建。
理由很实际:同一章节内所有 chunk 的 url 完全相同。把它们建成节点,图会糊成一团黑,而且点哪个都跳同一个地方——信息密度为零,干扰却拉满。
那 chunk 去哪了?标题数组挂在所属章节节点的 meta_json 里,前端展开某个章节时再按需派生出 chunk 节点。数据不冗余存,展示时派生——这部分在 API payload 里只占 17.6%,比存 1,432 个完整节点便宜太多。
建模决定二:层级归属边不入库
模块→章节的父子关系已经由 parent_id 字段表达了,前端自己推导即可。这不只省了 161 条边,更重要的是避免了一个视觉灾难:层级边是确定的树结构,语义边是概率性的关联,两种边混在一个画布上,读者就看不出哪条是真关联了。
一个不会报错、只会让图谱悄悄变废的坑

权重公式用的是 对数 TF:(1 + Math.log(tf)) * idf(term)。为什么要取对数?因为实测单个章节最多被切成 37 块,正文极长。不压一下的话,长章节会和几乎所有章节都「相似」,变成一个假枢纽节点,把真正的关联全遮掉。
同时还要用 dfMax 过滤高频词——「AI」「模型」这种出现在几乎每个章节的词,区分度为零,留着只会制造假边。
关键优化:倒排累加,不做 N² 稠密配对
// 不做 N² 稠密配对:161² = 2.6 万次稀疏点积,绝大多数结果为 0。
// 改为按词倒排,只在「共享该词的章节对」上累加,
// 复杂度与实际重叠量成正比。
const postings = new Map(); // term -> [secIdx, ...]
for (let i = 0; i < N; i++)
for (const term of vectors[i].keys()) {
let list = postings.get(term);
if (!list) { list = []; postings.set(term, list); }
list.push(i);
}
// 用一维下三角数组存相似度对,避开 Map 的键字符串拼接开销
const pairKey = (a, b) => (a > b ? a*(a+1)/2 + b : b*(b+1)/2 + a);
for (const [term, list] of postings) {
if (list.length < 2 || list.length > dfMax) continue;
for (let x = 0; x < list.length; x++) {
const i = list[x], wi = vectors[i].get(term);
for (let y = x + 1; y < list.length; y++) {
const j = list[y];
// 同模块章节本就有层级边相连,再连语义边只会让视图糊成一团
if (sections[i].moduleId === sections[j].moduleId) continue;
// 只累加当前这个词的贡献。切勿在此处重算整对点积——
// 那样每共享一个词就会把完整相似度叠加一次,结果被成倍放大
const key = pairKey(i, j);
sim.set(key, (sim.get(key) || 0) + wi * vectors[j].get(term));
}
}
}里面有三处值得单拎出来说。
下三角数组做键。无向对的键如果用 i + ':' + j 这种字符串,每轮内循环都要拼接字符串并哈希。换成 a*(a+1)/2 + b 得到一个整数键,内存和速度都更好。反解也很便宜:a = floor((sqrt(8*key+1)-1)/2),再用 while 修正一次浮点误差即可。
只累加当前词的贡献。这是我真踩过并修掉的坑:如果在内循环里重算整对的点积,那两个章节每共享一个词就会把完整相似度叠加一次,结果被放大成倍,而且放大的倍数还取决于它们共享了多少词——排序彻底失真。
同模块直接 continue。不只为了好看:同模块章节已经有层级边相连,再加语义边会让模块内部结成一块死疙瘩,跨模块的真关联反而被挡在视野外。图谱构建:TF-IDF + 倒排累加,84 ms 产出 195 节点 / 639 边

图 5 图谱构建全流程。底部对比的是我主动放弃的写法与最终采用的写法
最后说下模块级权重为什么要做 w/(1+w) 压缩。模块级的权重是它下属所有章节对权重的累加,很可能超过 1;而前端要拿权重映射连线粗细,要求值落在 (0,1) 区间。
这里没用常见的「除以最大值」归一化,因为归一化会让每次重建的线宽整体漂移——同一对模块,这次内容多了点就可能从粗线变细线,读者会困惑。w/(1+w) 是单调递增的压缩函数,保序、天然落在 (0,1),而且每个值的映射只取决于它自己,不依赖全局分布。

07|图谱前端:1,385 行单文件,手写力导向布局
先回答必然会问的问题:为什么不用 d3-force、ECharts graph 或者 AntV G6?
三个理由。ECharts 和 G6 体积几百 KB,而我要的是一个能秒开的页面;d3-force 只解决布局,渲染、缩放、命中检测还是得自己写,引它省不了多少代码;最关键的是我的图很小——195 个节点,力导布局本身不到 100 行,引库的收益覆盖不了体积和认知成本。
渲染选 Canvas 2D 而不是 SVG DOM,也是算过的:195 节点 + 639 边意味着每帧要重绘上千个图元。SVG DOM 方案每帧要改上千个属性,触发样式重算和重排;Canvas 每帧一次 clearRect 加批量绘制,没有 DOM 开销。
O(N²) 斥力:我故意没上四叉树
力导布局的斥力计算是两两配对,标准优化是 Barnes-Hut 四叉树把复杂度降到 O(N log N)。我没做,因为算过账之后发现不需要:
各视图层级的每帧配对量(实测)

最后一行就是这个设计的边界。所以 chunk 节点是按需展开而不是默认全展开——这不是妥协,是主动的规模控制。把四叉树加上当然能撑更大的图,但在当前体量下那是白写的复杂度。
传输:自己 gzip,自己管缓存
136.6 KB 的 JSON payload 不能裸传。但站点的 nginx 没配 gzip,也没改写
Accept-Encoding,所以 compression 中间件那套协商式压缩靠不住。我直接用 Node 内置的 zlib.gzipSync(level 6)压到 34.8 KB,不依赖任何中间层配置。
// 按「权益档位 + builtAt」分槽缓存:Plus 用户与非 Plus 用户
// 看到的节点集合不同(锁定节点只给标题),payload 不能共用。
// 分槽后各自常驻,builtAt 变化时旧槽自然被覆盖,不会白白
// 重复 JSON.stringify + gzip。
const graphCache = { plus: null, std: null };
const key = slot + ':' + payload.builtAt;
const hit = graphCache[slot];
if (hit && hit.key === key) return hit;
graphCache[slot] = {
key,
etag: 'W/"' + crypto.createHash('sha1').update(key)
.digest('base64').slice(0, 22) + '"',
gz: zlib.gzipSync(Buffer.from(json, 'utf8'), { level: 6 }),
};
// 请求侧:ETag 命中直接 304,一个字节都不传
res.setHeader('ETag', cached.etag);
if (req.headers['if-none-match'] === cached.etag) return res.status(304).end();ETag 用 sha1(档位:builtAt) 而不是对整个 payload 算哈希——builtAt 已经是重建的时间戳,内容变了它必变,算 136 KB 的哈希是白花钱。弱校验(W/ 前缀)也正好匹配这个语义。
两个页面不是两个功能,是同一份知识的两个入口
图谱支持 ?focus=节点id 深链,直接定位并高亮;同时与 /agent/?q=标题 互相跳转——在图谱里看到一个章节,可以带着标题直接去问 Agent;Agent 的回答也能跳回图谱,看看这个知识点还连着什么。
这个互跳能做得这么便宜,根因在于两个功能共享同一份编译产物。图谱不是另一个系统,它就是 kb_chunks 的第二个视图。这也是第 01 节那条架构边界带来的直接红利。
08|模型接入与降级:没配 API Key 也得能用
模型解析走三级优先级:user > platform > none。
user 是会员自己在设置里配的 Key(存 agent_llm_configs,返回前端时做密钥脱敏);platform 是环境变量 DEEPSEEK_API_KEY / BASE_URL / MODEL;none 就是降级。
为什么要让会员自配?因为平台统一出 Token 成本不可控,而且不同人想用不同模型。自配之后,平台成本和用户自由度解耦了——你愿意用自己的 Key 就能用更强的模型,不愿意也照样能用降级模式拿到带出处的答案。
会员能自配 baseUrl,就必须防 SSRF
这是一个很容易被忽略的安全口。一旦允许用户填写任意 baseUrl,他就可以填 http://127.0.0.1:6379 或者云厂商元数据地址 169.254.169.254,让我的服务器替他发请求、再把响应回给他。
// 字面量先过一道
if (isPrivateAddress(host)) {
return { ok: false, error: '出于安全考虑,不允许指向内网或本机地址' };
}
// 域名必须先解析、再逐条校验解析结果——
// 只校验字面量挡不住「域名指向内网 IP」这种绕法
const records = await dns.lookup(host, { all: true, verbatim: true });
for (const r of records) {
if (isPrivateAddress(r.address)) return { ok: false, error: '...' };
}
// isPrivateAddress 覆盖:10/8、172.16/12、192.168/16、127/8、
// 169.254/16(含云厂商元数据 169.254.169.254),
// 以及 ::ffff:127.0.0.1 这类 IPv4 映射的 IPv6 地址两个细节容易漏:all: true 要拿全部解析记录(只看第一条的话,多记录域名可以用剩下那条绕过),verbatim: true 保持解析顺序不被 Node 重排。还有 IPv4 映射的 IPv6 地址,::ffff:127.0.0.1 字面上不是 127 开头,但连上去就是本机。
伪流式揭示器:4ms 出结果反而体验差
这是个反直觉的发现。降级模式下本地检索只要 4ms,整段答案「啪」一下全铺出来——用户反而觉得「这根本没在思考」,甚至怀疑是不是缓存的旧答案。但我又不想假装在调模型骗用户。
所以做了一个前端揭示器:文本本身是完整的,只是按时间轴逐段展示。
// 时长按字数自适应并夹在上下限内:
// 短答案不至于一闪而过,长答案也不让人干等
const duration = Math.min(3000, Math.max(650, total * 1.2));
const step = (ts) => {
if (!t0) t0 = ts;
// 以时间轴而非帧计数推进:掉帧时自动补偿,总时长保持稳定
const target = Math.min(total, Math.ceil(((ts - t0) / duration) * total));
if (target > pos) { renderer.push(fullText.slice(pos, target)); pos = target; }
if (pos < total) raf = requestAnimationFrame(step);
else { raf = 0; settle(); }
};
// 用户点「停止」或切换会话:立即铺满剩余内容
// (全文本身是完整的,所以这不算截断)
skip() { if (raf) cancelAnimationFrame(raf);
if (pos < total) renderer.push(fullText.slice(pos));
settle(); }里面最值得抄走的一行是 target 的算法。用时间轴而不是帧计数推进。如果写成「每帧推 N 个字符」,在卡顿的机器上揭示会直接变慢,总时长不可控;而按 (ts - t0) / duration 算目标位置,掉帧就自动跳更多字,总时长始终稳定。
流式渲染 Markdown 的两个坑
坑一:代码高亮不能在流式过程中做。逐字推送时如果每收到一个 delta 就重新高亮整段,开销会随文本长度平方增长,肉眼可见地卡。改成只在完成态调一次高亮。
坑二:表格会在流式中途以裸管道符露出。Markdown 表格必须整张齐才能渲染,只收到表头那一行时,渲染器会把它当普通文本输出,用户就看到一行 | 列一 | 列二 | 裸符号在屏幕上闪一下。得在渲染前判断「当前是不是一个孤立表头」,是就先缓着不输出。
09|工程边界:这套方案的天花板在哪
前面八节都在讲我做对了什么。这一节讲做不到什么。一篇只讲优点的技术文章没有参考价值。

边界一:纯词法匹配,没有语义层
这是 FTS5 方案的固有边界,不是 bug。「上下文工程」和「提示词工程」这种字面重叠的近义表述能命中一部分,但真正的同义改写——比如问「怎么让模型记住前面说的话」而正文写的是「记忆机制」——bigram 完全对不上,会漏。
什么时候该上向量层?我的判断标准很具体:内容涨到万级块,或者「换个说法就搜不到」成为读者的主要抱怨。在那之前加向量是负收益——多一个 embedding 服务、多一份索引、多一处两边不一致的可能,而当前 1,432 块的体量下它带来的召回改善很有限。
边界二:图谱弱边占比不低
MIN_SIM = 0.05 定得偏宽,而边权重中位数只有 0.082——说明相当一部分边是弱关联。我的取舍是「宁多连不漏连」,靠前端按权重过滤展示。
但这也意味着:如果你要复用这套算法,阈值必须按自己的内容重新标定。
0.05 是对
AI小册这 161 个章节调出来的,换一批语料它很可能不是最优值。
边界三:多轮对话没有查询改写
现在第二轮问「它怎么部署」,检索层不知道「它」指代什么,只能拿着这两个字去分词,召回必然失准。
这是我明确列在下一步要补的:用上一轮的召回结果做一次轻量改写,把指代补全成完整问句。不需要额外的模型调用,所以不会破坏「查询期只读」这条边界。
边界四:还没有「查不到」的闸门
目前只要 OR 召回有结果就会给答案,但 OR 是「任一词命中即可」——问一个完全无关的问题(比如「今天天气怎么样」)也能因为一个「怎」字而拉回一堆无关内容。
该做的是加一道相关性阈值:分数低于阈值时明确说「没找到」。一个会说「我不知道」的助手,比一个永远能扯出点什么的助手可信得多。降级模式尤其需要这道闸门,因为它没有模型这一层再筛一次。

写在最后
回头看这一整套东西,如果只能留一句话,应该是:把贵的挪到编译期,把查询期留给便宜的只读操作。
这一条原则决定了后面每一个选择:

最终的结果是:约 4,600 行代码、一个 7.3 MB 的 SQLite 文件、零重型依赖,撑起了 161 个章节的智能问答与可视化导航。
但说真的,这些数字本身不算惊人。真正让我满意的是另一件事:这套系统的每一部分都能被一个人读懂。
没有需要专人运维的中间件,没有需要反复调参的向量模型,出了问题打开一个 .js 文件就能从头看到尾。对一个还在每周迭代内容的AI小册来说,这比任何单项性能指标都重要——因为内容再长,系统必须跟得上,而跟得上的前提是看得懂。
这也正是 LLM Wiki 那个范式真正打动我的地方:它不只是一套技术方案,更是一种把知识当资产沉淀下来的思路。内容每更新一次,索引和图谱就重新编译一次,知识就沉淀一层。跑得越久,积累越厚。
谢谢一路陪着我把这套东西从想法做到上线的每一位读者。
大家在后台留言问的那些「这个能不能搜」「那两个章节是不是有关」,直接变成了这次的知识图谱。
想自己上手试一下?
AI小册官网 https://aibook.mvtable.com
如果大家对AI知识库感兴趣,也欢迎加入我们一起把AI知识库打磨的更好:

同时大家也可以关注下方公众号获取更多AI协同办公解决方案:
先暂时聊这么多,后续会持续分享AI创业开源笔记,欢迎留言交流 ~
本文转载自微信公众号「徐小夕」,仅供学习交流使用。
觉得内容不错?我要