端侧推理与底层运行机制 (Runtime & Internals) ✅
本模块深挖大模型在前端与端侧落地的底层运行原理与极限性能机制:浏览器端 WebGPU Compute Shader 硬件加速与模型推理管线、前端私域 RAG 本地向量知识库混合检索、长会话上下文预算与多级记忆管理收敛机制、服务端 vLLM 推理引擎的 PagedAttention 分页与连续批处理吞吐优化,以及从 GPU 显存注意力机制到前端编排的 Prompt Caching 与 KV-Cache 状态优化,并延伸到 RAG 父子索引的「小块检索、大块喂给」粒度解耦、投机解码的 draft-verify 无损加速机制,以及 GraphRAG 图索引的 Local / Global Search 两种查询范式与建图成本权衡。在此之上,本模块进一步补齐已算 KV 的压缩三条路线(量化 / 驱逐 / 低秩,含 attention sink 与 H2O)、向量索引结构本身(HNSW 分层图与 DiskANN 的 SSD 方案与点密度取舍)、FlashAttention 的 IO 感知分块计算(精确注意力为何快、FA-2 的工作划分改进),以及语义缓存(embedding 相似度命中、阈值误命中与知识版本失效策略)。
浏览器端侧如何运行轻量大模型?WebGPU 与 Transformers.js / WebLLM 的工作原理是什么?
答案
核心结论:
随着现代浏览器对 WebGPU 标准的全面普及与模型量化技术的突破,在浏览器端侧(In-Browser / Client-Side)直接加载并运行轻量大语言模型与多模态模型(如 Qwen2.5-0.5B/1.5B、Llama-3.2-1B、Phi-3.5、Whisper Web 等)已成为高可用前端的前沿生产实践:
- 三大战略收益:
- 数据隐私不出域:医疗、金融、企业内网敏感文档完全在用户本机内存中推理,零数据回传风险;
- 零服务端 GPU 算力成本:将原本昂贵的服务端推理算力(A100/H100)分摊至数十万客户端的本地 GPU/NPU 芯片;
- 离线可用与零网络抖动:无惧弱网或网络中断,首字延迟(TTFT)不受网络往返(RTT)影响;
- 核心底层技术栈:
- WebGPU:取代过去仅面向图形渲染的 WebGL,直接提供底层通用 GPU 计算接口(Compute Shader),支持张量并行计算(Tensor Parallelism);
- WebLLM(MLC-LLM):基于 Apache TVM 深度学习编译器,将大模型编译为针对 WebGPU 定制的 WGSL(WebGPU Shading Language)着色器与高效 WASM 运行时;
- Transformers.js:Hugging Face 官方推出的 JavaScript 机器学习库,基于 ONNX Runtime Web,兼顾 CPU(WASM + SIMD)与 GPU(WebGPU)多后端自动降级;
- 模型轻量化与量化:通过 AWQ、GPTQ 或 4-bit 量化(q4f16_1),将 1B
3B 参数的模型显存占用压缩至 500MB1.8GB,结合 Cache API 避免重复下载。
原理解析:
1. 浏览器端侧 AI 执行引擎分层拓扑
2. 核心执行流程与技术瓶颈突破
- 流式分块下载与持久化缓存:
- 模型权重通常被切分为数十个 50MB~100MB 的分片(Shards)。引擎利用 Service Worker 与浏览器原生的 Cache API 或 IndexedDB 将权重持久化落盘;首次下载完成后,后续访问直接命中本地磁盘缓存,秒级加载;
- Web Worker 隔离机制:
- 大模型分词(Tokenization)与推理循环属于极度密集的计算任务。若在 Window 主线程执行,哪怕 10ms 的轻微阻塞也会造成页面白屏或滚动掉帧。因此,所有模型推理必须严格置于 Web Worker 内运行,主线程仅通过
postMessage接收生成出的增量 Token;
- 大模型分词(Tokenization)与推理循环属于极度密集的计算任务。若在 Window 主线程执行,哪怕 10ms 的轻微阻塞也会造成页面白屏或滚动掉帧。因此,所有模型推理必须严格置于 Web Worker 内运行,主线程仅通过
- 显存保护与 OOM 容灾降级:
- 浏览器对单个标签页的 GPU 显存分配有严格限制(如移动端 Safari 限制在 1GB~2GB 左右)。若用户显卡显存不足,WebGPU 上下文会丢失(Context Lost)。工业级架构必须具备优雅降级机制:优先申请 WebGPU 运行;若初始化失败或 OOM,自动切换为云端 API 兜底或降级为小体积 CPU WASM 嵌入模型。
规范代码实现:
以下为生产级架构中,在独立 Web Worker 中初始化端侧大模型并流式生成文本的完整 TypeScript 实现:
// worker/llm.worker.ts
// 需引入 @mlc-ai/web-llm
import { MLCEngine, InitProgressReport } from '@mlc-ai/web-llm';
class InBrowserLlmWorker {
private engine: MLCEngine | null = null;
// 选用端侧极速量化模型:Qwen2.5-0.5B-Instruct-q4f16_1-MLC (约 350MB)
private selectedModel = 'Qwen2.5-0.5B-Instruct-q4f16_1-MLC';
constructor() {
this.engine = new MLCEngine();
this.engine.setInitProgressCallback((report: InitProgressReport) => {
// 向主线程上报模型下载与编译进度
self.postMessage({
type: 'progress',
data: {
progress: report.progress,
text: report.text,
timeElapsed: report.timeElapsed,
},
});
});
}
public async init(): Promise<void> {
if (!this.engine) return;
try {
await this.engine.reload(this.selectedModel);
self.postMessage({ type: 'ready', model: this.selectedModel });
} catch (err) {
self.postMessage({ type: 'error', message: (err as Error).message });
}
}
public async generate(prompt: string): Promise<void> {
if (!this.engine) {
throw new Error('LLM Engine not initialized');
}
const messages = [
{ role: 'system', content: 'You are a helpful and concise browser-native AI assistant.' },
{ role: 'user', content: prompt },
];
// 流式响应推理
const chunks = await this.engine.chat.completions.create({
messages: messages as any,
temperature: 0.7,
stream: true,
});
for await (const chunk of chunks) {
const delta = chunk.choices[0]?.delta?.content || '';
if (delta) {
self.postMessage({ type: 'delta', text: delta });
}
}
self.postMessage({ type: 'done' });
}
}
const workerInstance = new InBrowserLlmWorker();
self.onmessage = async (e: MessageEvent) => {
const { action, payload } = e.data;
if (action === 'init') {
await workerInstance.init();
} else if (action === 'generate') {
await workerInstance.generate(payload.prompt);
}
};
面试官视角:
- 考察深度:
- 候选人是否了解前端在 AI 时代的算力边界拓展,是否清楚端侧 AI 的适用场景(离线表单自动填充、敏感代码脱敏、实时语音翻译、前端数据清洗)与不适用场景(数万字深度多步规划、长文档深度阅读);
- 能否从系统工程角度解答大体积模型权重的冷启动与网络下载瓶颈(Cache API 分块缓存、渐进式进度提示);
- 对 Web Worker 线程隔离与 WebGPU 显存限制有无实战防坑经验。
- 下探追问链:
- 追问 1:如果用户的设备不支持 WebGPU(如低版本 Chrome 或老款安卓手机),前端如何做到优雅无缝降级?
- 追问 2:端侧模型虽然零服务器成本,但在用户笔记本上可能引起发热与耗电增加,前端在架构上应如何控制推理的触发频次?
延伸阅读:
- W3C WebGPU Specification & WGSL Overview
- WebLLM (MLC-LLM) In-Browser High-Performance Acceleration
- Transformers.js v3 WebGPU Pipeline Architecture
前端如何构建端侧本地 RAG 系统?文档分块与余弦相似度检索是如何落地的?
答案
核心结论:
端侧本地 RAG(Client-Side Local RAG) 是指在前端浏览器内,不依赖任何第三方向量数据库(如 Pinecone、Milvus)或服务端接口,纯靠本地 JavaScript 引擎与 WebWorker 完成私有文档的解析、分块(Chunking)、向量嵌入(Embedding)、语义相似度检索(Vector Retrieval)与 Prompt 组装的全套闭环:
- 核心应用场景:本地 Markdown 知识库助手、离线 PDF 划词问答、用户个人浏览历史搜索等,具备极致的私密性与即时响应能力;
- 标准落地链路:
- 文档加载与分块:采用带重叠窗口(Overlap)的递归分块算法,保留段落上下文完整性,避免关键语义在切分点被截断;
- 端侧轻量嵌入:利用 Transformers.js 加载极轻量级嵌入模型(如
bge-small-zh-v1.5或all-MiniLM-L6-v2,模型仅约 20MB~40MB); - 向量计算与 Top-K 召回:使用
Float32Array类型化数组存储向量,利用**余弦相似度(Cosine Similarity)**算法并行比对,召回相关度最高的上下文片段注入大模型。
原理解析:
1. 前端端侧本地 RAG 全链路数据流
2. 核心算法原理推导
- 重叠切分窗口(Overlap)的必要性:
- 设切片大小为 S,重叠大小为 O。若不设重叠,切片 A 与切片 B 的交界处刚好存在一个核心实体关系(例如:“...张三出生于北京市朝阳区。[切分点] 毕业于清华大学...”),该实体关系就会被撕裂,导致模型无法检索到“张三的母校是清华”。引入 15%~20% 的 Overlap,能确保边界实体语义连贯;
- 余弦相似度(Cosine Similarity)计算:
- 给定查询向量 A 与文档切片向量 B,两者的余弦相似度定义为两向量点积与模长乘积之比:
Cosine(A, B) = (A · B) / (||A|| * ||B||) = sum(A_i * B_i) / (sqrt(sum(A_i^2)) * sqrt(sum(B_i^2))) - 在前端工程优化中,如果我们在存入向量库时已将向量做过L2 范数归一化(Normalize to unit length),则分母恒等于 1,余弦相似度直接简化为两个数组的点积(Dot Product),极大节省运行时 CPU 算力开销。
- 给定查询向量 A 与文档切片向量 B,两者的余弦相似度定义为两向量点积与模长乘积之比:
规范代码实现:
以下为纯 TypeScript 实现的高性能前端本地 RAG 核心(包含递归分块算法、归一化点积计算与 Top-K 召回):
// rag/local-rag-engine.ts
export interface DocumentChunk {
id: string;
text: string;
embedding?: Float32Array;
metadata?: Record<string, unknown>;
}
export class LocalRagEngine {
private chunks: DocumentChunk[] = [];
// 1. 递归文本分块器 (Recursive Character Text Splitter)
public chunkText(
text: string,
chunkSize = 300,
chunkOverlap = 50
): DocumentChunk[] {
const separators = ['
', '
', '。', '!', '?', ' ', ''];
const result: string[] = [];
const splitRecursively = (currentText: string, sepIndex: number): string[] => {
if (currentText.length <= chunkSize || sepIndex >= separators.length) {
return [currentText.trim()].filter(Boolean);
}
const sep = separators[sepIndex];
const parts = currentText.split(sep);
const subChunks: string[] = [];
let currentAcc = '';
for (const part of parts) {
const piece = currentAcc ? `${currentAcc}${sep}${part}` : part;
if (piece.length <= chunkSize) {
currentAcc = piece;
} else {
if (currentAcc) subChunks.push(currentAcc.trim());
if (part.length > chunkSize) {
// 继续往下用更小颗粒度的分隔符递归切分
subChunks.push(...splitRecursively(part, sepIndex + 1));
currentAcc = '';
} else {
currentAcc = part;
}
}
}
if (currentAcc) subChunks.push(currentAcc.trim());
return subChunks;
};
const rawChunks = splitRecursively(text, 0);
// 叠加 Overlap 窗口并生成带 ID 的结构
this.chunks = rawChunks.map((chunk, index) => ({
id: `chunk_${index}`,
text: chunk,
}));
return this.chunks;
}
// 2. 向量点积/余弦相似度计算 (假设向量已完成 L2 归一化)
public static dotProduct(a: Float32Array, b: Float32Array): number {
let dot = 0;
const len = a.length;
for (let i = 0; i < len; i++) {
dot += a[i] * b[i];
}
return dot;
}
// 3. 注册带 Embedding 的文档切片
public registerChunksWithEmbeddings(chunksWithVectors: DocumentChunk[]): void {
this.chunks = chunksWithVectors;
}
// 4. 执行 Top-K 向量相似度检索
public retrieveTopK(
queryEmbedding: Float32Array,
k = 3,
minSimilarity = 0.5
): Array<{ chunk: DocumentChunk; similarity: number }> {
const scoredChunks = this.chunks
.map((chunk) => {
if (!chunk.embedding) return null;
const similarity = LocalRagEngine.dotProduct(queryEmbedding, chunk.embedding);
return { chunk, similarity };
})
.filter((item): item is { chunk: DocumentChunk; similarity: number } =>
item !== null && item.similarity >= minSimilarity
);
// 按相似度降序排序并截取 Top-K
scoredChunks.sort((a, b) => b.similarity - a.similarity);
return scoredChunks.slice(0, k);
}
// 5. 动态构建带有证据引用的增强上下文 Prompt
public buildPrompt(query: string, retrieved: Array<{ chunk: DocumentChunk; similarity: number }>): string {
const contextBlock = retrieved
.map((item, idx) => `[参考切片 ${idx + 1}]:
${item.chunk.text}`)
.join('
');
return `你是一个专业的本地知识助手。请严格结合以下参考切片回答用户问题,若参考内容未提及,请诚实告知未知,切勿编造。
【参考上下文】:
${contextBlock}
【用户问题】:
${query}
请给出严谨的解答并标注参考来源。`;
}
}
面试官视角:
- 考察深度:
- 候选人是否真正动手实践过 RAG 检索增强架构,而非仅仅知晓名词概念;
- 能否从数学逻辑(点积、L2 范数归一化)和内存效率(TypedArray 连续内存块)层面优化前端大量向量运算的执行性能;
- 对分块尺寸(Chunk Size)与重叠(Overlap)超参数的选型敏感度,以及如何防止幻觉注入。
- 下探追问链:
- 追问 1:如果本地文档有上万个切片,在前端做全量线性比对($O(N)$ 暴力搜索)会出现明显的卡顿,前端如何在不借助后端的情况下实现高效近似近邻检索(ANN)?
- 追问 2:单纯依靠向量余弦检索时,遇到精确的工单编号(如
BUG-2026-9901)往往检索召回率极低,前端如何结合 BM25 关键词检索做混合加权排序(Hybrid Search)?
延伸阅读:
- LangChain: Concepts of Text Splitters & Embedding Chunking
- Hugging Face Transformers.js Feature Extraction Pipeline
- In-Memory Vector Search with HNSW in WebAssembly
Agent 长会话场景下,面对有限的上下文窗口,如何设计多级记忆管理与滑动压缩机制?
答案
核心结论:
在复杂自主智能体(Agent)或数十轮以上深度长对话场景中,上下文窗口(Context Window)的无节制膨胀是导致系统雪崩的致命隐患:
- 长上下文的“三重危机”:
- 经济成本飙升:大模型通常按 Token 计费,全量上下文每轮重送会导致调用成本呈平方级 $O(N^2)$ 爆炸;
- 推理时延(Latency)剧增:Prefill(首字预填充)耗时线性甚至超线性上升,用户感知卡顿加剧;
- 模型注意力稀释与“中间遗忘(Lost in the Middle)”:模型在超长上下文中对处于中段的关键信息检索准确率大幅下滑,严重影响决策可靠度;
- 分级记忆管理体系:工业级系统通过构建由 工作记忆(Working Memory)、滚动摘要记忆(Rolling Summary Memory)、固定系统锚点(Pinned Anchors)与长期向量记忆(Long-Term Archival Memory) 构成的多级存储层,实现上下文 Token 预算的恒定收敛与高质量信息无损流转。
原理解析:
1. 多级上下文内存分层金字塔
2. 核心滑动压缩四大关键机制
- 不可退化锚点锁定(Immutable Anchor Pinning):
- 系统提示词(System Prompt)、开发者注入的角色规则、当前激活的 MCP 工具定义 Schema,在任何情况下均享有“最高优先级保护”,永远不被压缩或滑动剔除;
- 滚动递归摘要压缩(Recursive Rolling Summarization):
- 设定阈值(如工作记忆达到 4000 Tokens 时触发)。从最旧的几轮对话开始,提取关键事实、用户决策、已获变量并生成简短摘要,合并入
summary字段,随后丢弃原始的详细对话对象;
- 设定阈值(如工作记忆达到 4000 Tokens 时触发)。从最旧的几轮对话开始,提取关键事实、用户决策、已获变量并生成简短摘要,合并入
- KV-Cache 命中率保护策略(Prompt Caching Optimization):
- 现代顶级模型厂商(Anthropic Claude、DeepSeek、OpenAI)支持 Prompt Caching(前缀缓存命中)。若每次请求频繁改动 Prompt 顶端内容,会导致整个服务端的 KV-Cache 彻底击穿失效。因此,System Prompt 与稳定上下文必须严格保持在前缀静态不变,动态变化的摘要与近期对话置于后部,从而大幅降低延迟与账单成本;
- 意图检索激活长期记忆(Episodic Retrieval on Demand):
- 对于早先被截断的超长业务细节,将其转存至长期向量数据库;仅当后续对话意图检测命中特定历史关键词或实体时,才按需将单条记忆片段召回至工作上下文中。
规范代码实现:
以下为生产级架构中,具备 Token 预算控制与滑动窗口摘要能力的上下文管理器实现:
// memory/context-budget-manager.ts
export interface ChatMessage {
id: string;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tokensEstimate: number;
isPinned?: boolean; // 关键信息锁定保护
}
export class ContextBudgetManager {
private maxAllowedTokens: number;
private pinnedMessages: ChatMessage[] = [];
private rollingSummary = '';
private recentMessages: ChatMessage[] = [];
constructor(maxTokens = 8000) {
this.maxAllowedTokens = maxTokens;
}
// 粗略估算 Token 数量 (中英混合场景经验估算: 1 token ≈ 1.5 字符)
public static estimateTokens(text: string): number {
return Math.ceil(text.length / 1.5);
}
public setSystemPrompt(prompt: string): void {
const tokens = ContextBudgetManager.estimateTokens(prompt);
this.pinnedMessages = [
{
id: 'system_core',
role: 'system',
content: prompt,
tokensEstimate: tokens,
isPinned: true,
},
];
}
public addMessage(role: 'user' | 'assistant' | 'tool', content: string, pin = false): void {
const tokens = ContextBudgetManager.estimateTokens(content);
const msg: ChatMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role,
content,
tokensEstimate: tokens,
isPinned: pin,
};
if (pin) {
this.pinnedMessages.push(msg);
} else {
this.recentMessages.push(msg);
}
// 检查并执行滑动压缩
this.enforceBudgetConstraints();
}
// 强制 Token 预算收敛控制
private enforceBudgetConstraints(): void {
const calculateTotalTokens = () => {
const pinnedSum = this.pinnedMessages.reduce((sum, m) => sum + m.tokensEstimate, 0);
const summarySum = ContextBudgetManager.estimateTokens(this.rollingSummary);
const recentSum = this.recentMessages.reduce((sum, m) => sum + m.tokensEstimate, 0);
return pinnedSum + summarySum + recentSum;
};
// 当超出预算且近期消息大于 4 轮时,滑动挤出最老的消息进入待摘要队列
while (calculateTotalTokens() > this.maxAllowedTokens && this.recentMessages.length > 4) {
const oldestMsg = this.recentMessages.shift();
if (!oldestMsg) break;
// 提取核心信息注入滚动摘要
this.rollingSummary += `
[历史记录]: ${oldestMsg.role} 曾提到 "${oldestMsg.content.slice(0, 100)}..."`;
}
}
// 组装最终提交给大模型的标准化上下文消息数组
public assembleContext(): ChatMessage[] {
const finalPayload: ChatMessage[] = [...this.pinnedMessages];
// 若存在滚动摘要,以系统补充信息形式插入
if (this.rollingSummary) {
finalPayload.push({
id: 'rolling_summary_block',
role: 'system',
content: `【前序对话背景与要点摘要】:${this.rollingSummary}`,
tokensEstimate: ContextBudgetManager.estimateTokens(this.rollingSummary),
});
}
finalPayload.push(...this.recentMessages);
return finalPayload;
}
}
面试官视角:
- 考察深度:
- 候选人是否具备大模型系统长效稳定运行的高阶工程认知,是否把 Token 视作和内存、网络带宽同等珍贵的工程资源;
- 能否讲清楚现代模型厂商底层 KV-Cache 与 Prompt Caching 机制对上下文编排结构提出的具体约束(静态前缀不变性);
- 对信息丢失(Information Loss)与预算压缩之间的权衡(Trade-off)掌控能力。
- 下探追问链:
- 追问 1:如果使用另一个轻量小模型(如 GPT-4o-mini 或端侧模型)对历史对话进行递归摘要,何时触发摘要最合理?如何避免摘要本身产生新的事实性幻觉?
- 追问 2:当用户在第 50 轮对话中突然问及“在第 3 轮里我给你的那个订单号是多少”,滑动窗口已经被丢弃,系统应该如何保证关键实体的记忆追溯?
延伸阅读:
- Anthropic Claude Prompt Caching Architecture & Cost Optimization
- MemGPT: LLMs as Operating Systems with Virtual Context Management
- Attention with Linear Biases (ALiBi) & Long-Context Attention Mechanics
什么是 Prompt Caching(前缀缓存)与 KV-Cache 状态优化?静态与动态上下文编排如何降低时延与成本?
答案
核心结论:
在现代高并发大模型应用与长任务 Agent 中,Prompt Caching(前缀提示词缓存)与底层 KV-Cache 状态复用是决定系统首字延迟(TTFT)与计费成本的胜负手:
- 物理机制(KV-Cache 复用):大模型自回归生成时,前序所有 Token 经过自注意力(Self-Attention)计算生成的 Key 与 Value 矩阵(KV 向量)会被保存在 GPU 显存中;Prompt Caching 允许跨请求复用这些已计算好的前缀 KV 向量,跳过昂贵的预填充计算(Prefill Phase);
- 巨额降本增效:主流模型服务商(Anthropic Claude、DeepSeek、OpenAI)对命中 Prompt Cache 的 Token 给予 50%~90% 的价格折扣,同时首字延迟降低 80% 以上;
- 前端编排铁律:前缀静态对齐(Static Prefix Alignment):Prompt Caching 严格要求从第 0 个 Token 起的前缀必须绝对完全一致。若前端随意在上下文头部插入动态时间戳、动态递增的消息序号或打乱 System Prompt,将导致全局 KV-Cache 彻底击穿失效。
原理解析:
1. 传统无缓存推理 vs Prompt Caching 命中机制
2. 前端上下文编排的三大黄金准则
- 绝对静态顶置(Static-First Layout):
- 上下文严格分为“静态区(Static Zone)”与“动态区(Dynamic Zone)”。全局系统角色声明、版本信息、MCP 工具定义 JSON Schema 必须焊死在 Prompt 的最前端;
- 严禁在静态前缀中注入动态时间戳:
- 错误做法:
System: 你是助手,当前时间是 2026-09-12 12:15:30...(每秒都在变,永远击穿缓存); - 正确做法:将当前时间作为最后一条 User 消息的尾随元数据传入;
- 错误做法:
- 最小变更边界(Append-Only History):
- 历史消息必须采用“仅追加(Append-Only)”模式;一旦中间插入或删除了某轮对话,该位置之后的所有 KV-Cache 缓存全部失效。
规范代码实现:
以下展示在前端上下文组装器中,严格遵循 Prompt Caching 静态前缀对齐原则的架构实现:
// prompt/caching-assembler.ts
export interface PromptMessage {
role: 'system' | 'user' | 'assistant';
content: string;
cacheControl?: { type: 'ephemeral' }; // Anthropic 等支持显式缓存断点
}
export class PromptCachingContextAssembler {
// 1. 恒定不变的静态前缀 (必须保证字节级全等)
private static readonly IMMUTABLE_SYSTEM_PROMPT =
`You are an enterprise AI architecture assistant.
` +
`You strictly follow the company engineering standards and never hallucinate.
` +
`Rules: Always use TypeScript; Do not invent external APIs.`;
// 2. 稳定的 MCP 工具元数据前缀
private toolsSchemaPrefix: string;
constructor(toolsSchemaJson: string) {
this.toolsSchemaPrefix = toolsSchemaJson;
}
// 3. 组装具备高命中率的上下文 Payload
public buildCacheOptimizedMessages(
historyMessages: Array<{ role: 'user' | 'assistant'; content: string }>,
currentQuery: string,
dynamicContextMetadata?: { currentTime: string; userId: string }
): PromptMessage[] {
const payload: PromptMessage[] = [];
// [Block 1: 永久静态区] System Prompt + Tool Schemas (必须打上缓存锚点)
payload.push({
role: 'system',
content: `${PromptCachingContextAssembler.IMMUTABLE_SYSTEM_PROMPT}
[Available Tools]:
${this.toolsSchemaPrefix}`,
cacheControl: { type: 'ephemeral' }, // 显式标记在此处固化 KV-Cache
});
// [Block 2: 线性追加对话历史] 严禁在中间任意插入时间戳破坏前缀
for (const msg of historyMessages) {
payload.push({
role: msg.role,
content: msg.content,
});
}
// [Block 3: 动态元数据与当前提问] 动态变量永远置于最尾部
let finalUserMessage = currentQuery;
if (dynamicContextMetadata) {
finalUserMessage += `
[Runtime Context Metadata]:
- CurrentTime: ${dynamicContextMetadata.currentTime}
- UserId: ${dynamicContextMetadata.userId}`;
}
payload.push({
role: 'user',
content: finalUserMessage,
});
return payload;
}
}
面试官视角:
- 考察深度:
- 候选人是否具备大模型系统设计的高级优化经验,能否把 GPU 显存注意力机制与前端应用层编排无缝贯通;
- 能否指出日常开发中最容易误杀 Prompt Cache 的典型前端坏味道(如随机 ID、随手注入的全局动态时间);
- 对成本控制(Token Economics)与首字延迟(TTFT)极致优化的工程敏感度。
- 下探追问链:
- 追问 1:在多轮分支对话树(Session Tree)中,当用户回滚到第 3 轮并发起新分支时,原先分支的 KV-Cache 是否会被复用?如何最大化利用已缓存的前缀?
- 追问 2:为什么在某些大模型 API 中,Prompt Cache 存在“最短 Token 门槛”(例如至少需要 1024 Tokens 才能触发缓存机制)?
延伸阅读:
- Anthropic Claude Official Guide: Prompt Caching Architecture & Best Practices
- DeepSeek-V2/V3 Multi-head Latent Attention (MLA) & KV Cache Compression
KV Cache 越用越大,有哪些压缩手段?量化、驱逐和低秩压缩各自是怎么做的?
答案
核心结论:
KV Cache 的体积 = 层数 × 头数 × 序列长度 × head_dim × 2 × 位宽,随生成长度线性膨胀。已算出来的 KV 有三条正交的压缩路线:
- 量化路线——压每个元素占几个 bit:
- KIVI(arXiv:2402.02750,2024-02-05)是代表:它做非对称量化,Key 按 channel 维度量化、Value 按 token 维度量化,各自压到 2bit。这么分是因为 Key 的离群值沿 channel 方向出现、Value 的离群值沿 token 方向出现,量化轴选错就会精度崩掉。
- 收益是显存直接降数倍,且推理时反量化可融合进 kernel,延迟额外开销很小。
- 驱逐路线——少存几个 token 的 KV:
- H2O(arXiv:2306.14048,2023-06-24)用累计注意力分数当"heavy-hitter oracle":统计每个历史 token 被后续 token 关注的总量,保留高分者 + 最近窗口,其余丢弃。
- StreamingLLM(arXiv:2309.17453,2023-09-29)发现了 attention sink(注意力沉降)现象:序列最开头几个 token 会承接掉大量注意力权重。所以保留开头几个 sink token + 一个滑动窗口就能保住质量,而且不需要额外训练就能把窗口外推。
- 低秩路线——换一种表示让 KV 本身变小:
- MLA(Multi-head Latent Attention),出自 DeepSeek-V2(arXiv:2405.04434,2024-05-07),把 KV 联合压缩成一个低维 latent 向量再缓存,推理时再投影回各头——是"存得更少但信息更全"的路子。
原理解析:
1. 三条路线分别在动什么
| 路线 | 动的是哪一维 | 代表工作 | 代价 |
|---|---|---|---|
| 量化 | 单个 KV 元素的位宽 | KIVI(2bit 非对称) | 精度损失,长上下文更敏感 |
| 驱逐 | 缓存的 token 个数 | H2O、StreamingLLM | 丢信息,依赖注意力分布的稳定性 |
| 低秩压缩 | KV 的表示维度 | DeepSeek-V2 的 MLA | 改架构,需要重新训练 |
2. 驱逐策略的两种哲学
- H2O 是"按重要性排序":用累计注意力分数做重要性代理,保住被反复引用的 token。它的隐含假设是注意力分布随长度变化是相对稳定的。
- StreamingLLM 是"按位置兜底":它不假设重要性分布稳定,而是承认开头几个 token 承担了"注意力垃圾桶"的作用——模型需要一个地方倾倒无处安放的注意力。所以只要保住 sink + 近期窗口,长度外推就不崩。
3. 与本库既有考点的边界(这一条最容易答混)
- GQA / MQA 是「架构层」:减少 KV 头数,即"每层存几份 KV"——它在模型设计阶段就决定了缓存的形状。
- 前缀缓存(Prompt Caching)是「复用层」:复用已算好的 KV,避免重复 prefill——它不改变 KV 有多大,只改变算几次。
- 本题讲的是「压缩层」:KV 已经算出来并且确实要存,怎么把它存得更小。
规范实现:
// 概念骨架:把「驱逐」与「量化」组合成一条 KV 缓存生命周期策略
type KvPolicy = 'keep-all' | 'evict-heavy-hitter' | 'evict-sink-window' | 'quantize';
interface KvEntryMeta {
tokenId: number;
position: number;
accumulatedAttention: number; // H2O 用的累计注意力分
isSink: boolean; // StreamingLLM 用的开头 sink 标记
}
interface KvCacheManager {
// 预算:以字节为单位,而不是以 token 个数——不同模型位宽不同
byteBudget: number;
policy: KvPolicy;
admit(meta: KvEntryMeta, kvBytes: number): void;
// 驱逐:保留 sink + 最近窗口 + 高累计注意力,其余按分数淘汰
evictIfNeeded(): KvEntryMeta[];
// 量化:Value 按 token 维、Key 按 channel 维,各压到 2bit
quantize(entry: { key: Float32Array; value: Float32Array }): {
key: Int8Array;
value: Int8Array;
scales: Float32Array;
};
}
面试官视角:
- 必答:① 说清 KV Cache 随序列长度线性增长;② 至少讲透一条路线(推荐量化 + 驱逐);③ 说清量化的非对称性(Key 按 channel、Value 按 token);④ 说清 H2O 与 StreamingLLM 的机制差异。
- 加分项:能讲出 attention sink 这个现象本身;能指出 MLA 属于改架构、不能即插即用;能给出"压缩越激进、精度损失越大、显存省得越多"的权衡轴。
- 常见误区:把量化说成"无损";把 KV Cache 压缩说成"减少了计算量"(计算量基本不变,省的是显存与带宽);把 GQA 当成 KV 压缩的一种(它是架构层,答案里要主动划清边界);把 H2O 说成"保留最近的 token"(那是 StreamingLLM 的窗口,H2O 保的是重不重要,不是近不近)。
延伸阅读:
- KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache(arXiv:2402.02750,2024-02-05)
- H2O: Heavy-Hitter Oracle for Efficient Generative Inference of Large Language Models(arXiv:2306.14048,2023-06-24)
- Efficient Streaming Language Models with Attention Sinks(arXiv:2309.17453,2023-09-29)
- DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model(MLA 的出处,arXiv:2405.04434,2024-05-07)
长任务跑久了,上下文会「腐化」、行为会「漂移」,怎么检测和干预?
答案
核心结论:
长任务的失败不是突然发生的,而是渐进的:轨迹越滚越长,早期的关键约束被埋在上下文深处且被大量中间步骤稀释,模型开始只顾着完成「当前一步」而忘了「整体目标」。这类现象在面试里通常叫上下文腐化(Context Decay)与行为漂移(Behavior Drift)。
检测要比修复更早一步,四个可落地的信号:
- 目标一致性检查(Goal Adherence):定期把「原始目标」与「当前正在做的事」做一次比对,问一句「当前这一步是否仍在推进原始目标」——这是最直接、最便宜的漂移探针;
- 不变量断言(Invariant Check):为任务预先声明「必须始终成立」的性质(如「不得修改用户明确禁止改动的文件」「不得在未确认前执行写操作」),每步或每 N 步校验一次,违反即判定漂移;
- 重复与回环检测(Loop & Repetition):语义相似的动作连续出现、步数增长但状态无变化、同一错误反复重试,都是腐化的外在信号;
- 成本与进展比:单位步数带来的有效进展下降(Token 消耗上升但任务完成度不变),是上下文被噪音占满的典型表现。
干预手段分四档,从轻到重:压缩(丢弃低价值轨迹)→ 锚定(把原始目标与关键约束重新注入到上下文近端)→ 重规划(显式重述计划并丢弃已污染的分叉)→ 人工接管。关键原则:原始目标必须被「锚定」到上下文的近端位置——因为自回归模型对上下文近端的信息更敏感,把目标写在第 3 轮的开头,不如在第 30 轮重新声明一次。
原理解析:
1. 腐化是怎么发生的
2. 「锚定」为什么有效
自回归模型的注意力随上下文增长而稀释,近端信息的影响更大。因此把目标与硬约束周期性重述(re-anchoring),相当于在每一步都把「全局目标」重新推到近端。工程上可以把它做成每 N 步插入一段结构化的任务状态块:
[任务状态块]
原始目标:<verbatim 重述,不改写>
硬约束:不可执行写操作 / 不得触碰 <路径> / 输出必须符合 <Schema>
已完成:<3 条以内>
当前子目标:<1 句>
禁止事项:<最近一次违反的规则,若有>
3. 腐化与「模型能力不足」怎么区分
| 现象 | 更可能是上下文腐化 | 更可能是能力/工具问题 |
|---|---|---|
| 早期步正确、后期开始跑偏 | ✅ | ❌ |
| 换个更小上下文就恢复正常 | ✅ | ❌ |
| 同一任务重跑一次又对了 | ✅ | ❌ |
| 一开始就选错工具、方向全错 | ❌ | ✅ |
| 特定工具/特定输入形态必错 | ❌ | ✅ |
4. 失败要分级,不要一律重试
同一失败模式重复出现 N 次,说明重试不会带来新信息,应当转为重规划或人工介入。这也是把「Agent 反复失败时工程师该做什么」的答案落到具体工程动作上。
规范实现:
// drift-detection.ts
export interface TaskAnchors {
goal: string; // 原始目标,逐字保留
hardConstraints: string[]; // 硬约束,逐字保留
forbidden: string[]; // 禁止事项
}
export interface RunSignal {
step: number;
actionSummary: string;
stateChanged: boolean;
tokensSoFar: number;
progressScore: number; // 0~1,本步带来的有效进展
}
export type DriftVerdict = 'ok' | 'drift-suspect' | 'drift-confirmed';
export async function detectDrift(
anchors: TaskAnchors,
recent: RunSignal[],
check: (anchors: TaskAnchors, recent: RunSignal[]) => Promise<{ aligned: boolean; note: string }>
): Promise<DriftVerdict> {
// 1) 重复/回环检测:语义相近动作连续出现且状态无变化
const tail = recent.slice(-3);
const noProgress = tail.length === 3 && tail.every(r => !r.stateChanged);
if (noProgress) return 'drift-confirmed';
// 2) 目标一致性检查:把原始目标与当前行为做比对
const { aligned, note } = await check(anchors, recent);
if (!aligned) return 'drift-confirmed';
// 3) 进展比下滑:Token 持续上涨但进展不增,典型的上下文被噪音占满
const tokensUp = recent[recent.length - 1].tokensSoFar > recent[0].tokensSoFar * 1.5;
const progressFlat = recent.slice(-3).every(r => r.progressScore < 0.1);
if (tokensUp && progressFlat) return 'drift-suspect';
return 'ok';
}
export async function intervene(
level: 'compress' | 'reanchor' | 'replan' | 'human',
anchors: TaskAnchors,
ctx: { history: unknown[]; state: unknown }
): Promise<{ history: unknown[]; note: string }> {
switch (level) {
case 'compress':
// 丢弃低价值轨迹(成功且短的工具输出、重复内容),保留失败与决策点
return { history: compressKeepingDecisions(ctx.history), note: '已压缩低价值轨迹' };
case 'reanchor':
// 把目标与硬约束重新注入到近端
return { history: [...ctx.history, renderAnchorBlock(anchors)], note: '已重述目标与硬约束' };
case 'replan':
return { history: [...ctx.history, renderAnchorBlock(anchors), renderReplanPrompt(ctx.state)], note: '已丢弃污染分叉并重规划' };
case 'human':
return { history: ctx.history, note: '已升级为人工接管' };
}
}
判定顺序的讲究:先做便宜的确定性检查(重复、进展比),再做需要模型的一次目标比对;确认漂移后按「压缩 → 锚定 → 重规划 → 人工」逐级升级,不要一上来就重规划——重规划会丢掉已经获得的正确中间结果,代价最大。
面试官视角:
考察意图: 考察候选人是否理解长任务的失败机制是渐进的信息稀释,而非模型突然变笨。高分回答必须能区分「上下文腐化」与「能力不足」,并给出周期性锚定与不变量断言这两条最具工程价值的手段。
深度追问链:
- 追问 1: 怎么判断 Agent 是跑偏了,还是这个任务本来就难?
- 答题切入点: 看「轨迹形状」而非「结果」——早期步骤正确、后期偏离,且缩短上下文或重跑一次就恢复正常,是腐化的典型特征;一开始就方向全错、且输入形态固定时必错,更可能是能力或工具问题。
- 追问 2: 上下文压缩会不会把关键信息压没了?
- 答题切入点: 会,所以压缩必须分级:先做无损的结构化裁剪(丢弃成功且短的观测、合并重复),再对失败与决策点做保留式摘要;关键目标与硬约束不靠摘要保留,而是靠周期性锚定重述——这是两者最大的区别。
- 追问 3: 同一类错误连续重试三次,你怎么办?
- 答题切入点: 不再重试。第三次仍失败说明重试不产生新信息,应触发重规划(显式重述计划与约束)并限制重规划次数;仍失败则上报人工,保留完整轨迹用于归因。
延伸阅读:
工具失败后重试,为什么容易演变成「重试风暴」?退避与熔断怎么设计?
答案
核心结论:
重试风暴的成因不是「重试次数太多」,而是重试本身变成了负载的一部分。一次下游故障会让 N 个上游同时重试,重试请求叠加到已经过载的下游上,把原本 30 秒能自愈的抖动放大成持续不可用——这叫重试放大(Retry Amplification)。
四个必须同时具备的工程手段:
- 指数退避 + 抖动(Exponential Backoff with Jitter):退避是必需的,但必须带抖动。纯指数退避会让所有客户端在同一时刻重试,形成同步化的尖峰;加随机抖动把重试摊平。
- 重试预算(Retry Budget):限制「重试请求量占正常请求量的比例」(业界常见上限是 10% 左右),超过就直接失败而不是继续重试。这是防止放大的关键阀门。
- 熔断(Circuit Breaker):连续失败达到阈值后快速失败,并周期性放行探测请求(半开态)确认下游是否恢复。熔断期间不占用下游连接,避免雪上加霜。
- 失败分级:只有可重试错误才重试(网络超时、限流、5xx),不可重试错误(参数非法、鉴权失败、404)必须立即失败。重试一个必然失败的请求,只是把成本翻倍并延后了正确的错误响应。
在 Agent 场景下的特殊约束:Agent 的重试成本远高于普通 RPC——每次重试都要重新喂给模型一遍上下文,可能同时产生新的 Token 消耗与副作用(写操作可能被重复执行)。因此 Agent 的重试必须幂等、必须有步数预算、并且默认不重试写操作(除非该操作带幂等键)。
原理解析:
1. 放大是怎么发生的
2. 熔断器的三种状态
| 状态 | 行为 | 目的 |
|---|---|---|
| Closed(闭合) | 正常放行,统计失败率 | 正常工作 |
| Open(打开) | 达到阈值后快速失败,不再打下游 | 保护下游、给下游恢复窗口 |
| Half-Open(半开) | 放行少量探测请求 | 判断下游是否已恢复,再决定回 Closed 还是回 Open |
关键细节:熔断的失败率统计要用滑动窗口(如最近 30 秒内的请求),且慢调用也要计入,否则下游变慢但没报错时,熔断不会触发,而慢会耗尽连接池,效果与雪崩相同。
3. 抖动为什么不能省
不加抖动的指数退避会让 1000 个客户端在第 1 秒、第 2 秒、第 4 秒形成整齐的脉冲式重试,恰好在下游最脆弱的时刻持续施压。加了随机抖动后,重试被摊平成持续的平缓流量,下游有喘息窗口。
4. Agent 场景的额外风险
- 写操作重复执行:Agent 重试一个「创建工单」的工具调用,可能产生两条工单。必须靠幂等键(请求级唯一 ID)而不是靠「不重试」来防。
- Token 成本翻倍:每次重试都要重新组装上下文。
- 步数膨胀掩盖真实问题:Agent 步数预算被重试吃掉后,真正需要推理的步数不够,任务质量随之下降。
规范实现:
// resilient-retry.ts
export type ErrorClass = 'retryable' | 'non-retryable' | 'throttled';
export function classify(err: unknown): ErrorClass {
const e = err as { status?: number; code?: string; name?: string };
if (e.status === 429) return 'throttled';
if (e.status !== undefined) return e.status >= 500 ? 'retryable' : 'non-retryable';
if (e.code === 'ECONNRESET' || e.code === 'ETIMEDOUT' || e.name === 'AbortError') return 'retryable';
return 'non-retryable';
}
interface BreakerState { failures: number; openedAt: number; state: 'closed' | 'open' | 'half-open'; }
export function createResilientCaller(opts: {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
failureThreshold: number;
openMs: number;
retryBudgetRatio: number; // 重试量 / 正常量 的上限
currentRetryRatio: () => number;
}) {
let breaker: BreakerState = { failures: 0, openedAt: 0, state: 'closed' };
const backoff = (attempt: number) => {
const exp = Math.min(opts.maxDelayMs, opts.baseDelayMs * 2 ** attempt);
return Math.random() * exp; // full jitter:抖动不是附加项,是必需项
};
return async function call<T>(fn: () => Promise<T>, idempotencyKey?: string): Promise<T> {
if (breaker.state === 'open') {
if (Date.now() - breaker.openedAt < opts.openMs) {
throw new Error('circuit-open'); // 快速失败:不占用下游连接
}
breaker.state = 'half-open'; // 半开,放行探测
}
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
const out = await fn();
breaker = { failures: 0, openedAt: 0, state: 'closed' };
return out;
} catch (err) {
lastErr = err;
const cls = classify(err);
if (cls === 'non-retryable') throw err; // 不可重试:立即失败
breaker.failures += 1;
if (breaker.failures >= opts.failureThreshold) {
breaker = { failures: breaker.failures, openedAt: Date.now(), state: 'open' };
throw err;
}
// 重试预算阀门:超过比例直接放弃重试
if (opts.currentRetryRatio() > opts.retryBudgetRatio) {
throw new Error('retry-budget-exhausted');
}
await new Promise(r => setTimeout(r, backoff(attempt)));
}
}
throw lastErr;
};
}
Agent 侧的配套约束:
- 写操作默认不重试,除非带幂等键(以请求级唯一 ID 去重);
- 重试次数计入 Agent 步数预算,超限时直接上报而不是继续消耗;
- 熔断打开时,Agent 应返回「该工具暂不可用 + 建议稍后重试或换路径」,而不是让整条链路失败。
面试官视角:
考察意图: 考察候选人是否理解重试放大这一非线性效应,以及是否具备「重试预算 + 熔断 + 抖动」这套完整的韧性工程直觉。高分回答必须能指出不加抖动会让重试同步化、不可重试错误不该重试、以及Agent 场景下写操作重试的副作用。
深度追问链:
- 追问 1: 你的重试策略是什么?为什么?
- 答题切入点: 指数退避 + full jitter,最大次数与最大延迟封顶;错误分级决定是否重试;配合重试预算(占正常流量比例上限)与熔断,而不是单纯设一个「最多重试 3 次」。
- 追问 2: 下游只是变慢了,没有报错,要不要熔断?
- 答题切入点: 要。慢调用同样耗尽连接池并拖垮上游;熔断的统计窗口应把慢调用纳入判定(慢调用率 + 错误率双阈值),且窗口是滑动窗口而非累计值。
- 追问 3: Agent 调用写操作失败了,重试会不会产生两条数据?
- 答题切入点: 会,所以写操作不能靠「不重试」来防,必须设计幂等——请求级唯一 ID 落库去重;Agent 层的默认策略是写操作带幂等键才允许重试,否则上报人工或走补偿流程。
延伸阅读:
AGENTS.md / 规则文件越写越长,效果反而变差,怎么治理?
答案
核心结论:
规则文件变长会让效果变差,机制是注意力稀释 + 冲突累积 + 过期内容三件事同时发生:
- 注意力稀释:规则越靠后越容易被忽略,靠前又容易被后续内容淹没;真正需要被遵守的硬约束淹没在几十条软建议里。
- 冲突累积:后期追加的规则往往与早期规则重叠甚至矛盾(早期说「优先用 A」,后期说「一律用 B」),模型面对矛盾指令的行为是不确定的。
- 过期内容:规则里沉淀了大量已经失效的约定(依赖换了、目录搬了、API 改了),这些内容不仅无用,还占预算并误导模型。
治理的四个动作(按优先级):
- 分层:把规则拆成「始终注入的核心层」(少量硬约束,通常不超过 10 条)与「按需加载的领域层」(只在相关文件/任务被触达时才注入);
- 去重与消歧:同一主题只保留一条权威表述,历史版本进归档而不是留在正文;
- 可执行化:能被工具/检查替代的规则,不要用自然语言写进提示词——用 lint、类型、CI 卡口表达,规则文件只保留「机器判不了、需要人理解」的部分;
- 治理机制化:规则文件必须有 owner、有评审、有失效期;每次大改动后跑一次「规则有效性回归」。
判断标准:一份规则文件里,每一条规则都应该能对应到一个真实的失败案例。找不到对应失败案例的规则,大概率是可以删的。
原理解析:
1. 膨胀的三条路径
2. 分层的具体形态
| 层 | 内容 | 注入方式 | 体量建议 |
|---|---|---|---|
| 核心层 | 不可违反的硬约束、目录结构、命令约定、安全红线 | 始终注入 | 10 条以内 |
| 领域层 | 某个子系统的专属约定(构建、测试、发布) | 命中该目录/任务时注入 | 每个领域 5~15 条 |
| 任务层 | 当前任务的临时约束 | 单次任务注入 | 视任务而定 |
| 归档层 | 已失效但可能有追溯价值的历史约定 | 不注入,按需查阅 | 不限 |
3. 「可执行化」是收益最大的一招
能用工具表达的规则,不要用提示词表达:
| 自然语言规则 | 可执行替代 |
|---|---|
「不要用 any」 | tsconfig 的 noImplicitAny + ESLint 规则 + CI 门禁 |
| 「提交前必须跑测试」 | husky pre-commit + CI 必跑项 |
「不要直接改 legacy/ 目录」 | ESLint no-restricted-paths / 目录级 CODEOWNERS + CI 检查 |
| 「新代码必须带测试」 | 覆盖率阈值门禁 + diff-aware 覆盖率检查 |
把机器能判的规则交给机器,规则文件就只剩下真正需要模型理解的部分(架构意图、领域语义、沟通风格),体量自然可控。
4. 规则有效性回归
规则改动的风险不亚于代码改动。最低成本的回归是:维护一组规则触发的真实场景(每个核心规则至少一个正例与一个反例),在规则文件变更后跑一遍,看模型行为是否符合预期。这与代码库的回归测试是同一套方法论。
规范实现:
// rules-governance.ts
export interface RuleLayer {
name: 'core' | 'domain' | 'task' | 'archive';
scope?: string; // domain 层的作用域(目录、任务类型)
rules: Array<{ id: string; text: string; enforcement: 'hard' | 'soft'; owner: string; expiresAt?: string }>;
}
export interface AssembledContext {
blocks: string[];
tokenEstimate: number;
conflicts: Array<{ a: string; b: string }>;
}
export function assembleRules(layers: RuleLayer[], ctx: { paths: string[]; taskType: string }): AssembledContext {
const active = layers.filter(l => {
if (l.name === 'core') return true;
if (l.name === 'domain') return (l.scope ?? '').split(',').some(s => ctx.paths.some(p => p.startsWith(s)));
if (l.name === 'task') return l.scope === ctx.taskType;
return false; // archive 永不注入
});
// 1) 同一主题只保留一条权威表述:按 id 去重,冲突标记出来人工裁决
const seen = new Map<string, string>();
const conflicts: Array<{ a: string; b: string }> = [];
for (const layer of active) {
for (const rule of layer.rules) {
const topic = rule.id.split(':')[0];
if (seen.has(topic)) {
conflicts.push({ a: seen.get(topic)!, b: rule.text });
continue;
}
seen.set(topic, rule.text);
}
}
// 2) 硬约束优先,且核心层排在最前
const ordered = [
...active.filter(l => l.name === 'core').flatMap(l => l.rules),
...active.filter(l => l.name !== 'core').flatMap(l => l.rules)
].sort((a, b) => (a.enforcement === b.enforcement ? 0 : a.enforcement === 'hard' ? -1 : 1));
const blocks = ordered.map(r => `- [${r.enforcement}] ${r.text}`);
return {
blocks,
tokenEstimate: Math.ceil(blocks.join('\n').length / 1.6), // 粗略 token 估算
conflicts
};
}
// 3) 规则有效性回归:每个核心规则至少一个正例与一个反例
export interface RuleCase { ruleId: string; prompt: string; expectCompliant: boolean; }
export function auditRuleCoverage(rules: RuleLayer[], cases: RuleCase[]): Array<{ ruleId: string; missing: 'positive' | 'negative' }> {
const issues: Array<{ ruleId: string; missing: 'positive' | 'negative' }> = [];
for (const layer of rules) {
for (const rule of layer.rules) {
if (layer.name !== 'core') continue;
const related = cases.filter(c => c.ruleId === rule.id);
if (!related.some(c => c.expectCompliant)) issues.push({ ruleId: rule.id, missing: 'positive' });
if (!related.some(c => !c.expectCompliant)) issues.push({ ruleId: rule.id, missing: 'negative' });
}
}
return issues;
}
配套的仓库约定(写进项目自身的规则里):
- 规则文件每条必须有 owner 与 来源失败案例链接;
- 新增规则前先检索是否已有同类条目(避免重复与冲突);
- 每季度做一次失效审计:把近三个月没有对应失败案例的规则降级到归档层;
- 规则文件变更走代码评审,diff 里能看出「新增规则对应的真实问题」。
面试官视角:
考察意图: 考察候选人是否理解「AI 编码助手的规则资产」本质上是需要治理的工程产物,而不是可以无限追加的文档。高分回答必须给出分层与可执行化两条主线,并能说清膨胀的三个机制。
深度追问链:
- 追问 1: 你们团队的规则文件多长?有没有统计过它对效果的负面影响?
- 答题切入点: 给分层后的口径——核心层条数与 token 占用、领域层按需命中率、归档层存量;并用「规则有效性回归」证明裁剪后效果没有退化,而不是凭感觉删。
- 追问 2: 规则之间冲突了怎么办?让模型自己判断吗?
- 答题切入点: 不让模型判断。冲突必须在装配阶段被检出(同主题多条 → 标记冲突并人工裁决),把矛盾决策从模型手里拿回来;同时规定同主题只有一条权威表述。
- 追问 3: 哪些规则应该写进文件,哪些应该变成工具?
- 答题切入点: 机器能判的一律变工具(类型、lint、CI 门禁、目录限制);只有需要模型理解的(架构意图、领域语义、沟通约定)留在文件里。这是把文件体量压回可维护范围的根本手段。
延伸阅读:
意图判断不准时,RAG 怎么保证检索到正确的知识库,而不是检索错库?
答案
核心结论:
多知识库 RAG 的失败点通常不在检索本身,而在路由:一旦把「数学题」路由到「物理知识库」,后面再好的向量检索也检索不出正确内容。所以正确做法是把路由从「让模型选一个库」改成**「先粗召回再精排」的检索问题**:
- 用检索而非分类来做路由:为每个知识库建立库级描述向量,用用户 query 直接对「知识库描述」做相似度检索,取 Top-K 候选库,而不是让模型做单选分类。分类是零和的,检索不是——当问题跨库时,Top-K 天然能同时保留多个候选。
- 让证据说话,而不是让模型猜:先在 Top-K 候选库里并行做粗召回,再用库级覆盖度(召回了多少条来自该库、命中强度如何)做重排,库级得分高的优先。这一步不依赖模型对「这是数学还是物理」的判断。
- 保留多路结果并显式标注:当 Top-K 的库级得分接近时,不要强行选一个——并行在多个库检索,把结果合并并在答案中标注来源库;让下游(引用、溯源、追问)来处理不确定性。
- 兜底到全局库:任何知识库都覆盖不了的部分,要有一个全局/兜底索引,否则路由错误会直接导致「查无结果」。
判断口径:路由错误的代价是检索结果全错,而不是「差一点」。因此路由这一层必须独立评测,不能只看最终答案质量。
原理解析:
1. 两条路线的差别
2. 为什么「让模型判断意图」在跨库问题上不可靠
模型在「数学 vs 物理」这种有明确学科边界的分类上通常没问题,但在边界模糊的真实问题上会崩:
- 「这堆报错是配置问题还是代码问题」——跨多个库;
- 「公司报销标准是多少」——同时涉及制度库与历史工单库;
- 「这个公式为什么这么写」——公式在数学库,出处可能在物理教材库。
一旦强行单选,损失是全部。检索式路由把「选择」变成「打分与排序」,允许并列,损失被限制在「多花一次检索」。
3. 库级描述向量是路由质量的关键资产
库级描述不是随便写的一句话,它应当包含该库覆盖的话题、典型问法、边界(不覆盖什么),并用真实的该库内问题去微调/校准。路由的召回质量几乎完全由这层资产决定——它是「检索的元数据」,不是文档。
4. 路由必须独立评测
至少三个指标:
- 路由命中率:正确库是否出现在 Top-K 候选里(Recall@K);
- 错路由代价:错误路由后是否还能被全局兜底捞回;
- 跨库问题占比:这类问题在真实流量里的比例,决定了并行多库检索是否值得。
规范实现:
// multi-kb-routing.ts
export interface KnowledgeBase {
id: string;
description: string; // 库级描述:覆盖话题 + 典型问法 + 边界
descriptionEmbedding: number[];
retriever: (q: string, topK: number) => Promise<Array<{ docId: string; score: number; excerpt: string }>>;
}
export interface RouteResult {
candidates: Array<{ kbId: string; kbScore: number; hits: Array<{ docId: string; score: number; excerpt: string }> }>;
merged: Array<{ kbId: string; docId: string; score: number; excerpt: string }>;
ambiguous: boolean; // 是否存在并列候选
}
export async function routeAndRetrieve(
query: string,
kbs: KnowledgeBase[],
embed: (text: string) => Promise<number[]>,
opts: { topK: number; perKbTopN: number; marginRatio: number; globalKb?: KnowledgeBase }
): Promise<RouteResult> {
// 1) 库级描述向量检索:得到候选库(不是单选)
const qv = await embed(query);
const scored = await Promise.all(
kbs.map(async kb => ({ kb, score: cosine(qv, kb.descriptionEmbedding) }))
);
scored.sort((a, b) => b.score - a.score);
const candidates = scored.slice(0, opts.topK);
// 2) 在候选库并行粗召回
const withHits = await Promise.all(
candidates.map(async c => ({
kbId: c.kb.id,
kbScore: c.score,
hits: await c.kb.retriever(query, opts.perKbTopN)
}))
);
// 3) 按库级覆盖度重排:证据说话,不靠模型猜意图
const reranked = withHits
.map(c => ({ ...c, kbScore: c.kbScore * 0.4 + coverage(c.hits) * 0.6 }))
.sort((a, b) => b.kbScore - a.kbScore);
// 4) 合并 + 标注来源;得分接近则标记歧义,交给下游处理
const merged = reranked.flatMap(c => c.hits.map(h => ({ kbId: c.kbId, ...h })));
const top = reranked[0];
const second = reranked[1];
const ambiguous = !!second && (top.kbScore - second.kbScore) / (top.kbScore || 1) < opts.marginRatio;
// 5) 兜底:全局索引捞回,避免路由错误直接等于查无结果
if (opts.globalKb && (reranked.length === 0 || top.kbScore < 0.3)) {
const fallback = await opts.globalKb.retriever(query, opts.perKbTopN);
merged.push(...fallback.map(h => ({ kbId: opts.globalKb!.id, ...h })));
}
return { candidates: reranked, merged, ambiguous };
}
function coverage(hits: Array<{ score: number }>): number {
if (hits.length === 0) return 0;
const top = hits[0].score;
const rest = hits.slice(1).reduce((s, h) => s + h.score, 0);
return 0.7 * top + 0.3 * Math.min(1, rest / Math.max(1, hits.length));
}
答案生成侧的配套:当 ambiguous 为真时,生成提示里要显式要求「列出可能的多个来源并标注各自依据」,而不是逼模型选一个库然后编造出确定的结论。
面试官视角:
考察意图: 考察候选人是否理解路由错误是检索错误的放大器,以及能否给出比「让模型分类」更稳健的工程方案。高分回答必须点出检索式路由(Top-K 而非单选)、库级描述向量作为核心资产、兜底索引与路由独立评测。
深度追问链:
- 追问 1: 你的多知识库路由是让模型分类吗?分类错了怎么办?
- 答题切入点: 分类是零和的,一旦选错就是全部丢失;改成对库级描述向量做 Top-K 检索保留并列候选,再在候选库并行粗召回、按库级覆盖度重排;路由层独立评测 Recall@K,而不是只看最终答案。
- 追问 2: 库里有些问题天然跨库(既要公式又要物理场景),你的方案怎么支持?
- 答题切入点: 允许多路并行检索并在答案里标注来源;得分接近时标记歧义,把不确定性交给下游(引用与追问),而不是在路由层强行二选一。
- 追问 3: 用户问了个所有库都没有的内容,会发生什么?
- 答题切入点: 必须有全局兜底索引;没有兜底时,路由错误会直接表现为「查无结果」,用户感知是产品坏了而不是「没查到」。
延伸阅读:
服务或容器重启后,Agent 的任务状态怎么恢复?任务执行到一半中断,怎么做到断点续跑?
答案
核心结论:
任务状态的恢复能力,取决于状态放在哪里,而不是状态有多少。四个必须同时成立的设计要点:
- 状态外置,且以编排器侧为唯一真相源:沙箱/容器内的文件、内存、进程状态都不可信——实例会被回收、被抢占、被重建。续跑所需的断点、产物、进度必须写在外部状态存储(对象存储 / KV / 数据库),沙箱只是可随时丢弃的计算载体。
- 断点粒度是成本与精度的取舍:工具调用级断点最精确但写入最频繁;步骤级是常用折中;阶段级最便宜但恢复后要重跑整段。判据是「该步副作用的代价」与「重跑成本」的比值——写操作密集的阶段用细粒度,纯推理阶段用粗粒度。
- 续跑必须幂等:写操作带幂等键(请求级唯一 ID,落库去重)。重放同一段轨迹时,已经发生过的副作用要能被判定为「已发生」,而不是被再做一遍。
- 不一致必须可检测、可修复:用状态版本号加乐观并发(CAS)防并发覆盖;用「事件日志为真、投影可重算」把修复变成重放;用 fencing token 防止已被回收的旧执行者继续写入。
关键区分:状态恢复不等于上下文恢复。 上下文可以靠摘要重放(近似即可),状态必须靠副作用重放(必须精确)。把这两件事混为一谈,是「续跑后数据错乱」的根因。
原理解析:
1. 为什么容器内的状态不能作为续跑依据
沙箱实例的生命周期由调度器决定:可能被抢占回收、可能被冷启动替换、可能因宿主重启而整体消失,依赖「上次还在那个容器里」的设计在第一次调度抖动时就会丢进度。更关键的是,Agent 执行的往往是模型生成的代码,它对沙箱内存与文件的写入不可信。结论是:沙箱只提供算力,外部存储才提供记忆。
2. 三种断点粒度的对照
| 粒度 | 写入时机 | 恢复精度 | 额外成本 | 适用阶段 |
|---|---|---|---|---|
| 工具调用级 | 每次工具调用后 | 精确到单次副作用 | 写入频繁,状态体量大 | 写操作密集(发单、改配置) |
| 步骤级 | 每轮 ReAct 推理后 | 精确到一轮「思考 - 行动」 | 中等 | 通用默认 |
| 阶段级 | 规划阶段切换时 | 需重跑整个阶段 | 最省 | 纯推理/调研阶段 |
3. 断点里到底存什么
断点不是「把上下文存下来」,而是三类信息的合集:控制状态(当前阶段、下一步动作、剩余预算)、已发生的副作用清单(写操作及其幂等键与结果)、可重放的上下文骨架(摘要、关键实体、证据引用指针)。第三类可以近似,第一类必须精确,第二类必须可判定。
4. 状态恢复流程
5. 两种一致性模型
- 快照式(Snapshot):定期把完整状态写入存储。实现简单、恢复快,但写放大明显,且快照之间的动作会丢失——快照点之后、崩溃之前做的写操作可能被重做或丢失。
- 日志式(Event Log / Write-Ahead Log):只记录状态迁移事件,恢复时重放。不会丢中间动作,代价是重放成本与日志膨胀;工程上常用「事件日志加周期性快照」的组合:重放最近一次快照之后的事件即可。
6. 状态不一致的三种典型形态
- 并发写覆盖:两个执行者(重试实例与原实例)同时推进任务,后者覆盖前者。用版本号加 CAS 解决。
- 陈旧执行者写入:实例被回收后网络分区,它仍持有旧租约继续写。用 fencing token(单调递增的 epoch)解决——存储侧校验 token,旧 epoch 一律拒绝。
- 外部世界已变:断点记录「工单已创建」,但网络超时导致对端其实创建了两次。这是幂等问题而非状态问题,必须由业务侧幂等键兜住。
规范实现:
// agent/resumable-runner.ts
export interface RunState {
taskId: string;
version: number; // 单调递增,乐观并发控制
epoch: number; // fencing token,防止陈旧执行者写入
phase: string; // 当前阶段
nextAction: string; // 下一步动作描述
spentBudgetMs: number;// 已消耗预算
sideEffects: Array<{ idempotencyKey: string; tool: string; resultDigest: string }>;
contextSkeleton: { summary: string; keyEntities: string[]; evidenceRefs: string[] };
}
export interface CheckpointStore {
// CAS 写入:版本不匹配则失败,避免并发覆盖
save(state: RunState): Promise<boolean>;
loadLatest(taskId: string): Promise<RunState | null>;
// 幂等登记表:同一个 key 只会产生一次副作用
claimIdempotencyKey(key: string, tool: string): Promise<{ claimed: true } | { claimed: false; resultDigest: string }>;
}
// 从断点恢复:区分「状态恢复」与「上下文恢复」
export async function resumeTask(
store: CheckpointStore,
taskId: string,
epoch: number
): Promise<{ state: RunState; resumed: boolean }> {
const saved = await store.loadLatest(taskId);
if (!saved) {
return {
state: {
taskId, version: 0, epoch, phase: 'init', nextAction: 'plan', spentBudgetMs: 0,
sideEffects: [], contextSkeleton: { summary: '', keyEntities: [], evidenceRefs: [] },
},
resumed: false,
};
}
if (saved.epoch > epoch) {
throw new Error('stale-epoch'); // 已有更新的执行者接管,本实例必须停止写入
}
return { state: { ...saved, epoch }, resumed: true };
}
// 写操作的幂等包装:先占坑再执行
export async function runSideEffect(
store: CheckpointStore,
state: RunState,
tool: string,
idempotencyKey: string,
execute: () => Promise<string>
): Promise<string> {
const claim = await store.claimIdempotencyKey(idempotencyKey, tool);
if (!claim.claimed) {
return claim.resultDigest; // 已经执行过,直接复用结果,不重复产生副作用
}
const result = await execute();
await store.save({
...state,
version: state.version + 1,
sideEffects: [...state.sideEffects, { idempotencyKey, tool, resultDigest: result }],
});
return result;
}
状态与上下文的分工:断点里的 contextSkeleton 只保存摘要、关键实体与证据指针,不保存完整对话;需要原文时按 evidenceRefs 回查外部存储重建——这正是「上下文可重放近似、状态必须精确」的落地。
面试官视角:
考察意图: 考察候选人是否把 Agent 当作分布式可恢复系统来设计。高分回答必须能指出「沙箱状态不可信、状态必须外置」,并讲清断点粒度取舍、幂等键与乐观并发 / fencing token三件套,而不是停留在「把变量存进 Redis 就行」。
深度追问链:
- 追问 1: 断点应该存在步骤级还是工具调用级?
- 答题切入点: 看该阶段的副作用密度与重跑成本——写操作密集用工具调用级(幂等键兜底),纯推理用阶段级;并说明写入频率带来的 IO 与状态膨胀代价。
- 追问 2: 续跑时某个写操作超时了,对方到底有没有执行成功?
- 答题切入点: 这是幂等问题不是状态问题;请求级唯一 ID 落库去重,续跑先查幂等登记表再决定是否重发;超时属于「结果未知」而非「失败」,不能按失败直接重发。
- 追问 3: 旧实例没被回收干净还在写状态,怎么处理?
- 答题切入点: 用 epoch / fencing token,由存储侧校验并拒绝陈旧 epoch 的写入;租约到期只是让实例「失去资格」,真正的一致性保障在存储侧的校验上。
延伸阅读:
记忆和个性化:如何让 Agent 长期记住用户习惯,又不泄漏隐私?
答案
核心结论:
长期记忆的隐私问题不是靠检索时过滤解决的,而是靠写入时的准入策略解决的。五个必须同时落地的要点:
- 先分级,再决定要不要记:把候选记忆按敏感度分成三档——可长期保留的偏好(语言、单位制、常用工具、代码风格)、需显式授权才保留的(工作上下文、日程、涉及同事的描述)、禁止存储的(身份标识、凭证与密钥、健康与财务明细、密码)。禁止档必须在写入前被拦截,事后清理追不回已经流出的数据。
- 隔离靠存储层强制,不靠 prompt 提醒:用户级命名空间加租户边界,检索条件里强制拼上租户与用户标识;越权召回在索引与查询层就不成立,而不是指望模型「不要看别人的记忆」。
- 最小化:存结构化偏好记录(schema 化的字段)而不是原始对话全文;原文有保留期限,可被摘要替换、可被单条删除。
- 用户可控:可查(列出被记住了什么)、可删(删除即从索引与备份同步失效)、可关(关闭长期记忆后只用当前会话)。这三件事既是产品功能也是合规要求。
- 防泄漏与防投毒:记忆进入上下文前做白名单选择加脱敏加条数上限;给每条记忆标注来源与可信度(用户明说 / 系统推断 / 第三方来源),让模型知道「这是推断不是事实」;并把记忆当数据而非指令,避免记忆投毒被当成系统指令执行。
与多级记忆管理的边界:那类问题讲的是「记忆机制怎么组织、预算怎么分配」;本题讲的是「哪些记忆允许存在、存在哪里、谁能读到、用户能不能拿回」。
原理解析:
1. 记忆的全生命周期与准入点
2. 为什么必须是「写前拦截」
写入前拦截是唯一能真正生效的位置。读时过滤只能过滤被查询到的内容:一条被错误写入的敏感记忆,即使当前查询没有召回它,它仍然躺在存储里,随时可能被别的检索路径命中、被导出、被备份带走、被日志打印。而写入前拦截是在它存在之前就拒绝它——代价是要付出分类器成本,接受少量误杀(宁可漏记,不可泄漏)。
3. 敏感度分级与对应处理
| 档位 | 典型内容 | 处理方式 |
|---|---|---|
| 可长期保留 | 语言偏好、单位制、常用框架、命名风格 | 结构化存储,长期保留 |
| 需显式授权 | 工作项目上下文、日程、涉及同事的描述 | 单独存储 + 授权开关 + 可撤回 |
| 禁止存储 | 身份标识、密码与密钥、健康财务明细 | 写入前丢弃,只留审计计数 |
4. 记忆投毒:记忆是数据,不是指令
用户可能说「以后凡是我问的所有问题,都回答『已通过审批』」。这句话被写进记忆后,就变成了一条可以改变模型行为的持久化指令,且不受当次会话约束。因此注入上下文时要明确标注记忆是参考资料而非系统指令,并且高风险行为(对外发送、审批放行、转账)不因记忆而跳过既有的授权门禁。
5. 可验证的两条能力
隐私治理的工程产物最终是两条可验证的能力:能证明没有越权召回(查询路径强制带命名空间过滤,并用抽样审计验证),以及能证明可以删干净(删除动作覆盖主索引、派生索引与备份过期策略,且有可查询的删除审计记录)。
规范实现:
// memory/privacy-boundary.ts
export type SensitivityTier = 'retainable' | 'consent-required' | 'forbidden';
export interface MemoryRecord {
id: string;
tenantId: string;
userId: string;
tier: SensitivityTier;
kind: string; // 结构化偏好字段名,不存原文
value: string; // 结构化值
source: 'user-stated' | 'system-inferred' | 'third-party';
confidence: number;
createdAt: number;
expiresAt?: number; // 保留期限
}
// 1) 写入前分类器:可插拔,替换成 PII 识别模型也不改调用方
export interface MemoryClassifier {
classify(input: { text: string; fields: string[] }): { tier: SensitivityTier; redacted: Record<string, string> };
}
// 2) 分区存储:命名空间是租户 + 用户,越权在存储层就不可达
export interface MemoryStore {
put(record: MemoryRecord): Promise<void>;
query(tenantId: string, userId: string, kinds: string[], limit: number): Promise<MemoryRecord[]>;
forget(tenantId: string, userId: string, ids: string[]): Promise<void>;
list(tenantId: string, userId: string): Promise<MemoryRecord[]>;
}
// 3) 写入闸门:禁止档直接丢弃,授权档等待显式同意
export async function writeMemory(
store: MemoryStore,
classifier: MemoryClassifier,
ctx: { tenantId: string; userId: string; text: string; fields: string[] },
consent: (rec: MemoryRecord) => Promise<boolean>
): Promise<{ written: boolean; reason: string }> {
const { tier, redacted } = classifier.classify({ text: ctx.text, fields: ctx.fields });
if (tier === 'forbidden') {
audit('memory-write-rejected', { tenantId: ctx.tenantId, userId: ctx.userId, tier });
return { written: false, reason: 'forbidden-tier' };
}
const fields = Object.keys(redacted);
const record: MemoryRecord = {
id: newId(),
tenantId: ctx.tenantId,
userId: ctx.userId,
tier,
kind: fields[0] ?? 'unknown',
value: fields.length ? redacted[fields[0]] : '',
source: 'system-inferred',
confidence: 0.6,
createdAt: Date.now(),
expiresAt: Date.now() + 90 * 24 * 3600 * 1000,
};
if (tier === 'consent-required' && !(await consent(record))) {
return { written: false, reason: 'consent-denied' };
}
await store.put(record);
return { written: true, reason: tier };
}
// 4) 注入前的读取:强制命名空间过滤 + 脱敏 + 上限 + 来源标注
export async function buildMemoryContext(
store: MemoryStore,
ctx: { tenantId: string; userId: string; relevantKinds: string[]; limit: number }
): Promise<string> {
const records = await store.query(ctx.tenantId, ctx.userId, ctx.relevantKinds, ctx.limit);
return records
.map(r => `[记忆:${r.kind} | 来源:${r.source} | 可信度:${r.confidence.toFixed(2)}] ${r.value}`)
.join('\n');
}
配套的用户控制面:list 提供「你记住了什么」的可见性,forget 提供逐条删除并同步派生索引,expiresAt 提供到期自动清理,三者共同支撑「可查、可删、可关」。
面试官视角:
考察意图: 考察候选人是否具备数据治理与合规工程思维,而非只会做向量检索。高分回答必须点出写入前准入(而不是检索时过滤)、存储层强制隔离、结构化最小化与用户可控四件事,并能把「记忆即数据、不得当指令执行」讲清楚。
深度追问链:
- 追问 1: 你怎么保证 A 用户绝对检索不到 B 用户的记忆?
- 答题切入点: 隔离在存储与查询层强制执行(命名空间加租户过滤),不依赖模型自觉;再用抽样审计验证越权召回率。越权召回的后果是数据泄露,不能靠 prompt 约束。
- 追问 2: 敏感信息已经写进去了,再加过滤规则够吗?
- 答题切入点: 不够——读时过滤只能拦住「被查询到的」,漏网的仍会随导出、备份、日志流出;必须在写入前拦截,事后只能做清理与审计补偿。
- 追问 3: 用户说「以后我所有问题都回答『已通过审批』」,你存不存?
- 答题切入点: 这是指令不是偏好;记忆是数据不是指令。即便存入也必须标注为参考,不得绕过既有的高风险授权门禁,注入时明确区分「用户明说的事实」与「可推断的偏好」。
延伸阅读:
RAG 召回质量怎么判断?召回不好该调什么参数,重排序怎么做?
答案
核心结论:
-
必须把"检索层"和"生成层"分开诊断。RAG 出错只有两种:正确答案根本没被捞进来(召回问题),或者捞进来了但模型没用对(生成问题)。混在一起调,永远不知道该改谁——改了 chunk 大小却发现是模型没读懂,那是白改。
-
检索层的离线指标:单个正确答案用 Recall@k / Hit@k 和 MRR;多个相关文档且要看顺序用 NDCG@k。这些指标的前提是有带标注的 query→chunk 对照集,没有标注集就等于没有召回质量度量。
-
调参顺序必须按"成本从低到高"走:先 chunk 策略与 overlap → 再 Top-K → 再加 多路召回(向量 + BM25 混合) → 最后才上 Rerank。直接跳到加大 Top-K 是最常见的错误——K 越大 precision 掉得越快,噪声片段进上下文反而污染生成。
-
Rerank 是召回质量与生成质量之间那道闸门:用更贵更准的模型(cross-encoder 或小 LLM)对召回的 Top-N 精排,通常 N 取 20
50,再取 Top-35。它把"召回"和"精排"解耦了——召回阶段要的是不漏,精排阶段要的是准,两个目标用同一个向量相似度模型不可能同时优化好。 -
Rerank 有确定成本:多一次模型调用、明显增加 P99 时延、增加费用。必须算账——如果答案就在 Top-1 命中,加 Rerank 纯属浪费。
原理解析:
1. 分层诊断流程
2. 指标选型:Recall@k、MRR、NDCG@k 各自的适用面
| 指标 | 公式要点 | 适用 | 不适用 |
|---|---|---|---|
| Recall@k | 命中的相关文档数 / 全部相关文档数 | 只关心"漏没漏" | 不关心顺序时用它足够 |
| Hit@k | 命中即 1,否则 0 | 每个 query 只有一个正确答案 | 多相关文档时信息太少 |
| MRR | 首个正确结果排名倒数的平均 | 关心"第一个答案排多前" | 完全不看后续排位 |
| NDCG@k | 位置折扣增益 / 理想排序增益 | 多相关文档且要管顺序 | 没有分级相关性标注时无法使用 |
3. 为什么"直接加大 Top-K"是错的
向量检索的相似度分数分布不均匀。设 Top-1 分数 0.82、Top-5 落在 0.55~0.60(断层),此时把 K 从 5 提到 20,往下捞进来的全是 0.45 以下的噪声。判断依据是看分数曲线有没有断层:有断层就说明排序已经收敛,K 该收紧;曲线平缓才说明召回不足,需要扩 K 或换检索策略。
而正确片段的召回失败往往不是 K 的问题,是"压根不相似"——这正是混合检索(BM25 抓编号、专有名词、精确匹配 + 向量抓语义)存在的理由。工单号 BUG-2026-9901 这类精确串,向量余弦几乎必然失败。
4. Rerank 为什么比"调 chunk"更有效
向量检索是双塔结构:query 和文档各自独立编码,相似度算完就定,query 与文档之间没有交互。所以它快,但表达力受限。
Rerank 用的是交叉编码(cross-encoder):把 query 和候选文档拼在一起送进模型做联合编码,模型能直接做 token 级交互。代价是每个候选都要跑一次模型——所以只能对 Top-N 做,不能对全量做。
这就是"召回 + 精排"两级架构的逻辑:用便宜的向量模型捞 2050 个候选,再用贵的交叉模型精排到 35 个送进上下文。便宜负责不漏,昂贵负责准确。
5. 轮次怎么定
面试官常追问"重排做几轮"。实务上:
- 一轮是默认。多轮重排(第一轮排完再对 Top-5 重排一遍)在收益递减很快的同时时延线性叠加。
- 只有当第一轮重排的分数分布仍然不可信(比如 Top-5 分数挤在一起都在 0.7 附近)时,第二轮才可能分辨出来。
- 判据应是边际收益:第二轮带来的 NDCG 提升,是否值得多付一次调用的时延与费用。
6. 面试官真正想听的:排查顺序
拿到一个"RAG 答不准"的 case,先看检索结果,再看生成:
- 人工看 Top-K 里有没有正确片段 → 没有 = 召回问题(查 chunk、查 embedding、查混合检索);
- 有正确片段但答案错 → 生成问题(查上下文顺序、查提示、查模型);
- 答案里有正确信息但模型编了 → 忠实度问题(加引用约束)。
跳过第 1 步直接改提示词,是 RAG 调优里最昂贵的错误。
规范实现:
interface Chunk { id: string; text: string; kbId: string; metadata: Record<string, unknown> }
interface Retriever {
retrieve(query: string, topN: number): Promise<Array<Chunk & { score: number }>>
}
interface Reranker {
rerank(query: string, candidates: Array<Chunk & { score: number }>, topK: number): Promise<Array<Chunk & { score: number }>>
}
// 1) 混合召回:向量负责语义,BM25 负责编号与专有名词
export async function hybridRetrieve(
query: string,
vectorRetriever: Retriever,
bm25Retriever: Retriever,
topN: number
): Promise<Array<Chunk & { score: number }>> {
const [vec, lex] = await Promise.all([
vectorRetriever.retrieve(query, topN),
bm25Retriever.retrieve(query, topN),
])
const merged = new Map<string, Chunk & { score: number; vec: number; lex: number }>()
for (const c of vec) merged.set(c.id, { ...c, vec: c.score, lex: 0 })
for (const c of lex) {
const prev = merged.get(c.id)
merged.set(c.id, { ...c, vec: prev?.vec ?? 0, lex: c.score })
}
// 归一化后加权;权重必须用标注集调,不能拍脑袋
return [...merged.values()]
.map(c => ({ ...c, score: 0.6 * c.vec + 0.4 * c.lex }))
.sort((a, b) => b.score - a.score)
.slice(0, topN)
}
// 2) 召回质量评估:需要带标注的 query→chunk 对照集
export function recallAtK(retrieved: string[], relevant: Set<string>, k: number): number {
const top = retrieved.slice(0, k)
const hit = top.filter(id => relevant.has(id)).length
return relevant.size === 0 ? 0 : hit / relevant.size
}
// 3) 分数曲线断层检测——决定是收紧 K 还是扩召回
export function findScoreGap(scores: number[]): number | null {
for (let i = 1; i < scores.length; i++) {
const gap = scores[i - 1] - scores[i]
if (gap > 0.15) return gap
}
return null
}
// 4) 编排:召回 30 → Rerank 取 Top-5 → 注入
export async function retrieveAndRerank(
query: string,
retriever: Retriever,
reranker: Reranker
): Promise<Chunk[]> {
const candidates = await retriever.retrieve(query, 30)
const reranked = await reranker.rerank(query, candidates, 5)
return reranked.map(c => ({ id: c.id, text: c.text, kbId: c.kbId, metadata: c.metadata }))
}
面试官视角:
考察意图: 考察候选人是否具备分层诊断 RAG 的能力。高分回答必须包含:先分层再动手(召回 vs 生成 vs 忠实度)、调参按成本递增的顺序、"召回 + 精排"解耦的原理(双塔 vs 交叉编码)、Rerank 的轮次与成本账。直接说"调 chunk size 和 topK"是初级回答。
深度追问链:
- 追问 1: 怎么知道你的 Top-K 取多少合适?
- 答题切入点: 用带标注的对照集扫 K,看 Recall@K 的曲线拐点——加 K 已经提不动 Recall,说明天花板到了,瓶颈在 chunk 或 embedding 而不是 K。再看分数曲线有没有断层(Top-1 明显高、后面挤在一起),有断层就说明排序已收敛,K 该收紧。别用"感觉 5 挺好的"来定。
- 追问 2: 为什么不能直接用 rerank 模型做召回,为什么不干脆只用它?
- 答题切入点: cross-encoder 必须把 query 和文档拼起来联合编码,复杂度随候选数线性增长,对全量做既慢又贵;而且它过拟合于"精排"场景。所以架构上必须是便宜的双塔负责不漏、昂贵的交叉编码负责准确,两阶段各自优化自己的目标。
- 追问 3: 重排序会不会让时延预算爆掉?线上怎么控?
- 答题切入点: 会,Rerank 是 P99 的主要来源之一。控法:限候选数(20~50 而不是全量)、用小模型或蒸馏版做 Rerank、只在召回分数分布不可信时才重排、对高频 query 做结果缓存。同时必须把 Rerank 前后的端到端 P99 和单位问答成本一起纳入监控,否则单看"答案变好了"会掩盖成本失控。
延伸阅读:
企业知识问答要把 Word、PDF 灌进向量库,文档解析到入库的中间流程到底怎么走?
答案
核心结论:
-
「文档 → 向量库」是一条五段流水线:解析(Parse)→ 结构化(Structure)→ 清洗(Clean)→ 切分(Chunk)→ 嵌入与索引(Embed & Index)。每段有各自的失败模式,混在一起做就永远定位不了问题。多数"RAG 答不准"的工单,根因在第一段或第二段,却被误判成"模型不行"。
-
解析阶段就有两条完全不同的路:PDF 分有文本层和扫描件(纯图片)两种,前者直接抽字符,后者必须走 OCR;Word 走 OOXML 解析,段落、标题样式、表格都是结构化标签。双栏排版是最常见的坑——直接抽文本会把左右两栏串行交错,语义彻底错乱,而且错乱得很隐蔽:句子还通顺,只是逻辑对不上。
-
结构化阶段要恢复标题层级,并把元数据打全:
source / heading_path / page / version / owner随 chunk 一起存。元数据是后面能做过滤、溯源、权限隔离的唯一依据——没有它,检索出来的片段无法回答"出自哪份文档第几页"。 -
清洗阶段最容易被跳过,也最容易致命:去页眉页脚、去水印、去目录残留、合并被换行切断的句子(PDF 里一个句子断成三行会直接毁掉嵌入质量)、规范化空白。
-
切分阶段应以结构边界为主、固定字数为辅:按标题 / 段落切,overlap 只作兜底。按固定字符数硬切会把一个完整论证拦腰截断,检索时捞到半句话,生成时必然接不上。没有标题的 Markdown 才是真的难,得先做启发式标题识别。
-
索引阶段把 embedding 和元数据一起存:检索时先用元数据过滤(权限、时效、文档范围),再做向量检索,顺序不能反。
原理解析:
1. 五段流水线全貌
2. 每段的失败模式对照表
| 阶段 | 典型失败 | 症状 | 定位手段 |
|---|---|---|---|
| 解析 | 双栏交错、OCR 乱码、表格结构塌陷 | 抽出的句子语义错乱、字符错乱 | 人工抽 3~5 个片段肉眼读 |
| 结构化 | 标题层级丢失,退化为扁平文本 | 切分点全在句子中间 | 检查 heading_path 是否为空 |
| 清洗 | 页眉页脚混进正文、句子被换行切断 | 嵌入分数偏低、召回片段开头结尾莫名重复 | 看 chunk 首尾 50 字符 |
| 切分 | 定长硬切、overlap 过大或为零 | 召回片段读起来像半句话 | 看 chunk 边界落点 |
| 索引 | 元数据缺失、embedding 与文本不同步 | 查不到来源、过滤失效 | 抽样核对 metadata 与原文 |
3. 三个具体的坑,面试官最爱追问
坑一:双栏排版。 学术论文 PDF 极常见。逐行抽文本会得到「左栏第 1 行、右栏第 1 行、左栏第 2 行……」的交错序列,句子层面还通顺,段落层面完全错乱。识别方法很简单:抽出来的文本里,相邻两行若分属两栏(可通过 x 坐标聚类判定),就必须先按栏分组、再按栏内顺序拼接。
坑二:扫描件。 图片型 PDF 抽出来是空字符串或乱码。判据是抽取字符数与页数的比值异常低。此时必须走 OCR(云端 OCR 服务或本地 OCR 引擎),而且要接受 OCR 的错误率会直接传递到检索质量——所以扫描件文档的召回质量基线天然低于原生文本文档,不能用同一套指标横向比较。
坑三:断行。 PDF 的文字流是按绘制顺序存储的,一个完整句子可能被拆到三行。入库前不做换行合并,嵌入模型拿到的是带换行的碎片,语义被切断,相似度自然下降。这一步很土但收益极大。
4. 结构感知切分:为什么优于定长切分
定长切分的隐含假设是"文本是均匀的语义流",但真实文档是层级化的——章节标题、段落、列表项、表格各有语义单位。正确做法是:
- 以标题为切分单元:一个 chunk 尽量对应一个完整小节,标题路径(
第 3 章 > 3.2 节 > 3.2.1 小节)写进 metadata; - 超大段落再按句子二次切分,句子边界用句号/分号判断,不按字符数;
- overlap 只在段落级生效,且比例要小(片段级重叠大,对 embedding 反而是噪声放大)。
5. 与既有题目的边界
本库另有两题覆盖相邻环节,边界是这样的:
p0-rag-frontend-knowledge-base讲的是浏览器端的 RAG 落地——分块算法、端侧轻量嵌入、余弦相似度检索的工程实现;p0-coding-rag-cosine-search是一道手写实现题——递归文档切分器 + 余弦相似度检索器的编码;- 本题讲的是企业级离线入库流水线——从 Word/PDF 到向量库的解析、结构化、清洗、切分、索引全链路,重点在"解析层"和"元数据契约",这两块在前两题里是被略过的。
6. 增量更新:企业知识库一定会变
文档会改版、会被删。只做全量重建的流水线在第一次全量之后就没法用了。工程上要保留内容指纹(源文件 hash + 段落级 hash),按段落粒度做增量:只对变化段落重新嵌入,未变的直接复用向量;删除文档时同步删向量与元数据。只做整文件 hash 的话,改一个错别字就要重嵌整份文档,成本上不可接受。
规范实现:
interface ParsedBlock {
kind: 'heading' | 'paragraph' | 'list' | 'table' | 'ocr'
text: string
level?: number
page?: number
bbox?: { x0: number; y0: number; x1: number; y1: number }
}
interface Chunk {
id: string
text: string
headingPath: string[]
metadata: {
source: string
page?: number
version: string
owner: string
contentHash: string
}
}
// ① 解析:PDF 优先走文本层,字符数异常低则判定为扫描件转 OCR
export async function parseDocument(input: Blob, opts: { ocr: (img: Blob) => Promise<string> }): Promise<ParsedBlock[]> {
const pdf = await parsePdfBlocks(input) // 返回带 bbox 的块
const chars = pdf.reduce((s, b) => s + b.text.length, 0)
if (chars < 50 * pdf.length) {
return await ocrPages(input, opts.ocr) // 扫描件:接受更高的错误率基线
}
return mergeColumns(pdf) // 双栏:按 x 坐标聚类分栏,栏内排序后再拼接
}
// ② 结构化:由字号/样式推断标题层级,产出章节树
export function buildHeadingPath(blocks: ParsedBlock[]): Array<ParsedBlock & { headingPath: string[] }> {
const path: string[] = []
return blocks.map(b => {
if (b.kind === 'heading') {
path.length = (b.level ?? 1) - 1
path[path.length] = b.text
}
return { ...b, headingPath: path.filter(Boolean).slice() }
})
}
// ③ 清洗:去页眉页脚 + 合并断行 + 规范化空白
export function cleanText(text: string, headerNoise: RegExp): string {
return text
.split('\n')
.filter(line => !headerNoise.test(line.trim()))
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
// ④ 切分:先按结构边界,超长段落再按句子二次切
export function chunkByStructure(blocks: Array<ParsedBlock & { headingPath: string[] }>, maxChars: number): Chunk[] {
const out: Chunk[] = []
for (const b of blocks) {
if (b.text.length <= maxChars) {
out.push(toChunk(b))
continue
}
const sentences = b.text.split(/(?<=[。!?.!?])\s*/)
let buffer = ''
for (const s of sentences) {
if ((buffer + s).length > maxChars && buffer) {
out.push(toChunk({ ...b, text: buffer }))
buffer = ''
}
buffer += s
}
if (buffer) out.push(toChunk({ ...b, text: buffer }))
}
return out
}
// ⑤ 索引:embedding 与 metadata 一起存,并保留内容指纹供增量更新
export async function indexChunks(chunks: Chunk[], embed: (t: string) => Promise<number[]>, store: ChunkStore) {
for (const c of chunks) {
const hash = contentHash(c.text)
const existing = await store.get(c.id)
if (existing?.metadata.contentHash === hash) continue // 未变化则复用向量
await store.put({ ...c, vector: await embed(c.text) })
}
}
面试官视角:
考察意图: 考察候选人是否理解RAG 的质量瓶颈在"入库流水线"而不在"生成"。高分回答必须能讲出五段流水线、至少两个具体的解析坑(双栏 / 扫描件 / 断行)、元数据契约的作用,以及增量更新的粒度选择。只说"用 LangChain 切块 + 存向量库"是初级回答。
深度追问链:
- 追问 1: 用户说"文档里的内容明明在里面,问就是答不出来",你按什么顺序查?
- 答题切入点: 先验证入库质量再看生成——把检索到的 Top-K 片段原文打出来给用户看(这是最省事也最有效的动作)。如果 Top-K 里根本没有答案,是召回/入库问题(回查 chunk 边界、元数据过滤是否把它误过滤掉);如果 Top-K 里有答案但回答没用上,是生成问题(上下文顺序、提示、忠实度)。先打片段,再谈改模型。
- 追问 2: 文档没有标题层级,怎么切?
- 答题切入点: 先做启发式标题识别——短行、独占成段、无句末标点、字号或加粗样式显著、编号模式(
1./一、/第 X 章)——综合打分,置信度高的当标题;再按段落切并把启发式标题写进 heading_path。同时要保留"按定长兜底"的通道,因为启发式一定会有误判。更重要的是:主动要求业务方补结构,或者用 LLM 做一次标题抽取(成本远低于事后排查召回质量)。
- 答题切入点: 先做启发式标题识别——短行、独占成段、无句末标点、字号或加粗样式显著、编号模式(
- 追问 3: 文档每周都在改,索引怎么维护?
- 答题切入点: 做增量,不做全量。用段落级内容指纹(源文件 hash + 段落 hash)定位变化,只对变化段落重新嵌入与更新向量;删除要同步清理向量与元数据。只做整文件 hash 的话,改一个错别字就要重嵌整份文档。另外要能回滚到指定版本——企业知识库的"当时是哪个版本说的"经常是合规问题。
延伸阅读:
向量检索上亿条数据时,HNSW、DiskANN 这类图索引是怎么工作的?内存和 SSD 怎么取舍?
答案
核心结论:
向量检索在千万到十亿量级上不可能做精确最近邻(那是一次全表扫描),所以默认解法是 ANN(近似最近邻):牺牲一点召回率,换取亚线性的查询延迟。索引分两大家族:
- 倒排类(IVF 系):先用聚类把向量空间切成若干个桶,查询时只扫其中几个桶。优点是结构简单、可压缩;缺点是桶边界处的召回会掉。
- 图索引类:把向量组织成一张可导航图,查询从入口点贪心游走到最近邻。代表是 HNSW 与 NSG/Vamana。
- HNSW(arXiv:1603.09320,2016-03-30)的核心是分层可导航小世界图:上层稀疏、跨距大,负责长距离跳转;下层稠密、负责精细定位,查询从顶层入口逐层下降。建图质量由
M(每点出边数)与efConstruction决定,查询时的候选队列宽度由efSearch决定——这三个参数是 ANN 调优的主战场。 - DiskANN(NeurIPS 2019)解决的是另一个问题:图索引根本放不进内存。它把图与向量放在 SSD 上,查询时用少量 DRAM 缓存 + beam search 在磁盘页上做图遍历。论文报告在单机 64GB 内存 + SSD上完成十亿点级别的索引与检索,并称其图索引的节点密度(points per node)比 HNSW 与 NSG 高 5~10 倍;它引入的图构建算法名为 Vamana。
- HNSW(arXiv:1603.09320,2016-03-30)的核心是分层可导航小世界图:上层稀疏、跨距大,负责长距离跳转;下层稠密、负责精细定位,查询从顶层入口逐层下降。建图质量由
- 今天的产品化路线就是这条:Redis 的向量检索支持 FLAT / HNSW / SVS-VAMANA 三种索引类型,SVS-VAMANA 即 Intel 的 Scalable Vector Search(基于 Vamana 图)。
原理解析:
1. 为什么"图"比"倒排"更适合高维向量
在高维空间里,欧氏距离的三角不等式近似失效,"离得近的向量也大致同方向"这类直觉不再可靠,倒排桶的边界极易切错邻居。而图索引把"邻近性"显式地固化进图的边里——只要建图阶段保证每个点的出边覆盖了它的真近邻,查询时沿边贪心游走就能收敛到高质量结果。
2. 两条工程路线各自的代价
| 路线 | 代表 | 内存占用 | 查询延迟 | 典型场景 |
|---|---|---|---|---|
| 全内存图索引 | HNSW | 高(索引常比原始向量还大) | 最低 | 百万级、内存充裕 |
| SSD 图索引 | DiskANN / Vamana | 低(只有热页在 DRAM) | 受随机读延迟约束 | 十亿级、单机能扛 |
| 倒排分桶 | IVF 系 | 中 | 中 | 批量检索、可容忍一定召回损失 |
3. 磁盘方案的胜负手:点密度与批量读
DiskANN 的关键不是"把索引放磁盘"这么简单——随机 IO 是 SSD 的天敌。它用两招换吞吐:一是提高每个节点的点数密度(一个节点上存更多向量,让一次磁盘读服务更多查询),二是把一次查询的多条候选访问在磁盘上聚成顺序读(beam search 的束宽越大,批处理收益越高)。这就是论文强调"points per node"这个指标的原因。
4. 与本库既有考点的边界
- Embedding 题讲的是"向量怎么表示、相似度怎么算"——是表征问题;本题讲的是"这么多向量怎么组织成可查询的结构"——是索引结构问题。
- RAG 召回质量题讲的是"召回不准该调什么参数、怎么 rerank"——是检索质量诊断;本题讲的是"索引本身怎么建、怎么选"。
规范实现:
// 概念骨架:把「索引类型选择」抽象成一份可配置的检索策略
type IndexKind = 'flat' | 'ivf' | 'hnsw' | 'svs-vamana';
interface IndexProfile {
kind: IndexKind;
// HNSW 系参数:建图质量 vs 查询召回 vs 内存的三角
m?: number; // 每点出边数,大则召回高、内存大
efConstruction?: number; // 建图时候选队列宽度,大则图质量高、建图慢
efSearch?: number; // 查询时候选队列宽度,大则召回高、延迟高
// 磁盘型索引
residentRatio?: number; // 常驻内存比例:SSD 索引靠它换命中率
beamWidth?: number; // 磁盘图遍历的束宽,大则顺序读收益高
}
interface SearchDecision {
recallTarget: number; // 业务能接受的最低召回率
latencyBudgetMs: number; // 单次检索的延迟上限
corpusSize: number; // 语料规模
}
// 选型判据(简化):规模与延迟预算共同决定索引形态
function chooseIndex(d: SearchDecision): IndexProfile {
if (d.corpusSize < 1_000_000) {
return { kind: 'hnsw', m: 16, efConstruction: 200, efSearch: 64 };
}
if (d.latencyBudgetMs > 200) {
// 离线/批量检索:容忍更高延迟,换吞吐
return { kind: 'svs-vamana', residentRatio: 0.2, beamWidth: 32 };
}
// 千万级以上 + 在线低延迟:只能全内存图索引 + 加机器
return { kind: 'hnsw', m: 32, efConstruction: 400, efSearch: 128 };
}
面试官视角:
- 必答:① 说清 ANN 是"牺牲召回换延迟";② 说清 HNSW 的分层贪心下降机制;③ 说清 DiskANN 解决的是内存装不下而不是"比 HNSW 更快";④ 说清
M/efConstruction/efSearch各自的作用方向。 - 加分项:能讲出 DiskANN 的 NeurIPS'19 论文原标题在 proceedings 里是 "Rand|NSG: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node"、论文首页标题才是 DiskANN 这处细节;能解释磁盘方案为什么把"points per node"当核心指标;能指出 SVS-VAMANA 已是 Redis 的内置索引选项。
- 常见误区:说 DiskANN"比 HNSW 快"(它换来的是可扩展性,不是速度);说图索引"用空间换时间所以比倒排好"(高维下倒排的召回衰减才是主因);把
efSearch说成建图参数;说倒排类(IVF)"一定比分桶快"(它反而更容易漏邻居)。
延伸阅读:
- Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs(HNSW,arXiv:1603.09320,2016-03-30)
- DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node(NeurIPS 2019 论文 PDF;proceedings 条目标题为 Rand|NSG)
- microsoft/DiskANN 官方仓库(DiskANN3 可组合向量索引库与各 Provider 实现)
- Redis 官方文档:向量与相似性检索(FLAT / HNSW / SVS-VAMANA 索引类型)
vLLM 是怎么把推理吞吐做上去的?PagedAttention 和 Continuous Batching 分别解决了什么瓶颈?
答案
核心结论:
-
传统推理服务的两个硬瓶颈:
- KV Cache 显存被浪费:每个请求的 KV Cache 大小随生成长度动态增长,传统做法按「最大可能长度」连续预留,碎片 + 冗余预留吃掉了大部分显存,能并发的请求数因此被卡死;
- 批处理僵化:静态批处理要等整批里最长的那条跑完才返回,批内短序列的请求白白排队。
-
PagedAttention(论文 arXiv:2309.06180,2023-09-12)借鉴操作系统的虚拟内存与分页:把 KV Cache 切成固定大小的 block,用一张「块表」把逻辑序列映射到物理上不连续的块,从而做到 KV Cache 显存的近零浪费,并且支持请求内与请求之间共享 KV Cache(同一段前缀只算一次)。论文报告:相比当时的先进系统(FasterTransformer、Orca),在同等延迟下吞吐提升 2~4 倍,且序列越长、模型越大、译码算法越复杂,提升越明显(这是论文给出的官方数字,需按自身负载复现)。
-
Continuous Batching(连续批处理):不等整批结束。新请求在下一个调度步就插入到空出的槽位,已完成的序列立刻退出并释放 KV Cache。它的思想源头是 Orca(USENIX OSDI '22)提出的迭代级调度。
-
对前端的直接含义:推理侧指标必须拆成 TTFT(首字时延) 与 TPOT / ITL(字间时延) 分别做 SLA。吞吐是服务端并发能力,不等于单请求更快——连续批处理提升的是「同一台机器能同时服务多少用户」,不是「一个用户等得更短」。
-
边界必须说清:
- PagedAttention 是显存管理优化,不改变注意力计算本身,也不改变模型输出;
- Continuous Batching 提升的是吞吐,不降低单请求延迟,低并发下还可能因调度开销略增;
- 「2~4 倍」是论文在特定模型、硬件与批配置下的数字,不是通用结论;
- 显存墙没解决前,吞吐提升很快会撞到 KV Cache 总量上限,此时该换的是量化解码或分片,而不是继续调批。
原理解析:
1. KV Cache 为什么会浪费
2. 静态批处理 vs 连续批处理的时间轴
3. 三个技术的职责边界
| 技术 | 解决什么 | 不解决什么 |
|---|---|---|
| PagedAttention | KV Cache 显存浪费与碎片 | 不提升单请求速度,不改模型精度 |
| 共享前缀 / 写时复制 | 重复前缀的重复计算 | 不解决请求间的调度僵化 |
| Continuous Batching | 批内等待与槽位空转 | 不降低单请求延迟,不突破显存墙 |
4. TTFT 与 TPOT 的分解
一条流式响应的用户感知时延是 TTFT + TPOT × (输出 token 数 - 1)。前者由排队 + prefill 决定,后者由decode 阶段每步耗时决定。吞吐优化主要改善前者的排队部分;只有减少单步 decode 耗时才能改善 TPOT——这正是量化与投机解码要解决的问题,与分页、批处理是两条正交的优化轴。
规范实现:
下面用最小实现演示连续批处理的「补位」语义,以及 TTFT / TPOT 的拆分口径:
// scheduler.ts —— 连续批处理调度器(最小可运行模型)
export interface Request {
id: string
arriveAt: number
totalTokens: number
firstTokenAt?: number
finishAt?: number
}
export interface ScheduleStep {
step: number
running: string[]
admitted: string[]
finished: string[]
}
export class ContinuousBatchingScheduler {
private pending: Request[] = []
private running = new Map<string, Request>()
private emitted = new Map<string, number>()
readonly steps: ScheduleStep[] = []
constructor(private readonly maxConcurrency: number) {}
enqueue(r: Request) {
this.pending.push(r)
}
tick(now: number): ScheduleStep {
// 1) 补位:新请求立刻进运行集,不等整批结束
const admitted: string[] = []
while (this.pending.length > 0 && this.running.size < this.maxConcurrency) {
const r = this.pending.shift()!
this.running.set(r.id, r)
this.emitted.set(r.id, 1)
r.firstTokenAt = now
admitted.push(r.id)
}
// 2) 推进:在跑的请求本步各产出一个 token
const finished: string[] = []
for (const [id, r] of this.running) {
const n = (this.emitted.get(id) ?? 0) + 1
this.emitted.set(id, n)
if (n >= r.totalTokens) {
r.finishAt = now
finished.push(id)
this.running.delete(id) // 立刻释放槽位
}
}
const step: ScheduleStep = {
step: this.steps.length,
running: [...this.running.keys()],
admitted,
finished
}
this.steps.push(step)
return step
}
}
// 指标拆分:TTFT 与 TPOT 必须分开统计,不能合并成一个「延迟」
export function latencyMetrics(r: Request) {
const ttft = (r.firstTokenAt ?? 0) - r.arriveAt
const tpot = (r.finishAt ?? 0) - (r.firstTokenAt ?? 0)
return { ttft, tpot, total: (r.finishAt ?? 0) - r.arriveAt }
}
关键在 tick 的第一步:槽位空出来就补,不看批次边界。静态批处理把「批次」当作调度单位,连续批处理把「调度步」当作调度单位——这一字之差就是吞吐差距的来源。
面试官视角:
考察意图: 区分「听说过 vLLM」与「能说清它到底优化了哪一环、没优化哪一环」。这题在 AI 基础设施与 AI 应用工程岗都常见,高级候选人必须能指出吞吐提升 ≠ 延迟下降这个最容易说错的点。
评分 Rubric:
- 必答:能指出 KV Cache 显存浪费与批处理僵化这两个瓶颈;能说明 PagedAttention 是分页思想的迁移;能区分吞吐与延迟。
- 加分项:能说清 TTFT 与 TPOT 的分解;知道共享前缀与写时复制的作用;能指出论文 2~4 倍数字的适用边界。
- 常见误区:说「vLLM 让模型生成更快」;把 PagedAttention 说成「一种注意力算法变体」;认为连续批处理会降低单请求延迟。
深度追问链:
- 追问 1:你们的 Agent 服务 P95 首字时延高、但 GPU 利用率不满,问题出在哪?
- 答题切入点:先分清是排队(TTFT 的排队段)还是 decode(TPOT)——把 TTFT 拆成「排队时间 + prefill 时间」再看;利用率不满且并发上不去,优先查 KV Cache 显存预算与调度策略,而不是加机器。
- 追问 2:为什么说「PagedAttention 不改变模型输出」?
- 答题切入点:它只改变 KV Cache 的存放方式与寻址方式,注意力计算读的数值完全一致;因此是纯工程优化,可用固定 seed 对比输出一致性来验证。
- 追问 3:吞吐继续提升时撞到墙,你会往哪个方向走?
- 答题切入点:先算 KV Cache 的总量预算是否已成为瓶颈;若是,方向是量化(缩短每 token 的 KV 字节数)、KV Cache 量化/卸载、或前缀缓存复用,而不是继续加并发。
延伸阅读:
FlashAttention 为什么能把注意力算得快?它说的「IO 感知」到底指什么?
答案
核心结论:
FlashAttention 是精确(exact)注意力——它算出来的结果与标准注意力在数学上完全一致,不做任何稀疏化或近似;它改的是计算顺序与访存路径:
- 瓶颈定位错了就会答错:标准注意力慢,不是因为 FLOPs 不够,而是因为它 IO-bound(访存受限)。
QKᵀ这一步是计算密集的,但接着的softmax和PV需要把一个 N×N 的注意力矩阵先写回 HBM(高带宽显存)、再读回来,而 HBM 带宽比片上 SRAM 低一到两个数量级。 - 解法是 tiling + SRAM:把 Q/K/V 切成块,一次只把一个块搬进片上 SRAM,在 SRAM 内一次性算完
QKᵀ → softmax → PV,N×N 的中间矩阵根本不落 HBM;反向传播时按块重计算(recomputation)中间量,而不是把它存下来。 - 两个可量化的收益:显存占用从 O(N²) 降到 O(N)(相对标准注意力),HBM 访问次数显著减少,且在一组 SRAM 容量下该 IO 复杂度是最优的。
- 两代论文:FlashAttention(arXiv:2205.14135,2022-05-27)与 FlashAttention-2(arXiv:2307.08691,2023-07-17)。
原理解析:
1. 为什么标准注意力是 IO-bound 而不是 compute-bound
标准注意力的访存量随序列长度按 O(N²) 增长,而 GPU 的算力增长远快于 HBM 带宽增长——算力被饿死在带宽上。FlashAttention 的核心洞察就是:把整个注意力计算搬到 SRAM 这一层去做,让 HBM 只承担"搬块进、搬结果出"。
2. FlashAttention-2 改了什么
FA-1 虽然已经拿到了 IO 优势,但它只跑到峰值 FLOPs 的 25%~40%——论文归因于两点:线程块之间的工作划分不合理,以及 softmax 里的非矩阵乘操作(逐元素 rescale)占比过高,导致 GPU 流水线填不满。FA-2 重新划分了工作分配、把 softmax 与矩阵乘的并行度对齐,并把并行维度上移到 head 级别,论文报告相对优化后的基线有 2~4 倍加速。
3. 与本库既有考点的边界
- FlashAttention(算得更省) ≠ PagedAttention / vLLM(把 KV 存得更省):前者优化的是单次注意力计算内的访存路径,后者优化的是推理引擎的显存分页与批处理调度。两者常被并列提及,但解决的瓶颈不同。
- FlashAttention(exact) ≠ 各类稀疏注意力 / 线性注意力(近似):后者改数学结果换速度,FlashAttention 不改。
规范实现:
// 概念骨架:FlashAttention 的分块主循环在 GPU kernel 里,
// 这里用 TS 表达「分块 + 片上暂存 + 重计算」三条不变式
interface TileConfig {
blockSizeQ: number; // 每个 tile 装多少行 Q
blockSizeKV: number; // 每个 tile 装多少行 K/V
smemCapacity: number; // 片上 SRAM 预算(字节)
}
interface TileAccumulator {
runningMax: Float32Array; // 在线 softmax 的行最大值 m
runningSum: Float32Array; // 在线 softmax 的行和 l
output: Float32Array; // 累积的 O = sum(p * V)
}
// 关键不变式:任意时刻 HBM 上只保留 [Q 全量] + [当前 K/V tile] + [O],
// N×N 的注意力矩阵永不落盘
function attentionTileLoop(
cfg: TileConfig,
q: Float32Array,
k: Float32Array,
v: Float32Array,
acc: TileAccumulator
): TileAccumulator {
for (let kvStart = 0; kvStart < k.length; kvStart += cfg.blockSizeKV) {
const kvTile = loadKVTileToSmem(k, v, kvStart, cfg.blockSizeKV); // 唯一一次 HBM 读
for (let qStart = 0; qStart < q.length; qStart += cfg.blockSizeQ) {
const qTile = loadQTileToSmem(q, qStart, cfg.blockSizeQ);
const scores = matmul(qTile, kvTile.K); // 只在 SRAM 内
const p = onlineSoftmaxUpdate(scores, acc, qStart); // 在线更新 m / l
acc.output = addScaled(acc.output, matmul(p, kvTile.V));
}
// 反向传播时:不保存 p,而是用 q / k / v 重新算一遍 scores(recomputation)
}
return acc;
}
面试官视角:
- 必答:① 指出瓶颈是 IO/访存受限而非算力不足;② 说清 tiling + SRAM 的核心是不让 N×N 中间矩阵落 HBM;③ 强调它是精确算法,不改变结果;④ 说清显存从 O(N²) 降到 O(N)。
- 加分项:能报出 FA-1 只跑到峰值 FLOPs 的 25%~40% 这一数字,并解释 FA-2 的工作划分改进;能讲清反向传播用重计算换显存而不是保存中间量。
- 常见误区:把 FlashAttention 说成"稀疏注意力/近似注意力"(它 exact);说"它是把注意力从 GPU 挪到 CPU"(恰恰相反,是把计算往上层挪到 SRAM);把 PagedAttention 和 FlashAttention 混为一谈(一个管 KV 怎么存,一个管注意力怎么算)。
延伸阅读:
RAG 流程中为什么要引入父子索引?「小块检索、大块喂给模型」是怎么权衡的?
答案
核心结论:
-
父子索引解决的是一个两难:切块小,向量语义集中、检索精准,但送进模型的上下文残缺(丢了标题、表格头、承接句);切块大,上下文完整,但一个长块的向量是多个主题的平均,余弦相似度被摊平,精确匹配反而召不回。父子索引(Parent-Child Index,也称 small-to-big / retrieve-by-child-return-parent)把「用于匹配」和「用于阅读」这两件事解耦:子块小而专,只它被向量化入库;父块大而全,不入索引,只在被检索到后整段送给模型。
-
机制拆解:
- 建索引时按语义边界切出子块(child node),每个子块保留指向父块(parent node)的指针;入库的只有子块;
- 查询时按语义命中子块;
- 送给模型的却是子块所属的父块全文(或父块中与该子块对应的更大片段)。
- LlamaIndex 的 Node Parser 正是这个抽象的官方实现:「Node parsers 把文档切成 Node 对象,每个 Node 是父文档的一个特定分块;当文档被拆成节点时,它的全部属性(metadata、正文、metadata 模板等)会继承给子节点」——子节点继承父节点的 metadata,因此「这段话属于哪张表的哪一行、属于哪个小节」这类信息不会随切块丢失。
-
父子索引顺带解决了「切块后失去上下文归属」的问题:章节标题、表格标题、图表说明等通常挂在父块上,子块通过继承拿到。这比在切块时手工拼接标题更稳,因为它是结构化继承,不依赖切分策略是否恰好把标题切进来了。
-
必须说清的代价与边界:
- 索引只存子块,所以向量库体积不变甚至更小,父块不占检索成本;
- 上下文会变大:父块可能远大于子块,Top-K 命中若集中在同一父块,需要去重合并,否则同一段内容被重复塞进上下文;
- 可以再套一层(多层父子:section → subsection → chunk),但层级越多,父块越大、上下文越冗;
- 它不改变召回的语义能力——检索精度仍由 embedding 模型与切分质量决定,它只保证「召回来之后给模型看的东西是完整的」;
- 表格类内容要额外处理:父块应当覆盖整张表,否则子块只拿到半张表,嵌入出来的是碎片语义。
原理解析:
1. 为什么单一切块粒度必然二选一
2. 父子索引的数据流
3. 与已有考点的边界
| 考点 | 解决什么 | 与父子索引的关系 |
|---|---|---|
| 切块策略与 overlap | 块边界怎么切、重叠多少 | 父子索引建立在切块之上,先切子块 |
| 召回质量诊断 / Rerank | 召回得准不准、排序准不准 | 父子索引不提升召回精度,只保证召回后的阅读完整性 |
| 文档解析入库流水线 | 怎么把 Word/PDF 变成结构化文本 | 父子索引关心切完之后块与块的父子关系怎么建 |
| 多路召回(向量 + BM25) | 召回阶段不漏 | 互补:多路召回解决「召不回」,父子索引解决「召回了但看不完整」 |
规范实现:
下面是最小可用的父子索引实现:切块时同时产出父块与子块,只有子块入库,检索命中后按父指针取回父块并去重。
interface Chunk {
id: string
parentId: string
text: string
// 子块继承父块的 metadata:section、tableTitle、rowRange 等
meta: Record<string, string>
}
interface ParentChunk {
id: string
text: string
meta: Record<string, string>
}
/** 按语义边界切子块,并挂到父块上;父块是「章节 / 整表」这种更大的单位 */
export function buildParentChild (doc: {
sections: Array<{ title: string; body: string; table?: { title: string; rows: string[] } }>
}): { parents: ParentChunk[]; children: Chunk[] } {
const parents: ParentChunk[] = []
const children: Chunk[] = []
for (const [si, sec] of doc.sections.entries()) {
const parentId = `p-${si}`
const tableText = sec.table
? `${sec.table.title}\n${sec.table.rows.join('\n')}`
: ''
parents.push({
id: parentId,
text: `${sec.title}\n${sec.body}${tableText ? '\n' + tableText : ''}`,
meta: { section: sec.title, tableTitle: sec.table?.title ?? '' }
})
// 表格整体作为父块,不切碎:碎块嵌出来的是无语义的碎片
if (sec.table) {
children.push({
id: `c-${si}-table`,
parentId,
text: tableText,
meta: { section: sec.title, tableTitle: sec.table.title }
})
continue
}
// 正文按句子边界切子块,子块继承父块 metadata
let idx = 0
for (const block of sec.body.split(/(?<=[。!?;\n])/)) {
const t = block.trim()
if (!t) continue
children.push({
id: `c-${si}-${idx++}`,
parentId,
text: t,
meta: { section: sec.title } // 继承父块元信息
})
}
}
return { parents, children }
}
/** 检索命中子块后,取回父块并按父 id 去重,避免同一父块重复进上下文 */
export function toParentContext (
hits: Chunk[],
parents: Map<string, ParentChunk>,
maxParents = 3
): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const h of hits) {
if (seen.has(h.parentId)) continue
seen.add(h.parentId)
const p = parents.get(h.parentId)
if (p) out.push(p.text)
if (out.length >= maxParents) break
}
return out
}
关键在 toParentContext 的按父去重:如果 Top-K 的多个子块都来自同一个父块,不去重就会把同一段文字重复喂给模型,白白消耗上下文预算,还可能放大某段内容的权重。
面试官视角:
考察意图: 考察候选人是否理解**「检索粒度」与「阅读粒度」可以不同**。这题在 RAG 工程岗很常见,高分回答必须能说清父子索引只建子块索引、命中后返回父块,并能指出去重与表格不可碎切这两个实践细节。把父子索引说成「就是切大块」是最常见的失分点。
评分 Rubric:
- 必答:子块向量化入库、父块只做阅读;子块通过继承拿到父块 metadata;命中后取父块;能讲清它与「调 Top-K」「加 Rerank」不是一回事。
- 加分项:知道要按父去重;知道表格要保持完整不碎切;能说清多层父子与上下文膨胀的权衡;知道它不提升召回精度。
- 常见误区:说成「把块切大一点就行」;说成「先召回大块再切」(方向反了);忽略去重导致同一父块重复进上下文。
深度追问链:
- 追问 1:父块比子块大很多,Top-5 命中的父块塞进上下文后超预算了怎么办?
- 答题切入点:先按父去重再统计预算;然后考虑「中间层」——在父与子之间加一层中等粒度,或者对超长父块做二次定位(只取父块中与命中小节对应的片段),而不是简单截断。
- 追问 2:父块里有一半内容跟用户问题完全无关,全量喂给模型是不是浪费?
- 答题切入点:是的,这是父子索引的固有成本;缓解手段是命中子块后用位置元数据(前后邻居 / 章节偏移)做局部窗口裁剪,保留「命中点 + 必要上下文」而不是整段父块。
- 追问 3:这套结构对你的召回指标有提升吗?
- 答题切入点:召回率与排序指标基本不变,因为索引里还是子块、匹配还是子块;提升的是生成端的可读性与答案完整率——衡量它要看「答案是否因缺上下文而答错」,而不是看 hit rate。指标口径要跟着换。
延伸阅读:
Agent 的记忆怎么持久化?会话级、任务级、项目级怎么分层?提取和冲突更新怎么处理?
答案
核心结论:
「Agent 的记忆」不是一块向量库,而是分层、分生命周期、分写入策略的持久化体系。工程上至少要切出三层,因为三层的生命周期、真相来源与冲突规则完全不同:
| 层 | 存什么 | 生命周期 | 真相来源 | 典型存储 |
|---|---|---|---|---|
| 会话级(Session) | 当前交互的消息、临时工具结果、滚动摘要 | 单次会话,结束后可归档 | 原始对话流 | 关系库 / 缓存,热数据 |
| 任务级(Task / Working State) | 一次任务的目标、计划、已改文件、测试结果、失败原因 | 任务生命周期,跨轮次存活 | 执行轨迹 | 关系库 + 对象存储(大产物外置) |
| 项目级(Project / Codebase) | 仓库编码规范、模块职责说明、历史坑位、团队约定 | 长期,跨会话跨任务复用 | 人工维护 + 审核 | 规则文件 / 结构化表 / 向量库 |
三条比「怎么存」更重要的规则:
- 提取(Extract)要有门槛,不能全记:候选记忆先过去重与归一(同一事实的不同表述合并)、再过准入分级(哪些可长期保留、哪些必须显式授权),最后才写入。没有门槛的提取等于把上下文噪声永久化——一次错误推断被写进项目级记忆,后面所有会话都会被它污染。
- 冲突更新(Conflict Update)不能静默覆盖:当新事实与既有记忆矛盾(如「用 pnpm」变成「改用 npm」),必须有裁决规则——时间戳优先、显式声明优先、来源权威度优先——并把变更留痕(旧值 + 新值 + 变更原因 + 操作者)。静默覆盖是记忆系统最危险的缺陷:它让 Agent 在没有报错的情况下持续给出过期结论。
- 可追溯与可撤销:每条记忆要能回答「这条是谁在什么时候基于什么写进来的」。做不到就等于给 Agent 装了一个无法审计的黑箱。
原理解析:
1. 三层记忆与「上下文预算」的关系
- 会话级 → 任务级是「把当前对话中产生的可执行状态抽出来」,抽的是目标与进展,不是聊天内容;
- 任务级 → 项目级是「把跨任务仍然成立的事实沉淀下来」,判据是「下一个任务还会用到吗」——不会的(这次改了哪个文件、这次报了什么错)只留在任务级或失败摘要里;
- 项目级 → 会话级是「按需检索注入」,而不是把整个项目级记忆塞进 prompt。项目级记忆通常是大而稀疏的,必须走检索 + 预算裁剪。
一个常见的反模式是只有一层:把全部对话历史塞进向量库当「记忆」。它同时踩了三个坑——写入无门槛、检索无作用域、过期事实无法裁决。
2. 提取:候选记忆的准入流水线
对话 / 轨迹
→ 候选抽取(说出「值得记住」的显式与隐式信号)
→ 去重归一(同义合并、指向同一实体的多条合并)
→ 准入分级(可长期保留 / 需授权 / 禁止存储)
→ 冲突检测(与既有记忆比对,是否矛盾)
→ 裁决与留痕(时间戳 / 声明 / 权威度)
→ 写入(结构化字段 + 语义向量双写)
为什么必须「双写」(结构化 + 向量):向量擅长语义召回(「怎么装依赖」能召回「包管理器约定」),但不擅长精确过滤(「2026 年之后」「只在这个仓库」「仅限这个用户」)。把过滤条件做进结构化字段,把语义匹配交给向量,两条路各司其职。
3. 冲突更新:三种裁决策略与它们的失效场景
| 策略 | 规则 | 适用 | 失效场景 |
|---|---|---|---|
| 时间戳优先 | 新事实覆盖旧事实,记录变更历史 | 客观演进的事实(依赖版本、接口地址、负责人) | 用户的临时偏好被误当成长期事实(一次说「先别用 pnpm」就覆盖了项目规范) |
| 显式声明优先 | 用户/管理员的显式声明覆盖推断值 | 项目规范、团队约定 | 声明本身可能是错的——所以声明要可审阅而不是直接生效 |
| 来源权威度优先 | 权威来源(仓库文件、CI 配置)覆盖模型推断 | 代码库事实 | 权威源过期时反而被固化 |
关键设计:三条策略不是三选一,而是按「字段类型」分别指定。同一个记忆槽位上,「包管理器」用显式声明优先,「依赖版本」用时间戳优先,「用户偏好」用时间戳优先但带有效期。把裁决策略做成按字段声明的配置,而不是全局开关。
4. 检索:作用域过滤必须先于语义排序
一条项目级记忆如果不加作用域(项目 / 仓库 / 租户 / 用户),语义召回会把它带到不该出现的场景里。所以检索的顺序是:先按作用域硬过滤 → 再按语义相似度排序 → 最后按时效性与权威度做重排。把作用域过滤放在语义召回之后,是越权召回最常见的成因。
规范实现:
type MemoryScope = 'session' | 'task' | 'project'
type MemoryKind = 'fact' | 'preference' | 'procedure' | 'incident'
interface MemoryRecord {
id: string
scope: MemoryScope
kind: MemoryKind
// 结构化字段:过滤与裁决靠它,不靠向量
projectId: string
ownerId: string
subject: string // 归一后的实体,如 'package-manager'
value: string // 归一后的结论,如 'pnpm'
confidence: number
source: 'user-declared' | 'repo-file' | 'ci-config' | 'model-inferred'
recordedAt: number
validUntil?: number // 偏好类记忆要带有效期
supersedes?: string[] // 被本条取代的旧记录 id(留痕,不物理删除)
}
interface ConflictPolicy {
fact: 'timestamp' | 'authority' | 'declared'
preference: 'timestamp'
procedure: 'declared'
incident: 'authority'
}
export function writeMemory (
draft: Omit<MemoryRecord, 'id' | 'recordedAt' | 'supersedes'>,
existing: MemoryRecord[],
policy: ConflictPolicy
): { accepted: boolean; record: MemoryRecord; superseded: string[] } {
const same = existing.filter(r =>
r.projectId === draft.projectId && r.subject === draft.subject
)
// 1. 去重:同 subject 同 value 直接增强置信度,不新增记录
const dup = same.find(r => r.value === draft.value)
if (dup) {
return {
accepted: true,
record: { ...dup, confidence: Math.min(1, dup.confidence + 0.05) },
superseded: []
}
}
// 2. 冲突检测:有同 subject 不同 value 的记录即为冲突
if (same.length > 0) {
const rule = policy[draft.kind]
const winner = pickWinner(same, draft, rule)
if (winner === 'keep-existing') {
return { accepted: false, record: draft, superseded: [] }
}
// 3. 裁决留痕:新记录取代旧记录,旧记录保留可追溯
return {
accepted: true,
record: {
...draft,
id: newId(),
recordedAt: Date.now(),
supersedes: same.map(r => r.id)
},
superseded: same.map(r => r.id)
}
}
return {
accepted: true,
record: { ...draft, id: newId(), recordedAt: Date.now() },
superseded: []
}
}
注意返回值里的
superseded:旧记录只是被标记为「被取代」,不做物理删除。记忆系统需要能回答「Agent 为什么上个月说用 npm、现在说用 pnpm」——没有这条链,就无法审计一次行为变更。
面试官视角:
考察意图: 这题在考候选人是否理解「记忆」是一个数据工程问题而不是一个向量库问题。它把「持久化」拆成分层、写入、冲突、检索四个必须各自有答案的子问题。满分回答必须主动区分会话级/任务级/项目级,并能说清冲突更新不能静默覆盖。
评分 Rubric:
- 必答:给出至少三层的记忆划分并说明各层生命周期差异;区分提取(有门槛)/ 冲突更新(不静默覆盖、留痕)/ 检索(作用域硬过滤先行);说明结构化字段 + 向量双写的分工;指出**只有一层(对话历史全塞向量库)**是反模式。
- 加分项:能把裁决策略按字段类型分别指定而不是全局开关;能说出偏好类记忆要带有效期;能提到旧记忆不物理删除而是被 supersede 标记(可审计);能讲清任务级 → 项目级的沉淀判据(「下一个任务还会用到吗」);能区分本库另一题「长会话上下文压缩」——压缩是对话历史的处理,持久化是跨会话的事实存储。
- 常见误区:把「记忆」等同于「向量库 + 相似度检索」;认为记忆越全越好(无准入门槛的提取会永久化噪声);遇到冲突直接覆盖或直接报错;只讲写入不讲检索与作用域;把项目级记忆整块塞进 prompt;把「上下文压缩」和「记忆持久化」当成同一件事。
深度追问链:
- 追问 1:怎么判断一条信息该不该进项目级记忆?
- 答题切入点:用反事实测试——「下一个完全无关的任务还会用到这条吗?」会用到的(编码规范、模块边界、部署约束)才进;只跟本次任务有关的(这次改了哪个文件、这次报了什么错)留在任务级或失败摘要里。再叠一层准入分级(可长期保留 / 需授权 / 禁止),并且禁止档必须在写入前拦截——事后清理追不回已经流出的数据。
- 追问 2:用户说「这次先别用 pnpm」,怎么处理?
- 答题切入点:这是偏好类记忆,要有有效期而不是直接覆盖项目规范——写一条带
validUntil的会话级记录,检索时按有效期过滤,到期自动失效;项目级规范保持不变。这正是「按字段声明裁决策略」的具体体现:偏好走时间戳且带有效期,规范走显式声明优先。
- 答题切入点:这是偏好类记忆,要有有效期而不是直接覆盖项目规范——写一条带
- 追问 3:检索时召回了别的项目的记忆,怎么防?
- 答题切入点:作用域过滤必须发生在语义排序之前,并且要进存储层(命名空间 / 租户隔离 + 查询条件强制拼接项目与用户标识),而不是在应用层拿到结果后再过滤——越权召回在查询层就不成立。同时要能证明隔离有效:用跨项目的对抗样本做测试,确认召回结果里不含外项目内容。
- 追问 4:记忆系统怎么证明自己没有在骗人?
- 答题切入点:每条记忆记录来源、时间、置信度、被谁取代;提供审计视图(「这条结论的依据链」);关键结论要能回溯到原始证据(引用的文件路径、CI 配置、原始对话片段),而不是只有一句归纳。同时要能撤销:用户能删掉某条记忆,并让已写入的派生结论失效。
延伸阅读:
投机解码(Speculative Decoding)是怎么用小模型给大模型「打草稿」加速的?为什么它不会改变输出分布?
答案
核心结论:
- draft 模型一次生成 γ 个候选 token,target 模型用一次前向并行验证这 γ 个——原本要串行 γ+1 次 target 前向的解码过程被压成 1 次。
- 它靠拒绝采样保证「输出分布与只用 target 模型完全一致」,是 lossless 的加速,不是「小模型猜个大概再让大模型润色」。
- 加速比由接受率 α 决定,论文给的精确期望式是
E[tokens] = (1 - α^(γ+1)) / (1 - α),γ 越大、α 越高越好。 - 论文还给了个更硬的结论:单步接受率 β = 1 - TV(p, q),即 draft 与 target 分布的总变差距离越小,加速越明显——这把「什么样的模型适合当 draft」变成了可计算的判据。
原理解析:
官方事实核对(不要凭记忆写):
- Leviathan et al.(arXiv:2211.17192,2022-11-30,《Fast Inference from Transformers via Speculative Decoding》)是原始论文。核心机制:用小模型
q连续提议 γ 个 token,target 模型p一次前向并行算出这 γ 个位置的概率,逐个用拒绝采样判定:候选 x 以概率min(1, p(x)/q(x))被接受;若某位被拒,则从归一化的(p - q)+分布里补采一个 token,并在此截断。正是这个补采步骤保证了最终输出分布与直接从 p 采样完全一致。 - 论文的 式 (1):设 α 为平均接受率,一次 target 调用产出的期望 token 数为
(1 - α^(γ+1)) / (1 - α);γ 趋于无穷时趋近1/(1-α)。 - 论文的 定理 3.5:单步接受率
β = 1 - D_LK(p, q),其中D_LK(p,q) = Σ|p(x) - q(x)| / 2 = 1 - Σ min(p(x), q(x))—— 这正是 p 与 q 的总变差距离。接受率 = 1 - 总变差距离。 - 论文明确对比:Blockwise Parallel Decoding 之类的近似方法只支持贪心解码、需要额外训练、只能保证下游质量;投机解码保证的是「输出分布一致」这个更强的性质。
为什么能快: decode 阶段是 memory-bound——每生成一个 token 都要把全部权重和 KV Cache 读一遍,瓶颈在搬运而不在计算。draft 模型小,搬运便宜;target 模型一次前向把 γ 个位置并行算完,等于用一次「贵搬运」换掉了 γ 次。
什么时候划算 / 什么时候不划算:
- 划算:draft 与 target 分布接近(例如小模型是按大模型蒸馏出来的);单请求时延敏感的场景;draft 模型成本远低于 target。
- 不划算:draft 太弱(α 低,验证白做);draft 成本接近 target;批量吞吐已经打满时——投机解码降的是单请求时延,而 PagedAttention / 连续批处理提升的是总吞吐,二者是不同的优化维度,不能互相替代。
与其它推理优化的关系(别混为一谈): 量化(INT4/INT8)降的是显存与带宽;PagedAttention 管的是显存碎片;连续批处理提的是吞吐;投机解码降的是单请求首字后延。四者可以叠加。
规范实现:
// utils/speculative-decoding.ts
export interface Candidate {
token: string;
draftProb: number; // q(x):draft 模型给出的概率
targetProb: number; // p(x):target 模型并行算出的概率
}
export interface VerifyResult {
accepted: Candidate[];
needsBonusSample: boolean; // 为真时必须从归一化的 (p - q)+ 分布补采一个 token
rejectedAt: number; // -1 表示全部接受
}
/**
* 投机解码的验证步:以 min(1, p(x)/q(x)) 的概率逐个接受 draft 的提议。
* 注意:真实实现里补采需要 target 的完整词表分布,这里只标出「需要补采」这个动作。
*/
export function verifySpeculative(candidates: Candidate[]): VerifyResult {
const accepted: Candidate[] = [];
for (let i = 0; i < candidates.length; i += 1) {
const c = candidates[i];
const ratio = c.targetProb / Math.max(c.draftProb, 1e-10);
if (ratio >= 1 || Math.random() < ratio) {
accepted.push(c);
continue;
}
return { accepted, needsBonusSample: true, rejectedAt: i };
}
return { accepted, needsBonusSample: false, rejectedAt: -1 };
}
/** 论文式 (1):一次 target 调用产出的期望 token 数 */
export function expectedTokens(alpha: number, gamma: number): number {
if (alpha >= 1) return gamma + 1;
return (1 - Math.pow(alpha, gamma + 1)) / (1 - alpha);
}
/** 论文定理 3.5:接受率 = 1 - 总变差距离(此处用离散分布的显式求和) */
export function acceptanceRate(p: number[], q: number[]): number {
let overlap = 0;
const n = Math.min(p.length, q.length);
for (let i = 0; i < n; i += 1) {
overlap += Math.min(p[i], q[i]);
}
return overlap;
}
面试官视角:
- 必答:说清「一次前向并行验证」这个机制;说清为什么输出分布不变(拒绝采样 + 残差补采);能写出期望 token 数公式。
- 加分项:能说出「接受率 = 1 - 总变差距离」这个结论,并据此判断什么样的模型适合当 draft;能区分「投机解码降单请求时延」与「连续批处理提总吞吐」。
- 常见误区:把它说成「小模型生成、大模型润色」(那是有损的);说成「不改变结果」(逐 token 序列可能不同,不变的是分布);把它和量化当成同一件事;说「draft 越大越好」。
延伸阅读:
GraphRAG 和向量 RAG 到底差在哪?什么场景该建图索引,什么时候用传统 RAG 就够了?
答案
核心结论:
- 向量 RAG 是「平面检索 + Top-K 片段」,天然只能回答事实型问题(某个实体的某个属性)。
- GraphRAG 用「实体-关系图 + 社区摘要」做索引,多出两类查询:Local Search(以实体为种子查细节)与 Global Search(对全量社区报告做 map-reduce,回答「整体而言 / 总体趋势」类问题)。
- 代价全在索引侧:建图需要 LLM 抽实体关系、做社区检测、为每个社区生成报告,索引成本远高于「切块 + 嵌入」。
- 选型判据:跨文档归纳、全局性问题、需要关系推理的用图;单点事实定位用向量 RAG 就够——上 GraphRAG 属于过度工程。
原理解析:
官方事实核对(不要凭记忆写):
GraphRAG 的官方文档(microsoft.github.io/graphrag)把索引拆成四个阶段:
- Graph Extraction —— 用 LLM 从文本中抽取实体、关系、协变量(covariate 是带时效性的声明性描述);
- Graph Augmentation —— 用**层次化 Leiden 算法(Hierarchical Leiden Algorithm)**做社区检测,递归聚类直到达到社区规模阈值,从而得到社区的层级结构;
- Community Summarization —— 为每个社区生成一份 Community Report(社区报告),这是图的「摘要层」;
- Text Embedding —— 对文本单元做嵌入,供后续的实体 / 文本混合检索使用。
官方文档的 Query Engine 列出五类查询:Local Search、Global Search、DRIFT Search、Basic Search、Question Generation。其中:
- Global Search:官方原文写明它「searching over all AI-generated community reports in a map-reduce fashion」,并注明这是「a resource-intensive method」,但对「需要理解整个数据集整体」的问题(例如「这份笔记里提到的所有草药中,哪些数值最突出」)能给出好答案。
- Local Search:以从问题中抽取的实体为种子,经由 entity-text unit、entity-report、entity-entity relationship、entity-covariate 四类映射各自召回候选,再统一做排序与过滤。
为什么图索引能回答全局问题: 向量 Top-K 天然只能返回局部片段,你无法用「取前 K 个最相似的块」去回答「这批文档的共同主题是什么」。GraphRAG 的社区报告是图结构的摘要,Global Search 是在这层摘要上做 map-reduce——这是向量检索在结构上做不到的。
工程取舍:
| 维度 | 向量 RAG | GraphRAG |
|---|---|---|
| 索引成本 | 低(切块 + 嵌入) | 高(LLM 抽实体关系 + 社区检测 + 社区报告生成) |
| 增量更新 | 简单(新增块即可) | 成本高(社区结构会因新数据而变化,可能需要重算) |
| 擅长 | 单点事实、精确属性查询 | 跨文档归纳、全局主题、关系推理 |
| 查询成本 | 低 | Global Search 明显更贵 |
规范实现:
// utils/rag-strategy-selector.ts
export type QueryIntent = 'fact_lookup' | 'multi_hop' | 'global_summary' | 'relationship';
export interface RagStrategy {
useGraph: boolean;
searchMode: 'basic' | 'local' | 'global' | 'drift';
reason: string;
}
/**
* 选型判据:只有当问题真的需要跨文档归纳或关系推理时才上图索引。
* 默认走向量检索 —— 建图索引的预付成本必须在问题集上被摊薄。
*/
export function selectRagStrategy(intent: QueryIntent): RagStrategy {
switch (intent) {
case 'fact_lookup':
return { useGraph: false, searchMode: 'basic', reason: '单点事实查询,向量检索足够且成本最低' };
case 'multi_hop':
return { useGraph: true, searchMode: 'local', reason: '多跳推理需要实体关系图做连接' };
case 'relationship':
return { useGraph: true, searchMode: 'local', reason: '关系型问题需沿实体-关系边遍历' };
case 'global_summary':
return { useGraph: true, searchMode: 'global', reason: '全局归纳需在社区报告层做 map-reduce' };
default:
return { useGraph: false, searchMode: 'basic', reason: '默认走最省的向量检索' };
}
}
面试官视角:
- 必答:说清图索引比向量检索多出来的是什么能力(全局归纳与关系推理);指出成本在索引侧;能说出 Local 与 Global 的差别。
- 加分项:能报出层次化 Leiden 社区检测与 Community Report 这两个关键构件;能说明 Global Search 是 map-reduce 且官方自述 resource-intensive;能主动给出「不上图」的理由(索引成本要靠问题集摊薄)。
- 常见误区:把 GraphRAG 说成「更准的向量检索」(它是另一套索引,不是同一套的加强版);把它与「知识图谱问答」混为一谈;说它查询更快(索引更贵、查询更贵)。
延伸阅读:
- GraphRAG 官方文档:Indexing Dataflow(Graph Extraction → Graph Augmentation → Community Summarization → Text Embedding)
- GraphRAG 官方文档:Query Engine Overview(Local / Global / DRIFT / Basic Search 与 Question Generation)
- Microsoft Research:From Local to Global: A Graph RAG Approach to Query-Focused Summarization(GraphRAG 的研究论文入口)
语义缓存(Semantic Cache)是怎么用相似度命中回答的?阈值怎么定,误命中怎么办?
答案
核心结论:
- 语义缓存的机制:把用户 query 向量化后存起来;新 query 进来先算 embedding,与历史 query 比相似度,超过阈值就直接返回当时缓存的答案,整个大模型调用被跳过。它复用的是答案。
- 它与前缀缓存(Prompt Caching)的本质区别——这是本题最容易答混的地方:
- 前缀缓存:要求 token 级前缀完全一致,复用的是已算好的 KV,跳过的是 prefill 计算。它不会给出错误答案,因为算的是同一段前缀。
- 语义缓存:只要求语义相近,复用的是答案,整个推理都省了。它会给出错误答案——相似不等于相同。
- 真正的考点是阈值与误命中(false hit):阈值太松 → 语义相近但意图不同的问题拿到错误答案(「怎么退运费」命中「怎么退货」);阈值太紧 → 命中率塌掉,缓存形同虚设。
- 必须配套失效策略:知识库更新、时效性内容(价格、库存、订单状态)、个性化上下文一旦变化,缓存答案就是错的。生产上要么把知识版本号写进缓存键,要么对这类 query 直接绕过缓存。
- 实现上是"向量库 + 缓存"两件套:Redis 的 LangCache 把 embedding 相似度匹配与 TTL 做成一体的托管语义缓存 API;Redis 的向量检索本身支持 SVS-VAMANA 图索引,所以同一套存储既能做语义缓存也能做 RAG 检索。
原理解析:
1. 三层缓存的分工(工程上最常被问的扩展题)
| 层 | 命中条件 | 复用的是什么 | 会不会答错 |
|---|---|---|---|
| 前缀缓存 | token 前缀完全一致 | KV 向量 | 不会 |
| 语义缓存 | embedding 相似度超阈值 | 完整答案 | 会 |
| 精确缓存 | query 字符串完全相同 | 完整答案 | 不会(但几乎没省到 embedding 计算) |
三层是逐层放宽命中条件的关系:语义缓存命中率最高、风险也最高。
2. 阈值怎么定:不能拍脑袋,要用数据
阈值不是"设成 0.9 就对了",标准做法是用评测集扫阈值:
- 拿一批真实 query,把它按"同义改写 / 相邻意图 / 无关意图"三类标注;
- 在候选阈值上分别统计命中率与误命中率;
- 取满足业务可接受的误命中上限(比如 1%)的最大阈值——误命中率是硬约束,命中率是软指标;
- 线上再按意图域分桶设阈值:FAQ 类同义改写多,阈值可以放宽;交易类意图相近但结果差异大,阈值要收紧。
3. 什么 query 不该进缓存
- 时效性内容:价格、库存、订单状态、物流轨迹——缓存几秒就是错的;
- 强个性化内容:依赖用户身份、权限、历史状态;
- 写操作链路:Agent 要执行的动作类请求,命中缓存意味着动作被跳过。
这类 query 应该在缓存层之前就被拦掉,而不是"命中了再判断"。
4. 与本库既有考点的边界
- Prompt Caching 题(
{#p0-prompt-caching-kv-cache-optimization})讲的是前缀静态对齐——怎么让 token 前缀不变以命中缓存;本题讲的是语义相近但不完全相同的 query 怎么复用答案,以及为此要付的误命中代价。 - Agent 链路延迟成本题(
{#p1-agent-chain-latency-cost-optimization})讲的是如何拆解与优化一条链路的延迟;本题锁定的是缓存层里"语义相似"这一种命中方式的判定与风险。
规范实现:
// 概念骨架:语义缓存的判定、阈值分桶与失效策略
interface SemanticCacheEntry {
queryEmbedding: number[];
answer: string;
createdAt: number;
ttlMs: number;
knowledgeVersion: string; // 知识库版本:变了就整批失效
}
interface SemanticCachePolicy {
threshold: number; // 相似度阈值,按意图域分桶
intentDomain: 'faq' | 'transaction' | 'general';
bypassWhen: (query: string, entry: SemanticCacheEntry) => boolean;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
}
/** 判定顺序:先判"该不该缓存",再判"能不能命中" */
function lookupSemanticCache(
queryEmbedding: number[],
entries: SemanticCacheEntry[],
policy: SemanticCachePolicy,
currentKnowledgeVersion: string
): { hit: true; answer: string } | { hit: false; reason: string } {
// 1) 知识版本不一致:整批作废
for (const e of entries) {
if (e.knowledgeVersion !== currentKnowledgeVersion) {
return { hit: false, reason: 'knowledge-version-mismatch' };
}
}
// 2) 意图域阈值:FAQ 可放宽,交易类收紧
const threshold = policy.intentDomain === 'faq' ? policy.threshold + 0.03 : policy.threshold;
let best: SemanticCacheEntry | null = null;
let bestScore = -1;
for (const e of entries) {
const score = cosineSimilarity(queryEmbedding, e.queryEmbedding);
if (score > bestScore) { bestScore = score; best = e; }
}
if (!best || bestScore < threshold) {
return { hit: false, reason: 'below-threshold' };
}
// 3) 命中后仍要做"该不该命中"的二次否决(时效性 / 写操作)
if (policy.bypassWhen('QUERY_PLACEHOLDER', best)) {
return { hit: false, reason: 'bypassed-by-policy' };
}
return { hit: true, answer: best.answer };
}
面试官视角:
- 必答:① 说清语义缓存复用的是答案、前缀缓存复用的是 KV,两者不可混为一谈;② 说清误命中是本质风险,并举出「相近但不同意图」的例子;③ 说清阈值靠评测集扫、不靠拍脑袋,且误命中率是硬约束;④ 说清知识版本变更会让整批缓存失效。
- 加分项:能列出三类不该进缓存的 query(时效性 / 强个性化 / 写操作);能讲清按意图域分桶设阈值;能提到 Redis LangCache 这类把「embedding 匹配 + TTL」做成一体的产品形态。
- 常见误区:把语义缓存说成"省了 prefill"(它省的是整个推理);说"设个高阈值就不会答错"(阈值再高也只是降低概率);忽略知识库更新后的批量失效;把语义缓存当成幂等性保证(缓存命中意味着动作没被执行,Agent 写操作链路尤其危险)。
延伸阅读:
RAG 是不是检索得越多越好?Self-RAG 怎么让模型自己决定要不要检索?
答案
核心结论:
- Self-RAG(arXiv:2310.11511)的核心命题就是「检索不是越多越好」。传统 RAG 无条件「检索 top-k → 塞进上下文 → 生成」,论文的消融实验直接指出:在推理时固定使用 top-1 段落(Retrieve top 1,类似传统 RAG 的做法)会让 PopQA 与 ASQA 的表现大幅下滑——因为无关段落一旦进入上下文,就会干扰生成的证据选择。
- 它靠四类「反思 token(reflection tokens)」把「要不要检索、检索的内容有没有用」变成模型可自己决策的显式动作:
| 反思 token | 取值 | 作用 |
|---|---|---|
Retrieve | {Yes, No} | 当前这一步是否需要检索 |
IsRel | 完全相关 / 部分相关 / 不相关 | 检索到的段落与问题是否相关 |
IsSup | 完全支持 / 部分支持 / 不支持 | 段落是否支撑当前生成的结论 |
IsUse | {5, 4, 3, 2, 1} | 这一整段回答对用户有多有用 |
论文 Table 1 把它们称为「四类反思 token」,并指出后三类是 Critique token(批判类),Retrieve 是第四类(检索类)。
- 推理时是「生成—批判—再生成」的循环,论文算法里每一步先
Generate(生成段落y_t),再Critique(对每个候选段落预测IsSup/IsUse),然后 Rank(按IsRel、IsSup、IsUse对候选排序);只有Retrieve == No时才走「不插入任何段落、直接生成」的分支。 - 检索的触发有两种口径:硬条件(生成出
Retrieve = Yes就检索)与软条件(Retrieve = Yes这个 token 的归一化概率超过设定阈值就检索),论文称之为 tree-decoding with critique tokens。 - 代价必须说清:Self-RAG 不是即插即用的工程方案,它需要训练。它训了一个批判模型 𝒞 与生成模型 ℳ,反思 token 由 𝒞 离线插入到语料中——论文说明这样做「eliminates the need to host a critic model during training」,但推理时仍要跑批判。官方结果口径是:Self-RAG(7B 与 13B)在开放域问答、推理、事实核查上超过 ChatGPT 与带检索的 Llama2-chat,并在事实性与引用准确率上有显著提升(论文报告,需按自身负载复现)。
原理解析:
1. 传统 RAG 的隐含假设,与 Self-RAG 的修正
| 阶段 | 传统 RAG | Self-RAG |
|---|---|---|
| 是否检索 | 总是检索 | 由 Retrieve token 逐段决定 |
| 检索多少 | 固定 top-k | 由批判环节按相关性筛选 |
| 证据与生成的关系 | 检索完就直接生成,没有证据校验环节 | IsSup 显式判断段落是否支撑结论 |
| 多个候选段落 | 通常只取前 k 段 | 生成多个候选段落后按 IsRel / IsSup / IsUse 排序 |
| 证据不足时 | 硬着头皮基于无关段落生成 | Retrieve == No → 不插入段落,基于参数化知识作答 |
论文的消融数据(PopQA 准确率)是最直接的证据:
| 变体 | PopQA |
|---|---|
| Self-RAG(标准) | 45.5 |
| 去掉 Retriever(不训练检索行为) | 43.6 |
| 去掉 Critic(无反思 token) | 42.6 |
| 推理时固定只取 top-1 段落(≈传统 RAG) | 41.8 |
| 推理时完全禁用检索 | 24.7 |
| 只用硬条件触发检索(不用自适应阈值) | 28.3 |
这张表说明两件事:① 批判环节(Critic)带来的增益大于检索器本身;② 「固定 top-1」比「自适应检索」差 3.7 个点,正是 Self-RAG 想纠正的对象。
2. 与本库既有 RAG 题目的边界
| 既有考点 | 它问的是 | 本题问的是 |
|---|---|---|
| 「RAG 召回质量分层诊断与 Rerank 精排」 | 召回没召回对怎么分层定位与精排 | 召回召回了但没用(或根本不该召回)怎么办 |
| 「RAG 父子索引」 | 小块检索 / 大块喂给的粒度解耦 | 要不要检索这件事本身由模型决策 |
| 「GraphRAG 与向量 RAG 差在哪」 | 索引结构的差异(社区报告 vs 向量) | 检索时机与证据筛选的差异 |
规范实现:
// Self-RAG 风格的「生成 -> 批判 -> 排序」推理循环(工程骨架,非论文实现)
type ReflectionTokens = {
retrieve: 'Yes' | 'No';
isRel: 'full' | 'partial' | 'none';
isSup: 'full' | 'partial' | 'none';
isUse: 1 | 2 | 3 | 4 | 5;
};
type Passage = { id: string; text: string; isRel: ReflectionTokens['isRel'] };
type Segment = {
text: string;
passages: Passage[];
isSup: ReflectionTokens['isSup'];
isUse: ReflectionTokens['isUse'];
};
// 软条件:Retrieve=Yes 的归一化概率超过阈值就检索
const RETRIEVE_THRESHOLD = 0.2;
async function selfRagAnswer(
llm: {
generate: (ctx: string) => Promise<{ text: string; retrieveProb: number }>;
critique: (segment: string, passages: Passage[]) => Promise<ReflectionTokens>;
},
retriever: (query: string) => Promise<Passage[]>,
query: string
): Promise<{ answer: string; usedRetrieval: boolean }> {
const segments: Segment[] = [];
for (let turn = 0; turn < 8; turn++) {
const ctx = buildContext(query, segments);
// 1) Generate:先决定这一段要不要检索
const { text, retrieveProb } = await llm.generate(ctx);
const needRetrieve = retrieveProb > RETRIEVE_THRESHOLD;
if (!needRetrieve) {
// Retrieve == No:不插入任何段落,直接基于参数化知识续写
segments.push({ text, passages: [], isSup: 'none', isUse: 3 });
continue;
}
// 2) Retrieve:只取少量候选,真正的筛选交给批判环节
const candidates = await retriever(query);
// 3) Critique:逐段判断是否支撑当前生成
let best: Segment | null = null;
for (const p of candidates) {
const t = await llm.critique(text, [p]);
const seg: Segment = { text, passages: [p], isSup: t.isSup, isUse: t.isUse };
// 4) Rank:按 IsRel -> IsSup -> IsUse 排序后再决定用不用
if (!best || rank(seg) > rank(best)) best = seg;
}
if (best && best.isSup !== 'none') {
segments.push(best); // 有支撑才纳入
} else {
segments.push({ text, passages: [], isSup: 'none', isUse: 3 });
}
}
return { answer: synthesize(segments), usedRetrieval: segments.some(s => s.passages.length > 0) };
}
// Rank:相关性优先,其次支撑度,最后有用程度
function rank(s: Segment): number {
const rel = { full: 3, partial: 2, none: 1 }[s.passages[0]?.isRel ?? 'none'];
const sup = { full: 3, partial: 2, none: 1 }[s.isSup];
return rel * 100 + sup * 10 + s.isUse;
}
面试官视角:
- 必答:① 说清 Self-RAG 的命题是**「不是检索越多越好」,并能举出「固定 top-1 会掉分」这个反例;② 说全四类反思 token(
Retrieve/IsRel/IsSup/IsUse)及其职责分工;③ 说清推理是生成—批判—排序的循环,且Retrieve == No时不插入任何段落**;④ 指出它需要训练(批判模型 + 生成模型),不是即插即用。 - 加分项:能报出「硬条件 vs 软条件(自适应阈值)」两种检索触发口径;能解释为什么批判环节的增益大于检索器本身(消融:去掉 Critic 掉 2.9 个点,去掉 Retriever 掉 1.9 个点);能说明反射 token 是离线插入语料训练出来的,因此训练期不需要额外托管批判模型;能指出论文自陈的边界——它解决的是「证据选择」,不解决「检索器召回不到正确段落」(召回不到时批判环节也无米下锅)。
- 常见误区:把 Self-RAG 说成「RAG 的一种重排序方法」(它改的是要不要检索 + 证据是否支撑,排序只是最后一环);以为它能直接套到现有 RAG 管线而不训练;把
IsSup(段落是否支撑结论)说成IsRel(段落是否与问题相关);忽略「固定 top-1 掉分」这个它最核心的对照实验;把论文的「超过 ChatGPT」当成无条件结论(这是论文自述的对比结果,需按自身负载复现)。
延伸阅读:
RAG 检索到的内容和大模型自己「知道」的不一致时该信哪个?冲突要怎么消解?
答案
核心结论:
- 默认行为不是「听检索的」——模型在冲突中倾向于信自己的参数化记忆。综述《Knowledge Conflicts for LLMs: A Survey》(arXiv:2403.08319)汇总的关键观察是:模型倾向 favor parametric knowledge;Xie et al. (2023) 把这描述为一种 confirmation bias(确认偏误)——即使面对冲突的外部证据,模型仍倾向于支持与内部记忆一致的信息。
- 模型为什么偏向内部记忆,有两个已知成因(Tan et al., 2024,综述转引):① 检索内容与输入问题的相似度低,看起来就不像是「在回答这个问题」;② 检索信息本身不完整——尤其当冲突的来源之间信息残缺时,模型更容易倒向更完整、更连贯的内部知识。
- 还有一个反直觉的倾向:Xu et al. (2023) 发现 LLM 倾向 favor 逻辑结构良好的知识,即使它与事实准确性相矛盾。这解释了为什么格式工整、看起来专业的检索片段比口语化但正确的片段更容易被模型采信——所以「让检索结果看起来更权威」会加剧错误采信,而不是缓解。
- 消解前先分类:综述把冲突分成三类,应用层策略完全不同:
- context-memory conflict(CM):外部上下文 vs 参数化记忆——RAG 场景的主战场;
- inter-context conflict(IC):多段上下文之间互相矛盾;
- intra-memory conflict(IM):模型自身参数化记忆内部自相矛盾——应用层基本无法根治。
- 正确目标行为不是「随便挑一个」,而是「定位冲突并给出有区分的答案」(Wang et al., 2023h,综述转引)。这要求应用层显式检测冲突并把冲突暴露给用户,而不是悄悄选一边。
- 必须一起说清的两条边界:① 冲突经常是召回质量问题的表征——检索到不相关文档,模型看到的是「对不上」而不是「真矛盾」,先修召回、再谈消解;② 综述对现有解法的批评:当前冲突消解方案主要针对人工构造的冲突场景,依赖假设先验,牺牲了粒度与广度,真实场景的冲突更杂乱。
原理解析:
1. 三类冲突与对应策略
| 冲突类型 | 识别信号 | 消解策略 | 兜底 |
|---|---|---|---|
| CM(上下文 vs 参数化记忆) | 检索片段的结论与模型自身答案不一致,且片段与问题相关性低或信息残缺 | 提示层显式指令「以检索文档为准」+ 冲突检测 + 来源标注 + 时效性优先 | 返回两种说法并提示人工确认 |
| IC(上下文之间互相矛盾) | 多段检索片段结论互斥 | 按来源权威度 / 时间戳仲裁;无法仲裁时显式并列 | 并列呈现 + 标注各自来源与时间 |
| IM(参数化记忆内部矛盾) | 模型对同一问题在不同轮次给出互斥答案,且无外部证据 | 应用层无解:只能靠更新模型 / 换模型 / 换检索源 | 标注「该问题存在模型内部不确定性」 |
2. 工程上的七步消解流程(L2 面经口径)
Agent Interview Hub 的「华为-面试题与面经」分节给出了这个问题的一线工程答案,可归纳为七步:
- 默认策略:在提示中明确指示「以检索到的文档内容为准,不要依赖自身知识」;
- 置信度对比:让模型分别输出对检索内容与自身知识的置信度,取高者,或标注冲突;
- 来源标注:要求回答里标明「根据文档 X」,便于用户核验;
- 检索质量保障:冲突往往源于检索到不相关文档,提升检索精度(精排、调整嵌入)是根本解法;
- 时效性判断:检索内容有明确时间戳且更新时,优先信任检索内容;
- 兜底方案:冲突无法自动解决时,返回两种说法并提示「存在信息冲突,建议人工确认」;
- 知识库维护:定期更新知识库、删除过时内容,从源头减少冲突。
注意第 4 步与上面第 6 条边界是同一件事的两面:消解策略不能替代召回质量。
3. 与本库既有 RAG 题目的边界
| 既有考点 | 它问的是 | 本题问的是 |
|---|---|---|
| 「RAG 召回质量分层诊断与 Rerank 精排」 | 召回不准怎么定位与排序 | 召回准了但与模型认知冲突怎么裁决 |
| 「为什么大模型会产生幻觉」 | 幻觉的成因与工程防范 | 冲突是幻觉的一种特殊形态(有据可依但据错了) |
| 「语义缓存的阈值与误命中」 | 缓存误命中导致答错 | 检索误命中导致答错——机制相似但治理点不同 |
规范实现:
// 上下文 vs 参数化记忆的冲突检测与消解(工程骨架)
type Chunk = {
id: string;
text: string;
source: string;
authority: number; // 来源权威度,0~1
updatedAt: number; // 时间戳,用于时效性仲裁
retrievedFor: string; // 该片段是针对哪个查询检索的(用于相关性判断)
};
type AnswerWithConflict = {
answer: string;
conflicts: Array<{ claim: string; bySource: string; byModel: string }>;
citations: string[];
};
async function resolveWithConflict(
llm: {
answerFromContext: (q: string, chunks: Chunk[]) => Promise<string>;
answerFromMemory: (q: string) => Promise<string>;
judgeConflict: (q: string, a: string, b: string) => Promise<boolean>;
confidence: (q: string, text: string) => Promise<number>;
},
chunks: Chunk[],
query: string
): Promise<AnswerWithConflict> {
// 1) 召回质量前置检查:相关性太低就别急着谈冲突(多半是召回错了)
const relevant = chunks.filter(c => similarity(c.retrievedFor, query) >= 0.6);
if (relevant.length === 0) {
return {
answer: await llm.answerFromMemory(query),
conflicts: [],
citations: []
};
}
// 2) 双路生成:一条基于检索,一条基于参数化记忆
const fromCtx = await llm.answerFromContext(query, relevant);
const fromMem = await llm.answerFromMemory(query);
// 3) 冲突检测
const isConflict = await llm.judgeConflict(query, fromCtx, fromMem);
if (!isConflict) {
return { answer: fromCtx, conflicts: [], citations: relevant.map(c => c.source) };
}
// 4) 时效性优先:更新的知识源优先
const newest = Math.max(...relevant.map(c => c.updatedAt));
const ctxConf = await llm.confidence(query, fromCtx);
const memConf = await llm.confidence(query, fromMem);
const ctxIsNewerAndConfident = newest > 0 && ctxConf >= memConf;
if (ctxIsNewerAndConfident) {
// 5) 单一裁决 + 来源标注
return {
answer: fromCtx,
conflicts: [],
citations: relevant
.filter(c => c.updatedAt === newest)
.map(c => c.source)
};
}
// 6) 兜底:显式暴露冲突,给出两种说法(而不是悄悄选一个)
return {
answer: `${fromCtx}\n\n(注意:以上结论与模型自身认知存在冲突,来源:${relevant
.map(c => c.source)
.join('、')};建议人工确认)`,
conflicts: [{ claim: fromCtx, bySource: relevant.map(c => c.source).join('、'), byModel: fromMem }],
citations: relevant.map(c => c.source)
};
}
面试官视角:
- 必答:① 说清默认是模型偏向参数化记忆,不是听检索的(并能说出 confirmation bias 这个说法);② 说全三类冲突(CM / IC / IM)并指出 CM 才是 RAG 场景主战场,IM 应用层无解;③ 给出至少三层消解手段:提示层指令 + 置信度/时效性仲裁 + 来源标注与冲突显式暴露;④ 指出冲突常是召回质量问题。
- 加分项:能说出模型偏向内部记忆的两个成因(检索片段与问题相似度低、检索信息本身不完整);能解释「为什么格式工整的错片段比口语化的对片段更容易被采信」(Xu et al. 的逻辑连贯性偏好);能引用综述对现有方案的批评——依赖假设先验、牺牲粒度与广度;能说出目标行为是「定位冲突并给出有区分的答案」。
- 常见误区:认为「在 prompt 里写一句以检索为准就解决了」(模型仍会偏向内部记忆,尤其当检索片段相关性低或残缺时);把冲突当成纯粹的幻觉(幻觉无据可依,冲突有据可依但据错了);把召回质量问题当成冲突消解问题来治(顺序反了);认为冲突就该自动选一边(该暴露的不暴露,等于把不确定性藏起来);认为所有冲突都能在应用层解决(IM 只能换模型)。
延伸阅读:
商品库做 RAG 时,标题、类目、价格这些字段该拿哪个去做 embedding?为什么价格这种数值字段不该进向量?
答案
核心结论:
- 入库时就要做字段分工,不是「把所有字段拼成一段话一起 embed」。语义型字段(标题、描述与卖点、类目路径、关键属性如品牌与材质)进 embedding;数值与枚举型字段(价格、库存、销量、SKU 编码)不进 embedding,改作结构化过滤条件。原因是 embedding 学的是语义相似度,对「三百元以内」这种数值谓词几乎没有分辨力——把价格写进文本,向量会在价格数字上抖动,语义信号被稀释。
- 纯数字字段进向量是净亏的:价格、库存这类字段的区分度几乎全在数值上,而向量空间里的距离反映不了「18 元」和「1800 元」的远近关系——它们在语义上几乎同义(都是「价格」),所以在向量空间里也几乎同向。真正需要精确比较的量,应该交给数据库的
WHERE或向量库的 filter 表达式做精确过滤,而不是指望余弦相似度。 - 过滤与近似索引的先后顺序有一个真实的坑:在 HNSW / IVFFlat 这类近似索引上,
WHERE过滤是在索引扫描之后(post-filter)施加的。pgvector 官方文档写明:过滤条件命中 10% 的行时,用默认hnsw.ef_search = 40,平均只有约 4 行能通过过滤——也就是说想要 Top-10,很可能只拿到 4 条甚至更少,而且不报错。修法有两个:给过滤列建索引 + 开启迭代索引扫描(pgvector 0.8.0 起提供iterative index scans),或按过滤值部分索引 / 分区。 - 多向量方案:标题单独一个向量(匹配短 query)+ 详情单独一个向量(匹配长 query),检索时分别召回再融合。单一向量无法同时服务「男士手表」这种短查询和「送男朋友的生日礼物,预算五百以内」这种长查询。
- 选型不是「哪个 embedding 模型榜单最高」,而是「哪个模型在你的域上最好」:跨领域通用榜单(如 MTEB)只能做初筛,最终必须自建带真实 query 与标注文档的小评测集来选。领域不匹配时(如金融合规、技术文档),通用模型的近邻结构会明显失真。
原理解析:
1. 字段分工表
| 字段类型 | 举例 | 该放哪 | 理由 |
|---|---|---|---|
| 强语义文本 | 标题、卖点描述、类目路径、品牌与材质等关键属性 | 进 embedding | 这些是「用户在说什么」的语义载体 |
| 数值 | 价格、库存、销量、折扣 | 不进,改作 filter | 语义相似度无法表达数值大小关系 |
| 枚举 / 编码 | SKU 编码、类目 ID、标签 ID | 不进,改作 filter | 编码无语义,进向量只增加噪声维度 |
| 结构化标签 | 「礼物」「男士」等标记 | 视情况:既可 filter 也可进文本 | 作为 filter 精确命中;作为文本提升语义覆盖 |
| 图片与富文本 | 商品详情图、评价摘要 | 图片走视觉表征;评价摘要可进文本 | 图片不该被转成文字塞进向量 |
2. 为什么「数值进 embedding」是净亏的
embedding 把一段文本映射到固定维度的语义空间,空间的度量是语义相似度。价格被写成「¥299」和「¥2999」时,两者在字面上都只是「一个价格」,在语义上更接近而不是更远。于是:
- 召回会错:查询「便宜的」和「贵的」召回到的商品,向量距离几乎一样;
- 排序会错:余弦相似度既不区分价格档位,也没法表达「低于预算」;
- 噪声会挤占:数字 token 稀释了描述性文本的语义信号,标题与卖点这种真正有区分度的内容被冲淡。
正确做法是把它变成精确条件:price <= 500 用 filter 表达,把「语义匹配」与「数值约束」交给两种各自擅长的机制。
3. post-filter 陷阱的机制
近似索引路径(以 pgvector HNSW 为例)
查询 -> 索引扫描,取回约 ef_search = 40 个近邻
-> 对这 40 个结果施加 WHERE 过滤
-> 若过滤命中率约 10%,平均只有约 4 个能通过
-> 返回结果数远小于期望的 LIMIT
问题在于它是静默的:不报错、不超时,只是「结果莫名其妙变少了」。工程上有两个层次的应对:
- 索引层:给过滤列建索引;过滤值种类少时用部分索引(
CREATE INDEX ... WHERE (category_id = 123)),种类多时用分区。 - 扫描层:开启迭代索引扫描,让引擎在结果不足时继续扫索引,直到凑够行数或触及上限。pgvector 官方同时给出两个上限参数
hnsw.max_scan_tuples与ivfflat.max_probes用来兜住最坏耗时。
反过来,当过滤条件本身就很宽时(比如只过滤「租户」而租户数据占多数),post-filter 几乎无损,用不着付迭代扫描的代价。判断依据是过滤命中率,不是「有没有用 filter」。
4. 多向量方案:标题向量 + 详情向量
短查询(用户输入几个关键词)与长查询(一整句带约束的需求)对向量的要求不同:短查询更依赖标题里的高密度关键词,长查询依赖详情里的完整语义。一个向量服务不了两端,所以索引期就分开存两份向量,检索时两路各自召回,再按统一口径融合排序。这条与既有的「RAG 父子索引」是不同问题——父子索引切的是同一份内容的粒度,多向量切的是不同字段的语义面。
5. embedding 选型的评测流程
- 初筛:用通用榜单(MTEB 这类)排除明显不适配的模型——它只看平均,不能当结论。
- 建小评测集:从真实 query 采样,人工标注相关文档;这与既有评测题里的「评测集要从真实 bad case 来」是同一条纪律。
- 看分层指标:检索侧看 Recall@K / MRR,生成侧看忠实度与答案相关性,分段看才能定位是向量的问题还是生成的问题。
- 域内微调是最后手段:通用模型在专有域上失真明显时,域内继续训练比换更大的通用模型更划算。
规范实现:
-- pgvector:向量列 + 可过滤的结构化列,字段分工在 schema 层就固定下来
CREATE TABLE product (
id bigserial PRIMARY KEY,
sku_code text NOT NULL, -- 编码:只做 filter,不进向量
title text NOT NULL, -- 语义字段:进向量
description text NOT NULL, -- 语义字段:进向量
category_path text NOT NULL, -- 语义字段:进向量
brand text NOT NULL, -- 语义字段:进向量
material text NOT NULL, -- 语义字段:进向量
price_cents integer NOT NULL, -- 数值:只做 filter,不进向量
stock integer NOT NULL, -- 数值:只做 filter,不进向量
is_gift_tag boolean NOT NULL DEFAULT false, -- 枚举:filter
title_embedding vector(1024) NOT NULL, -- 标题向量:服务短 query
detail_embedding vector(1024) NOT NULL -- 详情向量:服务长 query
);
-- 过滤列单独建索引
CREATE INDEX product_category_idx ON product (category_path);
CREATE INDEX product_price_idx ON product (price_cents);
-- 向量索引:HNSW
CREATE INDEX product_title_hnsw ON product USING hnsw (title_embedding vector_cosine_ops);
CREATE INDEX product_detail_hnsw ON product USING hnsw (detail_embedding vector_cosine_ops);
-- 关键:过滤条件越窄,越要开迭代索引扫描,否则结果会被 post-filter 悄悄吃掉
SET hnsw.iterative_scan = strict_order;
SET hnsw.max_scan_tuples = 20000;
-- 一次查询:语义召回 + 精确过滤 + 两路融合
WITH short_q AS (
SELECT id, title_embedding <=> '[...]' AS dist
FROM product
WHERE price_cents <= 50000 -- 数值约束交给精确过滤
AND category_path LIKE '%手表%' -- 枚举约束交给精确过滤
AND is_gift_tag -- 枚举约束交给精确过滤
ORDER BY title_embedding <=> '[...]'
LIMIT 50
),
long_q AS (
SELECT id, detail_embedding <=> '[...]' AS dist
FROM product
WHERE price_cents <= 50000
AND category_path LIKE '%手表%'
AND is_gift_tag
ORDER BY detail_embedding <=> '[...]'
LIMIT 50
)
SELECT id, MIN(dist) AS best_dist -- 两路召回后按统一口径融合
FROM (SELECT id, dist FROM short_q UNION ALL SELECT id, dist FROM long_q) u
GROUP BY id
ORDER BY best_dist
LIMIT 10;
// 入库时的字段分工:把「语义字段」与「过滤字段」在代码层就分开,不给后面留坑
type ProductRow = {
id: string;
skuCode: string;
title: string;
description: string;
categoryPath: string;
brand: string;
material: string;
priceCents: number;
stock: number;
isGiftTag: boolean;
};
// 语义字段:进 embedding
const SEMANTIC_FIELDS = ['title', 'description', 'categoryPath', 'brand', 'material'] as const;
// 过滤字段:只做精确条件,不进向量
const FILTER_FIELDS = ['priceCents', 'stock', 'isGiftTag', 'skuCode'] as const;
function buildEmbeddingText(p: ProductRow): string {
// 拼接成结构化文本,避免数字与编码污染语义
return [
`【标题】${p.title}`,
`【类目】${p.categoryPath}`,
`【品牌】${p.brand}`,
`【材质】${p.material}`,
`【描述】${p.description.slice(0, 500)}`,
].join('\n');
}
function buildFilterClause(p: ProductRow): Record<string, unknown> {
return {
priceCents: { lte: p.priceCents },
stock: { gt: 0 },
isGiftTag: p.isGiftTag,
};
}
面试官视角:
- 必答:① 说清入库期就要分字段:语义字段进向量、数值与枚举字段做 filter;② 说清价格不该进向量的原因(余弦相似度表达不了数值大小关系,且数字 token 稀释语义);③ 说清 post-filter 陷阱:近似索引上过滤在扫描之后,pgvector 官方口径是过滤命中 10% 时默认
ef_search = 40平均只剩约 4 行,且不报错;④ 说清修法(过滤列索引 + 迭代索引扫描 / 部分索引 / 分区)。 - 加分项:能说出多向量方案(标题向量服务短 query、详情向量服务长 query)并解释它与父子索引的区别;能说清 embedding 选型要自建带真实 query 的小评测集、通用榜单只做初筛;能指出过滤命中率决定要不要付迭代扫描的代价。
- 常见误区:把「结构化过滤」说成「召回的一部分」——它与语义召回是串联关系;认为加 filter 就一定不会影响结果数(近似索引下会);认为所有字段都该进向量以「最大化信息」;把 RRF 之类的融合算法与字段分工混为一谈;认为「换了更大的 embedding 模型就一定更好」而不看域内评测。
延伸阅读:
同一个问题,不同用户应该得到不同答案——个性化 RAG 怎么做?用户画像该注入到检索链路的哪一环?
答案
核心结论:
- 个性化有三个注入点,作用强度和可控性正好相反。① Prompt 侧:把用户画像作为额外 context 注入,模型在生成阶段「知道是谁」;② Query / 向量侧:把用户向量与 query 向量融合,让召回本身就偏向该用户的兴趣;③ Rerank 侧:在精排特征里加入用户特征,同一个 query 对不同用户给出不同排序。三者依次是「可控但不够个性化 → 个性化强但难以解释 → 个性化强且可控但最贵」。工程上通常三层叠加,而不是只选一层。
- 向量侧个性化的经典做法是「把人放进同一个空间」。eBay 的工程论文(arXiv:2102.06156)的做法是把商品与用户嵌入到同一个向量空间:用户向量由内容特征与站内行为共同算出,从而让人和货可以直接比较;论文同时指出这套办法的一个附带收益是缓解大市场的冷启动问题。RAG 里对应的做法,就是把用户向量与 query 向量做融合。
- 知识库要分层:全局知识库(所有用户共享)+ 用户私有知识库(个人收藏、历史对话、个人笔记)。检索时作用域过滤必须先于语义排序——否则私有内容会混进公共候选,而语义相似度天然分不清「这条是给 A 用户的还是给 B 用户的」。
- 最容易被漏掉的一条:缓存键必须包含用户特征。语义缓存若只用 query 的相似度做键,个性化请求会串味——A 用户的答案被 B 用户命中复用。这条在缓存命中率越高的系统里越致命。
- 个性化的收益是有边界的:用户画像本身有置信度与时效,过期画像会把错误偏好放大到每一次检索。画像的置信度应该参与融合权重,而不是一刀切地加进去。
原理解析:
1. 三个注入点对照
| 注入点 | 做法 | 个性化强度 | 成本 | 可解释性 | 主要风险 |
|---|---|---|---|---|---|
| Prompt 侧 | 画像写进 context(偏好、约束、身份) | 中 | 低(多几个 token) | 高 | 画像占上下文预算;模型可能忽略画像 |
| Query / 向量侧 | query 向量融合用户向量 | 高 | 中(需维护用户向量) | 中 | 用户向量陈旧会系统性带偏召回 |
| Rerank 侧 | 精排特征加入用户特征 | 高 | 高(多一次精排) | 高 | 精排模型需要用户侧样本,容易过拟合到头部用户 |
2. 为什么「把用户 embed 进同一个空间」是可行的
直觉上「人」和「商品」是两种东西,为什么可以放进同一个向量空间?eBay 论文的做法是:用内容特征描述用户(如偏好品类、品牌、价格带),再用站内行为(浏览、点击、加购、成交)监督这两种向量,最终让用户向量与商品向量可比——相似度高意味着「这个用户会喜欢这个商品」。
映射到 RAG 有两个很实用的推论:
- 用户向量不需要用户在提问。它是离线算好、在线复用的:用户的行为轨迹变化不频繁,可以定时批量重算并缓存。在线请求只做一次向量融合,成本可控。
- 融合权重 α 是可调旋钮。eBay 的做法里用户向量与 item 向量的相对权重就是超参;在 RAG 里同样如此——α 偏大则召回结果高度个性化但容易跑偏,α 偏小则退化成非个性化检索。α 应当由画像置信度决定:画像越新越可信,α 越大。
3. 知识库分层与「先过滤后排序」
私有内容(个人收藏、历史对话、个人笔记)与公共内容(商品库、文档库)放在同一套向量空间里时,语义相似度无法区分「这是给谁的」。所以顺序必须是:
用户请求 -> 作用域过滤(用户私有库 + 公共库)
-> 各自语义召回
-> 融合 / rerank(可加用户特征)
-> 生成
作用域过滤必须在语义排序之前,否则私有库的小向量会挤占公共候选的名额。
4. 缓存键必须带用户特征
语义缓存的判据通常是「query 向量相似度超过阈值就命中」。一旦引入个性化,这个判据就不充分了:同一个 query 对不同用户答案不同,但 query 向量完全一样。正确做法是把用户身份或用户向量纳入缓存键的构造:
- 粗粒度做法:键里加
userId或租户 ID,命中率高但共享率低; - 细粒度做法:键里加用户向量的哈希——画像相近的用户共享缓存,兼顾命中率与正确性;
- 同时要注意缓存失效:画像更新后旧键下的答案就不再正确,需要把用户向量纳入失效策略。
5. 什么时候不值得做个性化
- 画像置信度低时:新用户没有行为数据,强行个性化会把噪声放大;此时应退化成「全局召回 + 让模型主动追问澄清」。
- 场景本身无差别时:客服 FAQ、工具调用等场景,个性化收益接近零,而画像维护成本照付。
- 成本不允许时:Rerank 侧个性化最贵,若链路的瓶颈在成本,应先做 Prompt 侧。
规范实现:
type UserProfile = {
userId: string;
userVector: Float32Array; // 离线批量算好、在线复用
confidence: number; // 画像置信度,决定融合权重
updatedAt: number;
};
// 1) 融合用户向量与 query 向量:权重由画像置信度决定
function fuseQueryVector(queryVec: Float32Array, u: UserProfile): Float32Array {
const alpha = 0.2 + 0.3 * u.confidence; // 置信度低时自动收敛到弱个性化
const out = new Float32Array(queryVec.length);
for (let i = 0; i < out.length; i++) {
out[i] = (1 - alpha) * queryVec[i] + alpha * u.userVector[i];
}
return l2Normalize(out);
}
// 2) 作用域过滤先行,再做语义召回
type Scope = 'private' | 'public';
async function retrieve(
queryVec: Float32Array,
user: UserProfile,
topK: number
): Promise<Hit[]> {
const fused = fuseQueryVector(queryVec, user);
const privateHits = await vectorStore.query({
vector: fused,
filter: { scope: 'private', ownerId: user.userId }, // 私有库必须带 owner 约束
topK,
});
const publicHits = await vectorStore.query({
vector: fused,
filter: { scope: 'public' },
topK,
});
// 融合两路结果;Rerank 阶段再叠加用户特征
return rerank([...privateHits, ...publicHits], user);
}
// 3) 缓存键必须包含用户特征,否则个性化请求会串味
function cacheKey(query: string, user: UserProfile): string {
const userBucket = hash(l2Normalize(user.userVector).slice(0, 16)); // 画像相近的用户共享桶
return `${hash(query)}:${userBucket}`;
}
面试官视角:
- 必答:① 说清三个注入点(Prompt / 向量 / Rerank)及其取舍;② 说清用户向量的离线计算、在线融合这条工程路径;③ 说清知识库分层与「作用域过滤先于语义排序」;④ 指出缓存键要带用户特征这条容易漏的坑。
- 加分项:能说出 eBay 论文「把商品与用户嵌入同一空间、用内容特征 + 站内行为算用户向量」的机制,并能说明它对冷启动的缓解作用;能说出融合权重 α 应由画像置信度决定;能说清个性化与「主动追问澄清」的分工(新用户画像弱时更适合后者)。
- 常见误区:把个性化做成「把用户信息塞进 prompt 就完事」——那样只影响生成、不影响召回;把用户画像当成静态且永远正确的;把缓存命中率当成越高越好而忽略串味;认为作用域过滤可以放在 rerank 之后;把私有库和公共库混在一个向量空间里「靠 rerank 区分」。
延伸阅读:
- Personalized Embedding-based e-Commerce Recommendations at eBay(arXiv:2102.06156,2021-02-11;商品与用户嵌入同一向量空间、用户向量由内容特征与站内行为共同算出,以及冷启动问题)
- CoALA: Cognitive Architectures for Language Agents(arXiv:2309.02427,2023-09-05;工作记忆与长期记忆的语义 / 情节 / 程序三分,个性化记忆的认知架构视角)
- Agent Interview Hub:高频拷打题 - 牛客热帖(L2 面经来源,「如何做个性化 RAG?」一题给出画像注入 / 检索个性化 / Rerank 个性化 / 知识库分层四层做法,「如何做缓存?」一题指出带个性化因素的请求需把用户特征纳入缓存 key)