QMD LLM 模块源码分析
模块设计意图
llm.ts 是 QMD 的 LLM 抽象层,负责与本地 GGUF 模型的交互。设计目标:
- 统一接口:定义
LLM接口,支持不同后端实现(目前只有 node-llama-cpp) - 资源管理:自动管理模型加载/卸载、上下文生命周期、内存优化
- 并行优化:多上下文并行处理,充分利用 GPU/多核 CPU
- 会话管理:提供 scoped session 机制,保证操作原子性
核心类/函数详细分析
1. LLM 接口定义
文件位置: src/llm.ts:295-327
export interface LLM {
embed(text: string, options?: EmbedOptions): Promise<EmbeddingResult | null>;
generate(prompt: string, options?: GenerateOptions): Promise<GenerateResult | null>;
modelExists(model: string): Promise<ModelInfo>;
expandQuery(query: string, options?: { context?: string, includeLexical?: boolean }): Promise<Queryable[]>;
rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise<RerankResult>;
dispose(): Promise<void>;
}Queryable 类型 - 支持多种查询类型:
export type QueryType = 'lex' | 'vec' | 'hyde';
export type Queryable = {
type: QueryType;
text: string;
};- lex: 词汇查询 → 路由到 FTS (BM25)
- vec: 向量查询 → 路由到向量搜索
- hyde: Hypothetical Document Embedding → 生成假设文档后向量搜索
2. LlamaCpp 类 - node-llama-cpp 实现
文件位置: src/llm.ts:361-1160
这是核心实现类,管理三个模型:
- Embed Model: embeddinggemma-300M-Q8_0 (嵌入生成)
- Generate Model: qmd-query-expansion-1.7B-q4_k_m (查询扩展)
- Rerank Model: qwen3-reranker-0.6b-q8_0 (结果重排序)
2.1 延迟加载与并发控制
export class LlamaCpp implements LLM {
private llama: Llama | null = null;
private embedModel: LlamaModel | null = null;
private embedContexts: LlamaEmbeddingContext[] = [];
private generateModel: LlamaModel | null = null;
private rerankModel: LlamaModel | null = null;
private rerankContexts: Awaited<ReturnType<LlamaModel["createRankingContext"]>>[] = [];
// Promise guard 防止并发加载竞争
private embedModelLoadPromise: Promise<LlamaModel> | null = null;
private generateModelLoadPromise: Promise<LlamaModel> | null = null;
private rerankModelLoadPromise: Promise<LlamaModel> | null = null;Promise Guard 模式:
private async ensureEmbedModel(): Promise<LlamaModel> {
if (this.embedModel) return this.embedModel;
if (this.embedModelLoadPromise) return await this.embedModelLoadPromise;
this.embedModelLoadPromise = (async () => {
const llama = await this.ensureLlama();
const modelPath = await this.resolveModel(this.embedModelUri);
const model = await llama.loadModel({ modelPath });
this.embedModel = model;
return model;
})();
try {
return await this.embedModelLoadPromise;
} finally {
this.embedModelLoadPromise = null; // 清除 promise,但保留加载好的模型
}
}为什么需要 Promise Guard?
- 防止多个并发请求同时触发模型加载
- 避免重复分配 VRAM(模型加载是昂贵的)
- 第一个请求加载模型,后续请求等待同一个 promise
2.2 GPU 自动检测
private async ensureLlama(): Promise<Llama> {
if (!this.llama) {
// 检测可用 GPU 类型
const gpuTypes = await getLlamaGpuTypes();
// 优先级: CUDA > Metal > Vulkan
const preferred = (["cuda", "metal", "vulkan"] as const).find(g => gpuTypes.includes(g));
let llama: Llama;
if (preferred) {
try {
llama = await getLlama({ gpu: preferred, logLevel: LlamaLogLevel.error });
} catch {
llama = await getLlama({ gpu: false, logLevel: LlamaLogLevel.error });
console.error(`QMD Warning: ${preferred} reported available but failed...`);
}
} else {
llama = await getLlama({ gpu: false, logLevel: LlamaLogLevel.error });
}
this.llama = llama;
}
return this.llama;
}为什么不使用 gpu: "auto"?
- node-llama-cpp 的 auto 模式在某些配置下会返回 false,即使 CUDA 可用
- 手动检测更可靠,且可以按优先级选择
2.3 并行度计算
文件位置: src/llm.ts:578-607
private async computeParallelism(perContextMB: number): Promise<number> {
const llama = await this.ensureLlama();
if (llama.gpu) {
const vram = await llama.getVramState();
const freeMB = vram.free / (1024 * 1024);
// 使用 25% 的可用 VRAM,最多 8 个上下文
const maxByVram = Math.floor((freeMB * 0.25) / perContextMB);
return Math.max(1, Math.min(8, maxByVram));
}
// CPU: 每个上下文至少 4 个线程
const cores = llama.cpuMathCores || 4;
const maxContexts = Math.floor(cores / 4);
return Math.max(1, Math.min(4, maxContexts));
}设计决策:
- GPU: 限制使用 25% VRAM,避免影响其他应用
- CPU: 每个上下文至少 4 线程,保证基本性能
- 上限: GPU 最多 8 个,CPU 最多 4 个(经验值)
2.4 批量嵌入优化
文件位置: src/llm.ts:825-881
async embedBatch(texts: string[]): Promise<(EmbeddingResult | null)[]> {
const contexts = await this.ensureEmbedContexts();
const n = contexts.length;
if (n === 1) {
// 单上下文:顺序处理
for (const text of texts) {
const embedding = await context.getEmbeddingFor(text);
// ...
}
} else {
// 多上下文:将文本分块,并行处理
const chunkSize = Math.ceil(texts.length / n);
const chunks = Array.from({ length: n }, (_, i) =>
texts.slice(i * chunkSize, (i + 1) * chunkSize)
);
const chunkResults = await Promise.all(
chunks.map(async (chunk, i) => {
const ctx = contexts[i]!;
for (const text of chunk) {
const embedding = await ctx.getEmbeddingFor(text);
// ...
}
})
);
return chunkResults.flat();
}
}性能提升:
- 单 GPU 上多上下文并行,可实现 2.7x 加速
- 每个上下文有独立的序列锁,可以真正并行计算
3. 查询扩展 (expandQuery)
文件位置: src/llm.ts:942-1023
使用 fine-tuned Qwen3 模型生成查询变体:
async expandQuery(query: string, options: { context?: string, includeLexical?: boolean } = {}): Promise<Queryable[]> {
// 使用 Grammar 约束输出格式
const grammar = await llama.createGrammar({
grammar: `
root ::= line+
line ::= type ": " content "\\n"
type ::= "lex" | "vec" | "hyde"
content ::= [^\\n]+
`
});
const prompt = `/no_think Expand this search query: ${query}`;
const result = await session.prompt(prompt, {
grammar,
maxTokens: 600,
temperature: 0.7, // Qwen3 推荐值
topK: 20,
topP: 0.8,
repeatPenalty: { lastTokens: 64, presencePenalty: 0.5 },
});
// 解析结果
const lines = result.trim().split("\n");
const queryables: Queryable[] = lines.map(line => {
const colonIdx = line.indexOf(":");
const type = line.slice(0, colonIdx).trim();
const text = line.slice(colonIdx + 1).trim();
return { type: type as QueryType, text };
});
return queryables;
}Grammar 约束:
- 强制输出格式为
type: content - type 只能是 lex/vec/hyde 之一
- 防止模型输出无效格式
为什么 temperature=0.7?
- Qwen3 官方推荐非思考模式使用 0.7
- 不要使用 greedy decoding (temp=0),会导致重复循环
4. 重排序 (rerank)
文件位置: src/llm.ts:1028-1093
使用 Qwen3-Reranker 模型对文档进行相关性排序:
async rerank(query: string, documents: RerankDocument[]): Promise<RerankResult> {
const contexts = await this.ensureRerankContexts();
const model = await this.ensureRerankModel();
// 截断超长文档
const queryTokens = model.tokenize(query).length;
const maxDocTokens = LlamaCpp.RERANK_CONTEXT_SIZE - LlamaCpp.RERANK_TEMPLATE_OVERHEAD - queryTokens;
const truncatedDocs = documents.map((doc) => {
const tokens = model.tokenize(doc.text);
if (tokens.length <= maxDocTokens) return doc;
const truncatedText = model.detokenize(tokens.slice(0, maxDocTokens));
return { ...doc, text: truncatedText };
});
// 分块并行处理
const n = contexts.length;
const chunkSize = Math.ceil(texts.length / n);
const chunks = Array.from({ length: n }, (_, i) =>
texts.slice(i * chunkSize, (i + 1) * chunkSize)
);
const allScores = await Promise.all(
chunks.map((chunk, i) => contexts[i]!.rankAll(query, chunk))
);
// 合并结果并排序
const flatScores = allScores.flat();
return ranked.map((item, i) => ({
file: docInfo.file,
score: flatScores[i],
index: docInfo.index,
}));
}关键优化:
- 上下文大小: 2048 tokens(足够 rerank 使用,远低于默认值 40960)
- Flash Attention: 减少 20% VRAM 使用(568MB vs 711MB)
- 截断策略: 超长文档截断到合适长度,避免超出上下文
5. 会话管理 (Session Management)
文件位置: src/llm.ts:1170-1386
提供 scoped session 机制,保证操作期间资源不会被释放:
class LLMSessionManager {
private _activeSessionCount = 0;
private _inFlightOperations = 0;
canUnload(): boolean {
return this._activeSessionCount === 0 && this._inFlightOperations === 0;
}
acquire(): void { this._activeSessionCount++; }
release(): void { this._activeSessionCount--; }
operationStart(): void { this._inFlightOperations++; }
operationEnd(): void { this._inFlightOperations--; }
}
class LLMSession implements ILLMSession {
private manager: LLMSessionManager;
private abortController: AbortController;
constructor(manager: LLMSessionManager, options: LLMSessionOptions = {}) {
this.manager = manager;
this.abortController = new AbortController();
// 设置最大持续时间
const maxDuration = options.maxDuration ?? 10 * 60 * 1000; // 10分钟
if (maxDuration > 0) {
this.maxDurationTimer = setTimeout(() => {
this.abortController.abort(new Error(`Session exceeded max duration`));
}, maxDuration);
}
this.manager.acquire(); // 获取会话租约
}
release(): void {
this.manager.release(); // 释放会话租约
}
private async withOperation<T>(fn: () => Promise<T>): Promise<T> {
this.manager.operationStart();
try {
if (this.abortController.signal.aborted) {
throw new SessionReleasedError();
}
return await fn();
} finally {
this.manager.operationEnd();
}
}
}使用示例:
await withLLMSession(async (session) => {
const expanded = await session.expandQuery(query);
const embeddings = await session.embedBatch(texts);
const reranked = await session.rerank(query, docs);
return reranked;
}, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });为什么需要会话管理?
- 防止资源释放: 空闲超时器不会在有活跃会话时释放资源
- 操作原子性: 一个操作序列要么全部完成,要么全部取消
- 超时控制: 防止长时间运行的操作占用资源
6. 空闲资源管理
文件位置: src/llm.ts:401-484
private touchActivity(): void {
// 清除现有定时器
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
// 设置新的空闲超时
if (this.inactivityTimeoutMs > 0 && this.hasLoadedContexts()) {
this.inactivityTimer = setTimeout(() => {
// 检查是否可以卸载
if (typeof canUnloadLLM === 'function' && !canUnloadLLM()) {
this.touchActivity(); // 重新调度
return;
}
this.unloadIdleResources();
}, this.inactivityTimeoutMs);
}
}
async unloadIdleResources(): Promise<void> {
// 释放上下文(保留模型)
for (const ctx of this.embedContexts) await ctx.dispose();
this.embedContexts = [];
for (const ctx of this.rerankContexts) await ctx.dispose();
this.rerankContexts = [];
// 可选:也释放模型(默认不启用)
if (this.disposeModelsOnInactivity) {
// ...
}
}设计决策:
- 默认超时: 5 分钟(覆盖典型搜索会话)
- 只释放上下文: 上下文是 per-session 的重对象,模型保持加载
- 会话感知: 有活跃会话时不释放,避免中断操作
数据流和控制流
模型加载流程
请求嵌入/生成/重排序
│
▼
检查模型是否已加载
│
├─→ 已加载 ──→ 直接使用
│
└─→ 未加载 ──→ 检查是否有加载中的 Promise
│
├─→ 有 ──→ 等待现有 Promise
└─→ 无 ──→ 创建新的加载 Promise
│
▼
检测 GPU 类型
│
▼
加载模型到 VRAM/内存
│
▼
创建上下文池
│
▼
清除 Promise,保留模型
批量嵌入流程
批量嵌入请求
│
▼
获取/创建嵌入上下文池
│
▼
计算并行度 (基于 VRAM/CPU 核心)
│
▼
将文本分块
│
▼
并行处理 (Promise.all)
├─→ 上下文 1: 文本块 1
├─→ 上下文 2: 文本块 2
└─→ 上下文 N: 文本块 N
│
▼
合并结果
关键代码片段
1. 模型 URI 解析
// src/llm.ts:208-216
function parseHfUri(model: string): HfRef | null {
if (!model.startsWith("hf:")) return null;
const without = model.slice(3);
const parts = without.split("/");
if (parts.length < 3) return null;
const repo = parts.slice(0, 2).join("/");
const file = parts.slice(2).join("/");
return { repo, file };
}
// 格式: hf:org/repo/file.gguf
// 示例: hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf2. 重排序上下文大小优化
// src/llm.ts:726-729
// Qwen3 reranker 模板开销约 200 tokens
// 文本块最大 800 tokens
// 总计约 1100 tokens
// 使用 2048 作为安全边距(相比默认值 40960 减少 17 倍)
private static readonly RERANK_CONTEXT_SIZE = 2048;3. 嵌入格式化
// src/llm.ts:30-40
// 查询使用 search result 任务前缀
export function formatQueryForEmbedding(query: string): string {
return `task: search result | query: ${query}`;
}
// 文档使用 title + text 格式
export function formatDocForEmbedding(text: string, title?: string): string {
return `title: ${title || "none"} | text: ${text}`;
}最佳实践借鉴
- Promise Guard 模式: 防止并发加载竞争,避免重复资源分配
- 分层资源管理: 模型(重)→ 上下文(中)→ 序列(轻),按需释放
- 动态并行度: 根据硬件资源自动调整并发度
- Grammar 约束: 使用形式文法约束 LLM 输出,提高可靠性
- 空闲超时: 自动释放不活跃资源,平衡性能和内存
- 会话管理: 引用计数确保资源不会在操作中途被释放