Context Engineering 与大模型应用开发 Context Engineering & LLM Applications
2026 年最重要的一次认知升级:决定系统成败的不再是「提示词写得好不好」,而是「模型每一轮到底看到了什么」。上下文工程(Context Engineering)负责设计这些内容;驾驭工程(Harness Engineering)负责安排模型的运行框架——记忆、工具、多步规划与失败恢复。这两个词是 2026 年 AI 工程的核心,也是就业需求增长最快的方向。一个常被低估的事实:同样的模型,有良好上下文工程与 Harness 的团队能跑通 6 步以上的自主流程,没有的团队停留在单次问答。
阶段总览
- 能设计完整的上下文:系统提示、工具定义、检索文档、记忆、历史与 Token 预算
- 掌握 RAG 全流程每个环节的取舍:切分、嵌入、索引、混合检索、重排、生成与评测
- 能做进阶 RAG:查询改写、HyDE、多查询、查询路由、Agentic RAG、GraphRAG、RAPTOR
- 能设计分层记忆系统(工作 / 会话 / 长期)与上下文压缩策略
- 理解长上下文 vs RAG 的四维对比,知道何时可以不用 RAG
- 能实现可靠的结构化输出与工具调用,并掌握约束解码与解析失败的降级路径
- 会做模型路由、prompt caching 与语义缓存,并核算线上成本
Harness Engineering(驾驭工程):设计模型运行的框架(多步循环、状态与记忆、工具编排、失败恢复、人工审批)。
前者决定「这一轮它能看到什么」,后者决定「整个任务它怎么推进」。2026 年行业判断:同样的模型,有良好 Harness 的团队能跑通 6 步以上的自主流程,没有的团队停留在单次问答。
| 周次 | 主题 | 交付物 |
|---|---|---|
| 第 1 周 | 上下文工程定义与 Prompt 结构 | 带 Token 预算与污染防护的上下文组装器 |
| 第 2 周 | RAG 全流程:切分 / 嵌入 / 索引 / 检索 / 重排 / 生成 | 企业知识库问答(含引用与评估) |
| 第 3 周 | 查询理解 + 进阶 RAG | 查询改写 + 混合检索 + 重排 + GraphRAG 实验 |
| 第 4 周 | 记忆系统 + 长上下文 vs RAG | 分层记忆 + 压缩,长上下文对比报告 |
| 第 5 周 | 结构化输出与工具调用 + 缓存 | 带约束解码、降级与缓存的工具框架 |
| 第 6 周 | 成本、路由与工程质量 | 路由 / 缓存 / 成本核算报告 |
1. 上下文工程:设计模型每轮看到的一切
学习路径
- 读 1.1:掌握少而精原则与上下文腐坏(污染 / 干扰 / 冲突)三类风险
- 跑内置代码:构造一段含污染的上下文观察输出被带偏
- 完成动手练习:按四类内容配比重写一段 system prompt并对比
- 对接 M13:给 RAG 生成设定上下文预算,压缩质检剔除污染片段
核心知识点详解
- 少而精优于多而杂:上下文工程的核心是少而精。塞无关内容会稀释关键信息(context rot / 上下文腐坏)。量化:同样的 200 token 关键证据,在 2K 窗口占比 10%,在 128K 窗口只有 0.2%——注意力份额掉
64 倍。先过滤、再注入。 - 四类上下文的预算配比:系统指令 5%–10%、检索证据 20%–40%、对话历史 10%–30%、工具结果 10%–30%。哪类「放多了」会怎样要背下来:系统被噪声淹没、证据无关片段抢注意力、旧话题干扰当前任务。比例 = 重要性排序的工程落地。
- 三类上下文问题:污染(不相干内容混入,拉低信噪比)、干扰(错误 / 矛盾片段压过正确片段)、冲突(多份证据彼此矛盾,模型乱编或随机选边)。共同根因都是塞得多、筛得不够——解法不是更大窗口,而是更狠的过滤与重排。
- 常见坑:以为「长上下文 = 可以不做工程」,把全部资料无脑塞进 128K 窗口,成本线性涨、关键信息被稀释、TTFT 变长。能压缩就压缩,保留关键 margin 给真正需要的证据。
学习路径
- 读 1.2:吃透 system / user / assistant 角色语义与放置位置
- 跑内置代码:把示例放 system vs assistant 对比遵循差异
- 完成动手练习:用 XML 标签分块重构多段指令
- 对接 M13:把检索到的文档按角色与标签规范注入生成 prompt
核心知识点详解
- system 承载持久规则:system 放跨轮稳定、精简的规则(角色、输出格式、安全边界),置于最前以命中 token 缓存(前缀必须逐 token 相同才能命中)。system 里塞易变内容会导致缓存全部失效。
- few-shot 示例放 assistant:示例对话最好以 assistant 角色呈现(与真实问答同构,而非包进 system / user),让模型看到「该长什么样」。示例应格式统一,且难例放最后压阵,利用 recency bias。
- user 承载具体任务 + XML 标签:user 放当轮任务与检索到的证据;多段指令用 XML 标签分块(
//)比自然语言更易被拆解遵循。角色语义 = 「谁负责稳定、谁负责当轮、怎么标段」。 - 常见坑:把易变的检索结果塞进 system,导致前缀缓存失效 + 上下漂浮;或示例混用 user/assistant 角色,模型参照错位、格式漂移。按角色语义放对位置,示例统一放 assistant。
学习路径
- 读 1.3:掌握 recency bias、难例置后、2–5 个与格式一致四要点
- 跑内置代码:交换示例顺序观察位置偏置对输出的影响
- 完成动手练习:为 RAG 场景挑 few-shot 并经格式统一后自测
- 对接 M13:把 few-shot 固化进检索增强的生成模板并对比首卷
核心知识点详解
- recency bias / 位置偏置:模型对后出现的示例记忆更强、先出现的示例注意力被稀释——这就是 recency bias。可用「交换示例顺序」实验验证:同一组示例换序,输出在难例上的表现随之改变。选示例要刻意利用这个偏置。
- 难度排序 + 数量定档:难例 / 边界例放最后(压阵承受最新注意力),简单例放前。数量 2–5 个足够——太少学不到模式,太多稀释注意力且 token 成本线性涨。
- 示例格式必须一致:所有示例的输入输出结构、标注、字段命名须完全一致,否则模型把「不一致」当规则学走。RAG 场景的 few-shot 要固化进检索增强的生成模板并做格式统一。
- 常见坑:示例越长越细越好、堆 10 个以上——注意力被稀释、成本翻倍;或示例间格式漂移(有的带引用有的不带),模型混学。用换序 + 数量消融证明自己的选择合理。
学习路径
- 读 1.4:比较 XML / Markdown / JSON 对遵循率的影响与约束解码原理
- 跑内置代码:用约束解码与自由文本各生成一次比较合法率
- 完成动手练习:为你的输出定义 JSON Schema 并做 think / answer 分离
- 对接 M13:让引用溯源输出走 strict 解码,结构合法且语义正确
核心知识点详解
- XML > 自然语言:对多段结构指令,XML 标签(
//)的遵循率通常高于等价的自然语言描述。格式越硬(标签清晰、枚举放死),遵循率越高——用约束解码 + JSON Schema 把格式「焊死」。 - 约束解码 / JSON Schema:
constrain decode(Grammar / FSM / JSON 模式)在采样时只允许合法 token,结构合法率可逼近 100%(字段不缺、类型不错、无多余键)。它只保证结构合法,不保证语义正确——语法治这个。 - think / answer 分离:把「推理过程」与「最终答案」分开(
),思维链受控、答案结构化、便于解析与引用溯源。RAG 的引用输出应走 strict 解码 + 结构合法且语义经校验。... ... - 常见坑:误以为约束解码保证语义正确:金额 / 日期 / 单位仍可能算错,需再用业务规则 /
Pydantic校验。结构打标 + 语义校验要分开做,别把「合法」当「正确」。
学习路径
- 读 1.5:背下系统 / 检索 / 历史三类内容的预算配比与压缩顺序
- 跑内置代码:把一段超预算上下文压缩到目标 token 内并观察信息损失
- 完成动手练习:为一次真实查询分配 token 预算
- 对接 M13:按预算先压缩的规则实现生成侧上下文压缩
核心知识点详解
- 三段的预算配比:系统 5%–10%、检索 20%–40%、历史 10%–30%。这是一张「注意力预算表」:系统精简稳定、检索宁少而精(重排 + 去冗余)、历史滚动窗口 + 摘要。先给真实查询算一次各段占比。
- 超预算先压历史:压缩顺序通常是先压对话历史(滚动摘要,可压到 1/8–1/15)、再精简工具定义与系统说明、最后才砍检索证据——因为检索最接近答案。一句原则:
超预算 → 先压历史,再做检索注入。 - 压缩的量化:滚动摘要「相关结论提取」+ 保字段,可比原始上下文压缩到 1/8–1/15 仍保留关键结论;衡量信息保留用「原上下文能否复原答案」的回溯测试而非盯着 token 数。
- 常见坑:只算「总 token 够不够窗口」,不算各段占比——检索证据被历史挤到 5% 以下,答案漂了还不知道原因。先核算占比,再定压缩顺序,再注入检索。
学习路径
- 读 1.6:理解 Prompt 工程只是上下文工程的子集与边界
- 跑内置代码:制造指令与示例冲突,观察示例优先于指令
- 完成动手练习:把一条 Prompt 版本化并记录每次改动的影响
- 对接 M13:把 Prompt 模板纳入仓库版本管理并挂到 CI 回归
核心知识点详解
- Prompt 工程是上下文子集:Prompt 工程关注「这一句话怎么写」(措辞 / 示例 / 格式);上下文工程关注「这一轮模型看到的全部」——系统提示 + 工具定义 + 检索文档 + 记忆 + 历史 + 预算分配。2026 重心已从措辞转向注入与组织。
- 指令 vs 示例,示例优先:构造「指令说 A、示例做 B」的冲突实验:模型通常按示例执行而非严格指令。原因:示例提供了可复制的分布先验。所以想让模型稳定照做的行为,宁可做成 few-shot 也不只写规则。
- Prompt 当代码版本化:Prompt 不再是一次性文本,要进 Git 版本管理 + CI 回归测试——改一行能复现地说明影响(用好/坏样本集做前后对比)。这正是「工程」区别于「灵机一动」的标志。
- 常见坑:把薄弱语义全押在更长的指令里,不验证就上线(改了自己都不知道);或不版本化,出问题无法回滚定位。指令冲突时不真实验证「示例是否真的优先」,靠猜。
1.1 定义与原则:稀缺窗口与少而精
上下文工程的定义极其朴素:设计单次模型调用中,模型能看到的所有内容,以及它们如何组织。它不是「写好一个 Prompt」,而是「在有限的上下文窗口里,把最相关的信息以最不易被误解的方式放进去」。核心原则是少而精优于多而杂——向窗口里塞无关内容,会稀释关键信息的注意力(即「上下文腐坏 / context rot」),并线性推高成本与延迟。
| 上下文的四类内容 | 典型占比 | 配比原则 | 放多了会怎样 |
|---|---|---|---|
| 系统指令 | 5%–10% | 稳定、精简、放最前以命中缓存 | 被后续噪声淹没,指令失效 |
| 检索证据 | 20%–40% | 宁少而精,重排 + 去冗余 | 无关片段抢走注意力,答案漂移 |
| 对话历史 | 10%–30% | 滚动窗口 + 摘要 | 旧话题干扰当前任务 |
| 工具结果 | 10%–30% | 只保留本轮需要的 | 工具过多导致误选 / 超预算 |
python# 上下文腐坏(context rot):塞得越多,关键信息越容易被稀释
def share(relevant_tokens, total_tokens):
return relevant_tokens / total_tokens # 关键信息的「注意力份额」近似
for total in [2_000, 8_000, 32_000, 128_000]:
print(f"总 token={total:>7} 关键信息占比≈{share(200, total):.3f}")
# 预期输出:2000 -> 0.100;8000 -> 0.025;32000 -> 0.006;128000 -> 0.002
# 结论:同样 200 token 的关键证据,在 128K 窗口里的注意力份额只有 2K 窗口的 1/64。
# 这就是「长上下文 != 可以不做工程」的量化依据——先过滤、再注入。
1.2 Prompt 结构:system / user / assistant 的角色语义
Prompt 不是一段自由文本,而是由三种角色消息按训练时的语义约定组成的。模型在预训练与对齐阶段被教成:system 是「持久的人格与规则」、user 是「当前任务与输入」、assistant 是「模型此前的输出,也会被继续预测」。搞混角色会让对齐失效。
| 角色 | 训练时的语义 | 常见误用 |
|---|---|---|
| system | 设定角色、边界、输出格式、长期规则;权重最高 | 把大段用户数据塞进 system(应放 user);忽视其优先级导致指令被覆盖 |
| user | 承载本轮任务、问题与附件 | 把系统规则也写进 user,导致指令层级混乱 |
| assistant | 模型历史输出;few-shot 示例常伪装成它 | few-shot 示例写错角色,模型学不到示范;把待填的空也标成 assistant |
python# 一个角色语义清晰、用 XML 标签划分区块的 Prompt 模板
messages = [
{"role": "system", "content": (
"你是严谨的合规助手。规则:\n"
"1. 只依据 <evidence> 回答,资料不足必须说不知道。\n"
"2. 输出 JSON,字段见 schema。\n"
"3. 不得编造条款编号。"
)},
{"role": "user", "content": (
"<question>这份合同里乙方的违约金上限是多少?</question>\n"
"<evidence>{{retrieved_chunks}}</evidence>"
)},
# assistant 角色留给「模型自己生成」或「few-shot 示范」
]
# 关键:few-shot 示例要用 assistant 角色承载,模型才会把它当成
# 「应该模仿的输出范式」,而不是当成要回答的问题。
python# 角色语义错误的典型后果:规则、数据、示范必须各归其位
good = [
{"role": "system", "content": "只依据 <evidence> 回答,不足则拒答"},
{"role": "user", "content": "<question>违约金上限?</question>"},
{"role": "assistant", "content": '{"answer": "见条款 8.2"}'}, # few-shot 示范
]
# 常见错误写法:
# - 把 <evidence> 整段塞进 system -> 模型把数据当规则(越权 / 指令被覆盖)
# - 把「待填的答案」也标成 assistant -> 模型以为已给定,提前停止
# 经验:system 占比 5%–10%,越短越稳;示范一律用 assistant 承载。
... 这类显式标签包裹。模型对结构化边界的遵从度明显高于散文式分隔,且能降低把证据误当指令的风险。1.3 Few-shot 示例的选择与排序
Few-shot 不是「随便给几个例子」,选择和排序都会显著影响输出。顺序之所以重要,是因为自回归模型存在 recency bias(更关注靠近末尾的 token),且会把示例当成分布的一部分。把最难、最该模仿的示例放在最后,效果通常最好。
| 维度 | 做法 | 坑 |
|---|---|---|
| 选择 | 挑与当前输入同分布、覆盖边界情形的示例 | 随机挑导致示范不代表真实分布 |
| 排序 | 简单在前、最难 / 最该模仿的在后(recency bias) | 把好例子埋在中间被忽略 |
| 数量 | 2–5 个通常足够;过多占预算且稀释 | 堆 20 个示例挤掉检索证据 |
| 一致性 | 示例格式必须与目标输出格式完全一致 | 示例与要求格式冲突,模型困惑 |
| 多样性 | 覆盖典型失败模式 | 只给正例,模型遇到异类就崩 |
python# Few-shot 排序的朴素实现:把「最难示范」放到最后
def order_examples(examples):
# 启发式:长度越短越「简单」,越靠后越「该被模仿」放最后
return sorted(examples, key=lambda e: e["difficulty"]) # 难度升序,最难在末尾
few_shot = [
{"role": "user", "content": "把 '订单已发货' 分类"},
{"role": "assistant", "content": '{"category": "物流"}'},
{"role": "user", "content": "客户说 '我要退款' 而且很生气"},
{"role": "assistant", "content": '{"category": "退款", "urgency": "高"}'}, # 难例,放最后
]
# 注意:示例与真实问题之间不要留空行空隙过大,否则模型可能误以为示例已结束。
python# few-shot 顺序的影响:把「最该模仿的示例」放到最后
def build_fewshot(examples, hardest_first=False):
ex = sorted(examples, key=lambda e: e["difficulty"])
if hardest_first:
ex = ex[::-1] # 难例放最前
msgs = []
for e in ex:
msgs.append({"role": "user", "content": e["q"]})
msgs.append({"role": "assistant", "content": e["a"]})
return msgs
# 经验(示意):难例放末位时,复杂输入的格式遵循率约 +5~10 个百分点(recency bias);
# 但示例超过 5 个后收益趋平甚至下降,因为会挤占检索证据的 token 预算。
1.4 格式约束:XML / Markdown / JSON 对遵循率的影响
输出格式的「硬度」直接影响模型是否遵循。研究与实践共识是:结构化标签 > 自然语言描述,且越接近模型训练时见过的格式,遵循率越高。
| 格式 | 遵循率经验 | 适用 | 注意 |
|---|---|---|---|
| 自然语言指令 | 最低(易被自由发挥带偏) | 开放创作、闲聊 | 不适合需要可解析输出的场景 |
| Markdown / 列表 | 中—高 | 人类可读报告、步骤 | 仍需后处理提取 |
| XML 标签 | 高 | 需明确边界的结构(证据 / 章节) | 标签要成对、无嵌套歧义 |
| JSON(无 schema) | 高 | 程序消费 | 仍可能漏字段 / 类型错 |
| JSON / 约束解码 | 最高(接近 100%) | 强结构化输出 | 需要结构化输出能力或 grammar 约束 |
python# 用 XML 标签强制模型把「推理」与「结论」分开,便于程序提取
template = """回答前先在 <think> 中列出依据,最后只输出 <answer> 里的 JSON。
<think>{reasoning}</think>
<answer>{{ "category": "...", "confidence": 0.0 }}<answer>"""
# 经验:把「要求」与「示例格式」都写进 system,并在 user 里用标签
# 包裹输入,模型的遵循率比纯散文高出一个档次。
| 输出格式 | 格式遵循率(经验) | 程序可直接解析率 |
|---|---|---|
| 自然语言描述 | 最低 | 需正则,常失败 |
| Markdown 列表 | 中—高 | 需后处理提取 |
| XML 标签 | 高 | 高(标签成对即可抽取) |
| JSON(无 schema) | 高 | 中(仍可能漏字段 / 类型错) |
| JSON Schema / 约束解码 | 接近 100% | 接近 100%(结构与类型都被约束) |
1.5 上下文的组成部分与 Token 预算
| 组成部分 | 内容 | 典型预算占比 | 优化手段 |
|---|---|---|---|
| 系统提示 | 角色、规则、输出格式、边界 | 5%–10% | 精简、模板化、把稳定部分放前面以命中缓存 |
| 工具定义 | 函数签名、参数 schema、说明 | 5%–15% | 只暴露当前任务需要的工具,避免工具过多导致误选 |
| 检索文档 | RAG 召回的片段 | 20%–40% | 重排 + 去冗余 + 压缩,宁少而精 |
| 记忆 | 长期事实、用户偏好、先前结论 | 5%–15% | 分层存储,按相关性注入,定期摘要压缩 |
| 对话历史 | 多轮交互记录 | 10%–30% | 滚动窗口 + 摘要 + 关键信息结构化提取 |
| 当前输入 | 用户本轮问题与附件 | 10%–20% | 附件按需解析,不要无脑全文塞入 |
python# Token 预算的显式分配:超预算必须压缩,而不是硬塞
budget = {"system": 2000, "tools": 3000, "retrieved": 12000,
"memory": 3000, "history": 8000, "user": 4000}
cap = 32000
used = sum(budget.values())
print("预算合计:", used, "上限:", cap, "余量:", cap - used) # 32000 32000 0
# 预期输出:合计 32000 = 上限,无余量 -> 任一部件超支都要靠压缩腾挪。
# 经验配比:检索 20%–40%、历史 10%–30%、系统 5%–10%;
# 先按占比分配,再按相关性把预算填满,而不是「先塞后砍」。
python# 上下文组装器:把「该放什么」变成显式的、可测量的工程
from dataclasses import dataclass
@dataclass
class Budget:
total: int = 32_000
system: int = 2_000
tools: int = 3_000
retrieved: int = 12_000
memory: int = 3_000
history: int = 8_000
user: int = 4_000
def assemble(system_prompt, tools, retrieved_docs, memories,
history, user_input, budget=Budget()):
parts = []
parts.append(truncate(system_prompt, budget.system)) # 系统提示最稳定,放最前
parts.append(render_tools(select_relevant_tools(tools, user_input), budget.tools))
# 检索文档按相关性排序,逐条加入直到超出预算(宁缺毋滥)
parts.append(pack_by_score(retrieved_docs, budget.retrieved, key=lambda d: d.score))
parts.append(pack_by_recency(memories, budget.memory)) # 记忆按相关性 + 新鲜度
parts.append(summarize_if_needed(history, budget.history)) # 历史超限则摘要
parts.append(user_input)
prompt = "\n\n".join(p for p in parts if p)
assert est_tokens(prompt) <= budget.total, "超预算,必须压缩而不是硬塞"
return prompt
1.6 Prompt 工程没有消失,只是变成了子集
2026 年招聘数据里,prompt engineering 的岗位数仍然远多于 context engineering(约 2.2 万 vs 700),但重心已经转移。结论:简历上两个词都要有,能力上以上下文工程为主。写 Prompt 的技巧依然有用,但它只是「上下文工程」中的一小块。
- 仍然有效的技巧:明确角色与目标、给出少量示例(Few-shot)、要求分步推理、指定输出格式、给出拒绝边界。
- 已经被淘汰的思路:堆砌「你是世界顶级专家」这类形容词、把规则写成一大段散文、指望一条万能 Prompt 覆盖所有场景。
- 2026 的做法:把 Prompt 当代码管理——版本化、有测试、有 CI 回归、A/B 对比。Prompt 变更要能被看见(快照测试)。
python# 把 Prompt 当代码管理:版本化 + 快照测试
PROMPTS = {
"extract_v2": "从工单文本抽取 category / urgency / order_id,输出 JSON。",
"classify_v1": "把用户消息分类为 退款 / 物流 / 质量 / 其他。",
}
# 每次改动要做三件事:(1) 记版本 (2) 跑回归集 (3) 对比新旧命中率。
# 经验:加一道快照测试(固定 50 条输入 + 期望输出字段),
# 能在改 prompt 时第一时间发现「改坏了」,比人肉 review 可靠得多。
python# 结构化输出:让模型输出可被程序消费,而不是靠正则去猜
from pydantic import BaseModel, Field
from typing import Literal
class Ticket(BaseModel):
"""客服工单的结构化抽取结果"""
category: Literal["退款", "物流", "质量", "其他"] = Field(description="问题类别")
urgency: Literal["低", "中", "高"] = Field(description="紧急程度")
order_id: str | None = Field(default=None, description="订单号,没有则为 null")
summary: str = Field(max_length=120, description="一句话摘要")
# 两种落地方式:
# A) 用模型的 structured output / JSON schema 能力直接约束解码(最可靠)
# B) 让模型输出 JSON 文本,再用 Pydantic 校验;失败则重试或降级
def extract(client, text: str) -> Ticket | None:
for attempt in range(3):
raw = client.chat(messages=[{"role": "user", "content": text}],
response_format={"type": "json_schema",
"json_schema": Ticket.model_json_schema()})
try:
return Ticket.model_validate_json(raw) # 校验
except Exception as e:
log.warning("schema 校验失败 attempt=%d: %s", attempt, e)
return None # 降级:转人工 / 走规则
1.7 动手练习与自测
- 用 1.1 的份额模型,算出「500 token 关键证据放进 64K 窗口」的注意力份额,并说明该不该直接塞。
- 按 1.5 的预算表给一个「法律合同问答」重新分配 Token 预算(检索 / 历史 / 系统),说明依据。
- system / user / assistant 三个角色各自的语义是什么?各给一个常见误用。
- 同一个模型,为什么「示例格式写错」比「指令写错」更致命?
- Prompt 工程与上下文工程的关系是什么?2026 年为什么重心转移?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 注意力稀释 | 500/64000 ≈ 0.008;应压缩或只注入相关片段,不能直接塞 |
| 2 | 预算分配 | 法律问答证据比例调高(如检索 40%)、历史压缩;系统规则精简靠前以命中缓存 |
| 3 | 角色语义 | system=持久规则(权重最高)、user=当前任务与数据、assistant=历史输出/示范;误用见 1.2 表 |
| 4 | 示例优先 | 模型更倾向模仿示例而非遵守散文指令,格式错了会被系统性仿效 |
| 5 | 关系与趋势 | Prompt 工程是上下文工程的子集;限制系统表现的是「关键信息是否被正确注入」而非措辞 |
2. RAG:最落地的生产模式
学习路径
- 读 1.1 与 2.2:掌握递归切分参数与嵌入的 L2 归一化 / Matryoshka 截断
- 跑内置代码:对同一份文档用不同 chunk + overlap 对比召回的 recall@k
- 完成动手练习:按父子文档策略为你的语料划分索引单元
- 对接 M13:把切分与嵌入参数写进 RAG 管线并纳入消融对照
核心知识点详解
- 递归切分与 overlap:递归切分按分隔符层级(标题 → 段落 → 句子)切分;chunk 常用 256–512 token,overlap 设 10%–20%(避免切断语义让同一句话被拆两项)。参数直接影响 recall:
chunk 太小 → 上下文不足回不到目标;太大 → 向量被稀释。 - 父子文档策略:父子文档:大块「父」用于检索定位、小块「子」用于生成上下文。父块保语义完整、子块保细节精确,常用在表格 / 代码/长段落场景。判定依据就是 recall@k 的消融对比。
- L2 归一化 + Matryoshka 截断:嵌入向量要做 L2 归一化再用内积(否则长文本模长虚高误排);Matryoshka 截断(如 768→256 维)可省存储与算力、召回损失很小。参数选型都要靠
recall@k对比验证,而不是拍脑袋。 - 常见坑:嵌入没归一化就做内积,长文本向量模长虚高导致误排;或切分把表格 / 代码切断,召回到的片段不可用。用固定评测集 + recall@k 消融证明参数选择。
学习路径
- 读 2.3 与 2.4:理解 HNSW M / IVF nprobe / PQ 的代价与 BM25 稀疏检索
- 跑内置代码:用 HNSW 建索引并对比暴力检索的 Recall@10 与 QPS
- 完成动手练习:实现 RRF 融合(k=60)合并向量 + BM25 两路召回
- 对接 M13:把混合检索(向量 + BM25)+ RRF 接入主干管线
核心知识点详解
- 近似最近邻三件套:HNSW(
M=16常用)图索引,召回高但建索引 / 内存大;IVF(聚类倒排 +nprobe控制查多少个桶);PQ 乘积量化把向量压缩(约 64× 减内存)但引入精度损失。三者是「召回-速度-内存」三角,要实测权衡。 - BM25 稀疏检索:向量擅长语义近似,但对必须逐字匹配(订单号 / 型号 / 人名)弱;BM25 稀疏向量按词频 + 逆文档频率正好补短板。两类检索分数量纲不同,不能直接相加。
- RRF 融合(k=60):RRF 按排名融合两个检索器结果:
score(d) = Σ 1/(k + rank_i(d)),k常取 60。它只依赖排名不依赖分数量纲,天然解决向量 / BM25 分数量纲不可比。混合检索 = 向量 + BM25 → RRF → 取 top-k。 - 常见坑:只用向量检索,编号 / 型号 / 专有名词召回不到;或直接把向量分数 + BM25 分数相加(量纲不同必然错排)。用 RRF 融合并按
Recall@10 / QPS实测。
学习路径
- 读 2.5:分清 cross-encoder 与 ColBERT 晚期交互的机制与延迟
- 跑内置代码:把重排器加进管线,记录召回 100 重排取 8 的效果与耗时
- 完成动手练习:对比召回 100 直接取 vs 重排取 8 的端到端指标
- 对接 M13:把交叉编码器重排接入管线并量化它对精度的增益
核心知识点详解
- cross-encoder 重排:交叉编码器把「query + 文档」拼成一个序列喂给模型,能精确建模两者交互,重排精度高于双塔向量检索,但只能两两配对、无法预先缓存,每对都要一次 forward。
召回 100 条重排取 top 8是典型用法。 - ColBERT 晚期交互 / MaxSim:ColBERT v2 给 query 与文档各过一个单塔得到 token 向量,再做晚期交互:对每 query token 与文档所有 token 取最大相似度
MaxSim聚合。可把文档向量预计算索引化,兼顾精度与速度,是 cross-encoder 与双塔之间的折中。 - 多花 100–300ms 的账:重排让 top-k 不变但把「真正命中的例子」提到前面,常常是对端到端精度提升最高、成本却最低的一个环节。
召回 100 → 重排取 8只需对 100 对做 cross-encoder,多花 100–300ms 是否值取决于它对 Answer Relevancy 的提升是否超过延迟预算。 - 常见坑:跳过重排直接取向量 Top-1,答案质量不稳;或对已很小的 top-k 也重排,白花延迟无收益。先召回 100,做「直接取 vs 重排取 8」的端到端对比再决定。
学习路径
- 读 2.6:掌握引用 chunk id、资料不足拒答与证据冲突的生成策略
- 跑内置代码:构造证据冲突样例观察引用与拒答行为
- 完成动手练习:实现 faithfulness 检查并标记低可信输出
- 对接 M13:全部论断带引用溯源且打不开引用时走降级
核心知识点详解
- 引用 chunk id:生成的每条论断都要能对应到具体 chunk(如
[c3]),让用户可回溯到原文。做法:要求模型在答案后列出引用 id,若打不开对应引用则走降级,从源头遏制「自信的错误答案」。 - 资料不足则拒答:检索结果无法支撑回答时,模型应明确 拒答 / 声明信息不足,而不是强行编造。这是 RAG 相对裸 LLM 的核心收益之一;用「资料不足集」测拒答率。
- 证据冲突处理:当多片段互相矛盾时,模型要么报告冲突、要么按「更新 / 权威 / 多数」策略择优并注明。被动让模型随机选了会得出不可靠答案——要把冲突显式送进 prompt 让模型裁决。
- 常见坑:答案没有引用、也无拒答逻辑,用户不辨真伪就信了;或证据冲突时模型随机选边,连自己都不知道依据哪段。加
faithfulness检查 + 低可信标记,凡引用即溯源、无支撑即拒答。
学习路径
- 读 2.7:串起解析→切分→嵌入→索引→检索→重排→生成七环节
- 跑内置代码:把整条管线端到端跑通并看各环节耗时分布
- 完成动手练习:用 recall@5 > 0.8 作为检索质量红线做一次体检
- 对接 M13:交付完整 pipeline 并给出承载全部环节的组件清单
核心知识点详解
- 七环节流水:
解析 → 切分 → 嵌入 → 索引 → 检索 → 重排 → 生成与引用。每个环节的输入输出要能讲清:解析出纯文本 → 切分成 chunk → 嵌入成向量 → 建索引 → 混合检索召回 → 重排精选 → 生成带引用的答案。一条管线就是这七步的接线。 - retrieval 红线 recall@5 > 0.8:离线体检可以用 recall@5 > 0.8 作为检索质量红线:前 5 个召回里应覆盖到目标片段 ≥ 80% 的查询。没到这个线,重排再好也救不了——先修检索再谈上层。
- 各环节耗时分布:端到端跑一遍并看各环节耗时分布:常见大头是 prefill(检索结果的解码)与检索本身。先用 profile 找出瓶颈,再针对性优化(缓存、并行、压缩),不要上来就换模型。
- 常见坑:端到端只测最终答案,不分开测检索——出问题无法定位是召回不够还是生成臆造;或没有 recall 红线 QA 就上线。先按七环节逐级给输入输出,再用 recall@5 体检。
学习路径
- 读 2.8 与 2.9:了解进阶结构与检索 / 生成分离评测口径
- 跑内置代码:先用 Recall@k / MRR 测检索侧,再单独测生成侧
- 完成动手练习:判断当前任务是否需要进阶结构,给出依据
- 对接 M13:落地逐组件消融表,说明每个组件的边际贡献
核心知识点详解
- 检索侧 vs 生成侧分开评:检索侧指标:Recall@k(目标片段是否被召回)、MRR(命中的排名多靠前)、Hit Rate;生成侧:Faithfulness(是否臆造)、Answer Relevancy(是否切题)。分开评测才能定位问题在「没召回」还是召回了但「说错」。
- 进阶结构按需上:基线 RAG 打底后再按需叠加:查询改写 / HyDE 提升短 query、多跳分解解决多步、Agentic RAG 让 agent 自主检索、GraphRAG 做全局总结、RAPTOR 摘要树做层级概览。盲目全上只会增加延迟与复杂度。
- 消融表证明边际贡献:落地时逐组件做消融(开 / 关某一组件,其余不变)并记录指标变化——证明每个组件的边际贡献,而不是「一招大杂烩」说不清谁起了作用。示例表:
基线 | +重排 | +RRF | +HyDE各列 recall / faithfulness。 - 常见坑:只评估最终答案一个数,出问题无法定位在检索还是生成;或把所有进阶模块堆上去不加消融,指标涨了却不知道是哪个起效。分开评 + 逐组件消融是硬要求。
2.1 切分:策略与经验值
切分(chunking)是 RAG 质量的上游阀门——切坏了,后面重排也救不回来。没有「唯一正确」的切法,只有「适配文档结构与查询方式」的切法。
| 策略 | 做法 | 适用 | 代价 / 坑 |
|---|---|---|---|
| 固定长度 | 每 N 个 token 一刀切 | 纯文本、无结构 | 容易在句 / 表中间切断 |
| 递归切分 | 按段落 → 句子 → 词的层级切 | 多数通用文档 | 仍可能切断语义单元 |
| 语义切分 | 按 embedding 相似度断层切 | 长文、主题跳跃大 | 慢、对阈值敏感 |
| 父子文档 | 大块存、小块检索,召回后回贴大块 | 需要完整上下文生成 | 存储翻倍 |
| 小块检索大块生成 | 检索用小块保精度,生成用大块保完整 | 表格 / 长段落 | 需两套索引关联 |
经验值:通用文档 chunk size 取 256–512 token,overlap 取 10%–20%(如 256-token 块配 10%–15% 重叠 ≈ 25–40 token)。代码 / 法律 / 表格类文档可放到 512–1024 token 以减少结构切断。overlap 太小会丢失跨块语义,太大则冗余与成本上升。父子文档是当前生产里性价比最高的折中:检索用 128–256 token 的小块保证精度,召回后换成 1–2k token 的父块送给生成模型,兼顾召回率与上下文完整。
python# 递归切分 + 小块检索大块生成(父子文档)的朴素实现
def recursive_split(text, chunk=400, overlap=60):
# 按段落优先,再按句子,最后按词兜底
seps = ["\n\n", "\n", "。", " "]
pieces = [text]
for sep in seps:
out = []
for p in pieces:
if len(p) <= chunk:
out.append(p); continue
for sub in p.split(sep):
out.append(sub)
pieces = out
# 滑动窗口合并 + overlap
chunks, buf = [], ""
for p in pieces:
if len(buf) + len(p) <= chunk:
buf += p + " "
else:
chunks.append(buf.strip()); buf = buf[-overlap:] + p + " "
if buf: chunks.append(buf.strip())
return chunks
# 父子映射:每个小块的 metadata 记录它所属的父块 id,
# 检索命中后回查父块,把父块(更完整)喂给生成模型。
python# chunk 大小对召回的影响(经验曲线)
def recall(chunk_size):
if chunk_size < 128: return 0.72 # 太碎,语义不完整
if chunk_size <= 512: return 0.86 # 甜点区
if chunk_size <= 1024: return 0.80 # 偏大,噪声增多
return 0.70 # 过大,关键句被稀释
for cs in [64, 128, 256, 512, 1024, 2048]:
print(f"chunk={cs:>5} recall@5≈{recall(cs):.2f}")
# 预期输出:64->0.72;128/256/512->0.86;1024->0.80;2048->0.70
# 结论:通用文档 256–512 token 最稳;父子文档用 128–256 小块检索更精。
2.2 嵌入:模型选择与相似度
嵌入模型把文本压成固定维度向量,其质量直接决定召回上限。选择时要看三个维度:维度、多语言支持、领域适配。
| 嵌入模型 | 维度 | 多语言 | 备注 |
|---|---|---|---|
| bge-m3 | 1024 | 强(100+ 语言) | 支持稠密 + 稀疏 + 多向量,检索全能选手 |
| gte-Qwen2 | 1536 / 3584 | 强 | 中文场景常用,需部署 |
| e5-mistral-7b | 4096 | 中 | 质量高但模型大、慢 |
| voyage-3 | 1024 | 强 | 托管 API,按量计费 |
| text-embedding-3-large | 3072(可截断到 256) | 中 | 支持 Matryoshka,可压缩 |
| ColBERT Late Interaction | 每 token 独立向量 | 取决于底座 | 检索精度高,存储大(见 2.5) |
归一化与相似度:嵌入向量几乎总要先 L2 归一化到单位球。归一化后,余弦相似度 = 点积(内积),所以「余弦 vs 内积」之争在归一化后等价——但如果你忘了归一化,直接做内积就会被向量模长带偏(长文本向量模更大,内积虚高)。务必统一在入库和查询时都归一化。
pythonimport numpy as np
def l2_normalize(v):
return v / (np.linalg.norm(v, axis=-1, keepdims=True) + 1e-8)
def cosine(a, b):
a, b = l2_normalize(a), l2_normalize(b)
return float(np.dot(a, b)) # 归一化后等于内积
# 嵌入与生成模型「不同源」的注意事项:
# - 检索用 bge-m3,生成用 GPT / Gemini 是常态,二者语义空间不同,
# 这没问题(检索只看嵌入空间内部一致性)。
# - 但如果你用生成模型的 embedding 做检索,注意它可能未针对检索优化,
# 召回率远低于专用嵌入模型。不要为了省一个服务而牺牲召回。
python# 忘记归一化的代价:内积被向量模长带偏
import numpy as np
a = np.array([0.1, 0.2]) # 短文本方向向量(模长小)
b = np.array([10.0, 20.0]) # 长文本,方向相同、模长大
print("未归一化内积:", float(a @ b)) # 5.0(虚高)
print("余弦相似度:", float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))) # 1.0
# 预期输出:内积 5.0;余弦 1.0(方向完全一致)。
# 结论:入库与查询都要 L2 归一化;归一化后「余弦 = 内积」,
# 否则长文本向量模长虚高,会把不相关的长文档排到前面。
2.3 索引:HNSW / IVF / PQ
当文档量到十万级以上,暴力遍历所有向量(flat search)不可行,必须上近似最近邻(ANN)索引。主流三者各有取舍。
| 索引 | 关键参数 | 取舍 |
|---|---|---|
| HNSW | M(每层连边数,默认 16)、efConstruction(建图质量,100–400)、efSearch(查询召回,50–200) | 召回高、查询快;内存占用大(图结构),M 越大越准越占内存 |
| IVF | nlist(聚类中心数)、nprobe(查几个簇) | 内存省、可扩展;nprobe 太小召回掉,太大变慢 |
| PQ(乘积量化) | m(子量化段数)、bits(每段位数) | 把 4096 维压到几十字节,内存降 10–16 倍;有精度损失 |
| Flat(暴力) | 无 | 100% 召回;仅适合 < 10 万级 |
python# HNSW 的实用参数配置(以 hnswlib 为例)
import hnswlib
p = hnswlib.Index(space="cosine", dim=1024)
p.init_index(max_elements=1_000_000, ef_construction=200, M=16)
p.add_items(vectors, ids)
p.set_ef(64) # 查询时的 efSearch:越大召回越高、越慢
# 经验:
# M=16 是默认甜点;要更高召回可试 M=32(内存约翻倍)。
# ef_construction=200 平衡建图质量与建索引时间。
# efSearch=50~200:召回率不足先调它,再考虑加大 M。
# PQ 通常叠加在 HNSW 上做「压缩存储」,牺牲一点精度换内存。
python# HNSW vs PQ 的内存量级:为什么上亿向量必须量化
n, dim = 1_000_000, 1024
raw = n * dim * 4 / 1e9 # float32 原始向量
pq = n * 64 / 1e9 # PQ 压到 64 字节/向量
hnsw = raw * 1.5 # HNSW 图结构额外开销
print(f"原始向量 {raw:.1f} GB;HNSW 约 {hnsw:.1f} GB;PQ 约 {pq:.2f} GB")
# 预期输出:原始向量 4.1 GB;HNSW 约 6.1 GB;PQ 约 0.06 GB(压缩约 64x)
# 结论:内存吃紧时用 PQ/SQ,代价是召回微降;M=16 是默认甜点,要更高召回再加大 M。
2.4 检索:稠密 / 稀疏 / 混合 + RRF
检索是 RAG 的第一道关口,直接决定「该不该被看见的材料有没有被看见」。三种范式各有盲区。
| 检索范式 | 擅长 | 盲区 |
|---|---|---|
| 稠密(向量) | 语义相近、同义改写、概念匹配 | 精确术语、编号、型号、人名(需逐字匹配) |
| 稀疏(BM25) | 精确关键词、专有名词、编号 | 语义泛化、同义改写 |
| 混合(两者融合) | 同时覆盖语义与精确 | 实现稍复杂,需融合策略 |
python# 混合检索 + RRF 融合 + 重排:RAG 质量提升最明显的三步组合
from rank_bm25 import BM25Okapi
import numpy as np
def rrf_fuse(vec_rank, bm25_rank, k=60):
"""RRF:只依赖排名不依赖分数,天然解决两种检索器分数不可比的问题。
公式:score(d) = sum_{i in {vec,bm25}} 1 / (k + rank_i(d))"""
scores = {}
for r, doc_id in enumerate(vec_rank): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + r + 1)
for r, doc_id in enumerate(bm25_rank): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + r + 1)
return [d for d, _ in sorted(scores.items(), key=lambda x: -x[1])]
def retrieve(query, chunks, embedder, vdb, reranker, top_k=6, recall_k=60):
qv = embedder.encode(query)
vec_hits = vdb.search(qv, k=recall_k) # 1) 向量召回(语义)
bm = BM25Okapi([c.tokenized for c in chunks])
bm_hits = np.argsort(-bm.get_scores(query.split()))[:recall_k] # 2) 关键词召回(精确)
fused = rrf_fuse([h.id for h in vec_hits], [chunks[i].id for i in bm_hits])
cands = [chunk_by_id(chunks, i) for i in fused[:recall_k]]
return reranker.rerank(query, cands)[:top_k] # 3) 重排(性价比最高)
python# RRF 数值演示:两路检索分数不可比,但排名可以融合
def rrf(rank_lists, k=60):
s = {}
for rl in rank_lists:
for r, d in enumerate(rl):
s[d] = s.get(d, 0) + 1 / (k + r + 1)
return sorted(s, key=s.get, reverse=True)
vec = ["D3", "D1", "D5"] # 向量召回顺序
bm25 = ["D1", "D2", "D3"] # 关键词召回顺序
print("融合结果:", rrf([vec, bm25]))
# 预期输出:融合结果: ['D1', 'D3', 'D2', 'D5']
# D1 两路都靠前 -> 排第一;D3 次之。k 常取 60,只依赖排名、绕开量纲问题。
2.5 重排:cross-encoder 与 ColBERT 晚期交互
召回阶段为了快,用的是「独立编码」的向量;重排阶段为了准,用的是「query 与文档联合编码」的 cross-encoder,或保留 token 级交互的 ColBERT。
| 方法 | 机制 | 精度 | 延迟 / 成本 |
|---|---|---|---|
| cross-encoder | query+doc 拼一起过一遍 transformer | 最高 | 慢(不能预计算,每对现算),贵 |
| ColBERT v2 | 保留每 token 向量,late interaction 用 MaxSim 聚合 | 高(接近 cross-encoder) | 中等,token 向量存储大,可量化 |
| 无重排 | 直接取召回 Top-1 | 最低 | 最快 |
| 两阶段(召回 + 轻重排) | 向量召回 50–100 → 轻量重排 Top 5–8 | 高 | 性价比最高 |
python# ColBERT v2 晚期交互(late interaction):检索时延后到 token 级
# 思想:query 与 doc 各自先编码成 token 向量(可预计算、入库),
# 查询时再用 MaxSim 聚合,兼顾「可预计算」与「token 级精度」。
import numpy as np
def maxsim(q_tokens, d_tokens):
"""对 query 每个 token,取它与 doc 所有 token 的最大余弦,再求和。"""
s = 0.0
for q in q_tokens: # query token 向量(已归一化)
sims = q @ d_tokens.T # 与 doc 所有 token 的余弦
s += sims.max() # MaxSim:最匹配的 doc token
return s
# 召回阶段:只用 doc 的「单向量」(如 CLS 或平均)做粗排取 Top 50;
# 重排阶段:对这 50 个再算 MaxSim 精排。这是 ColBERT 快且准的关键。
python# 召回 + 重排的收益:为什么「召回 100 重排取 8」性价比最高
def precision_at_k(topk, relevant):
return sum(1 for d in topk if d in relevant) / len(topk)
relevant = {"A", "B", "C"}
recall_stage = ["x1", "x2", "A", "x3", "x4", "B", "x5", "C"] # 召回顺序不理想
after_rerank = ["A", "B", "C", "x1", "x2", "x3"]
print("重排前 Top-3 精确率:", round(precision_at_k(recall_stage[:3], relevant), 2)) # 0.33
print("重排后 Top-3 精确率:", round(precision_at_k(after_rerank[:3], relevant), 2)) # 1.00
# 预期输出:0.33 -> 1.00。重排把相关三条提到最前,直接决定最终答案质量。
# 延迟提示:cross-encoder 逐对现算,Top-100 重排约加 100–300ms,召回窗口别开太大。
2.6 生成:引用、拒答与证据冲突
检索到好材料只是半场。生成阶段要解决三件事:让答案可追溯(引用)、资料不足时老实说不知道(拒答)、多份证据矛盾时怎么办(冲突)。
| 问题 | 做法 | 失败表现 |
|---|---|---|
| 引用 / 归因 | 要求输出每段答案对应的 chunk id,并回链原文 | 答案无出处,无法核验 |
| 拒答策略 | 明确「资料不足则回答不知道」,并设置信阈值 | 硬编、幻觉式作答 |
| 证据冲突 | 让模型先列出矛盾点,再给最可能结论并标注不确定 | 随机站队、掩盖矛盾 |
| 长答案忠实 | 要求「每句话都能在证据中找到依据」 | 夹带外部知识(closed-book 偷跑) |
python# 带引用与拒答的生成 Prompt 设计
SYSTEM = (
"你是基于资料的助手。规则:\n"
"1. 只使用 <evidence> 中的信息,不得引入外部知识。\n"
"2. 每个结论后用 [n] 标注来源 chunk 编号。\n"
"3. 若资料无法回答,输出 '资料不足,无法确定'。"
)
def build(user_q, chunks):
ev = "\n".join(f"[{i}] {c.text}" for i, c in enumerate(chunks))
return [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"<question>{user_q}</question>\n<evidence>{ev}</evidence>"},
]
# 证据冲突时:让模型先输出 <conflict> 段列出矛盾,再给结论;
# 程序侧可据 conflict 标记决定是否升级人工。
python# 忠实度(faithfulness)检查:答案里的每个事实是否能在证据中找到
def faithfulness(claims, evidences):
ev = " ".join(evidences)
supported = sum(1 for c in claims if any(k in ev for k in c["keywords"]))
return supported / len(claims)
claims = [{"keywords": ["8.2", "违约金"]}, {"keywords": ["不可抗力"]}]
ev = ["条款 8.2 约定违约金上限为合同金额的 20%"]
print("忠实度:", round(faithfulness(claims, ev), 2)) # 0.50
# 预期输出:0.50 —— 第二条「不可抗力」在证据里找不到,判定为「偷跑 / 幻觉」。
# 实践:把忠实度纳入评测集,专门抓 closed-book 偷跑(答案看似合理但证据里没有)。
2.7 全流程七环节(串起来看)
python# 七个环节的最小可运行骨架(把全流程串起来)
def rag_answer(q):
qs = rewrite(q) # 1) 查询改写
docs = hybrid_retrieve(qs, k=60) # 2) 向量 + BM25 混合召回
docs = rrf_fuse(docs) # 3) RRF 融合
top = rerank(q, docs)[:6] # 4) cross-encoder 重排取 Top-6
ctx = pack_with_citations(top) # 5) 带引用拼装
ans = llm.generate(q, ctx) # 6) 生成(只依据证据)
return ans, evaluate(ans, top) # 7) 检索侧 + 生成侧分别评估
# 目标基线:recall@5 > 0.8,faithfulness > 0.9。
# 七步里「混合检索 + 重排 + 评估」三步对最终质量影响最大。
2.8 进阶 RAG:从检索到推理
| 进阶技术 | 做法 | 解决的问题 | 代价 |
|---|---|---|---|
| 查询改写 / 扩展 | 让模型把用户问题改写成更适合检索的多个查询 | 口语化提问与文档用词不一致 | 额外一次模型调用 |
| HyDE | 先生成假设性答案,再用它检索 | 问题太短、语义信息不足 | 可能放大幻觉方向 |
| 多跳检索(Multi-hop) | 把复杂问题拆成子问题,逐步检索 | 需要跨文档推理的问题 | 延迟与调用次数增加 |
| Agentic RAG | 让 Agent 自主决定检索什么、检索几轮、何时停止 | 复杂且不确定的信息需求 | 成本与不可控性上升 |
| GraphRAG | 构建实体-关系图,做图上的多跳检索 | 全局性、总结性、跨实体关联问题 | 构建成本高、维护复杂 |
| 上下文压缩 | 检索后对片段做摘要或抽取关键句 | 注入的 token 过多、关键信息被稀释 | 压缩可能丢信息,需评估 |
| 自适应检索 | 先用小模型判断是否需要检索 | 简单问题不必检索,省成本与延迟 | 分类器误判的风险 |
| 阶段(渐进叠加) | 叠加技术 | 典型收益 | 代价 |
|---|---|---|---|
| ① 基础 RAG | 混合检索 + 重排 | recall 相对纯向量 +10~20 个点 | 一次重排延迟 100–300ms |
| ② + 查询理解 | 查询改写 / HyDE / 多查询 | 短 query 召回 +5~15 个点 | 额外 1 次模型调用 |
| ③ + 多跳 / 分解 | 子问题拆分逐步检索 | 多跳 / 跨文档问题可答 | 延迟与调用数增加 |
| ④ + Agentic / Graph | 自主多轮 + 实体关系图 | 复杂 / 全局总结类问题 | 成本与不可控性显著上升 |
2.9 评测:检索侧与生成侧分开测
RAG 最致命的错误是「只测最终答案」。检索坏了还是生成坏了,必须用两套指标分别定位。
| 层面 | 指标 | 含义 | 怎么算 |
|---|---|---|---|
| 检索 | Recall@k | 前 k 个结果是否包含全部相关文档 | 命中相关数 / 总相关数 |
| 检索 | Hit Rate@k | 前 k 个里是否至少命中一个 | 至少命中一次的 query 占比 |
| 检索 | MRR | 第一个相关结果的平均排名倒数 | mean(1 / rank_of_first_relevant) |
| 生成 | Faithfulness(忠实度) | 答案是否只来自证据、无臆造 | LLM-as-Judge / 归因比对 |
| 生成 | Answer Relevancy | 答案是否切题 | LLM-as-Judge 打分 |
| 生成 | Context Precision | 送入的证据里相关的是否排前面 | 基于证据相关性的排序质量 |
| 生成 | Context Recall | 回答问题所需的证据是否都被召回 | 所需证据 / 被召回的相关证据 |
python# 用 Ragas 风格分别评估检索与生成(概念性伪代码)
def eval_rag(questions, golden, pipeline):
ret, gen = [], []
for q in questions:
ctx = pipeline.retrieve(q, k=5)
ans = pipeline.generate(q, ctx)
# 检索侧:ground-truth 相关 chunk 是否在前 5
ret.append(recall_at_k(ctx, golden.relevant[q], k=5))
# 生成侧:答案是否能在 ctx 中找到依据(忠实度)
gen.append(faithfulness(ans, ctx))
return {"recall@5": mean(ret), "faithfulness": mean(gen)}
# 实践:先确保 recall@5 > 0.8,再优化 faithfulness。
# 若 recall 低 → 改切分 / 嵌入 / 检索;若 faithfulness 低 → 改生成指令 / 加引用约束。
python# 检索侧指标的最小实现:Recall@k / Hit Rate@k / MRR
def recall_at_k(ret, rel, k):
return len(set(ret[:k]) & set(rel)) / len(rel)
def hit_rate_at_k(ret, rel, k):
return 1.0 if set(ret[:k]) & set(rel) else 0.0
def mrr(ret, rel):
for i, d in enumerate(ret):
if d in rel:
return 1.0 / (i + 1)
return 0.0
ret = ["x1", "x2", "A", "x3", "B"]
rel = {"A", "B"}
print("Recall@5:", recall_at_k(ret, rel, 5)) # 1.0
print("Hit@3:", hit_rate_at_k(ret, rel, 3)) # 1.0
print("MRR:", round(mrr(ret, rel), 3)) # 0.333
# 预期输出:1.0 / 1.0 / 0.333(第一个相关项排在第 3 位)。
# 实践:先确保 recall@5 > 0.8,再优化 faithfulness;否则在错的地方使劲。
2.10 动手练习与自测
- 用 2.4 的 rrf 函数,把 k 从 60 改为 10,观察融合结果顺序是否变化,并解释 k 的作用。
- 用 2.9 的指标代码构造一组「召回有但排序靠后」的数据,算出 MRR < 0.5 并说明该调什么。
- 给自己的一小段文本实现父子文档切分(小块 128、父块 1024),统计召回 20 条时命中父块数。
- RAG 效果差时,如何用两套指标判断是检索问题还是生成问题?
- 为什么 RRF 优于「向量分数 + BM25 分数直接相加」?
- 什么时候该上 GraphRAG,什么时候基础 RAG + 重排就够?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | RRF 的 k | k 越小越强调头部排名;k 大则接近平均。顺序可能微调,但双路都靠前的仍居首 |
| 2 | MRR 定位 | 相关项排名 3 -> MRR≈0.33;属于排序问题,优先加 cross-encoder 重排 |
| 3 | 父子文档 | 小块保精度、父块保完整;命中后回贴父块,验证生成侧上下文更完整 |
| 4 | 故障定位 | recall 低=检索问题(切分/嵌入/检索);recall 高但答案错=生成问题(指令/冲突) |
| 5 | RRF 优势 | cosine 与 BM25 量纲不可比,直接相加失衡;RRF 只用排名、一视同仁 |
| 6 | GraphRAG 边界 | 全局总结 / 跨实体关联才上;它预处理 token 是基础版 5–10 倍且维护复杂 |
3. 查询理解:在检索之前先读懂问题
学习路径
- 读 3.1:理解查询改写、多查询扩展与路由各自解决什么
- 跑内置代码:把一条 query 扩成 3–5 路后再检索,对比单路召回
- 完成动手练习:为任务配置查询路由与分类规则
- 对接 M13:把改写 + 多查询并入混合检索的召回前处理
核心知识点详解
- 查询改写 / 多查询扩展:改写在检索前把 query 改得更可检索(补全指代、去口语);多查询扩展把一条 query 扩成 3–5 个变体分别检索再合并去重,能显著提升召回的 recall(示例可比单路 recall 高 10%+)。增益越低越说明基路已健康。
- 查询路由 / 分类:按「要检索 / 要工具 / 要长上下文 / 要纯知识」等类别做路由 / 分类,把 query 分到不同执行路径。分类依据要可解释:关键词 / embedding 相似度 / 少量分类器,别拍脑袋。
- 多查询的取舍:多查询扩展成本线性涨(n 路检索 × n 次),但通常先合并去重、只把 top 合并进重排。要实测:加多查询后 recall 涨多少,延迟涨多少,收益是否值回成本。
- 常见坑:无条件全量多查询(几倍延迟只换 1% recall);或路由分类规则拍脑袋、遇到新 query 分错路径导致答案路径不对。先量增益、把路由依据写进文档。
学习路径
- 读 3.2:理解 HyDE 用假设答案嵌入提升短 query 召回的原理与风险
- 跑内置代码:对比直接嵌入 vs HyDE 在短 query 上的 recall 提升
- 完成动手练习:定位哪类 query 该用 HyDE 并写出降级条件
- 对接 M13:按需启用 HyDE 并在消融里确认净增益
核心知识点详解
- HyDE 原理:HyDE(Hypothetical Document Embedding):先让 LLM 根据短 query 生成一段假设答案,再用这段假设答案的嵌入去检索。假设答案包含更丰富的语义,通常比裸 query 更贴近目标文档,短 query 召回到 recall 可提升 13 个百分点量级。
- 为什么只适合短 / 模糊 query:短 query 信息熵低,加假设答案能大幅补全语义;若 query 本身已很具体、冗余文本多,HyDE 收益变小甚至为负。要按 query 区分:短 / 模糊 query 触发 HyDE,长 / 明确 query 不触发。
- 幻觉带偏风险:Biggest risk 是假设答案带偏——LLM 先入为主的错误会污染嵌入,把检索拉到错误片区。缓解:把假设答案与 query 本身都参与检索、或对高风险 query 禁用。净增益要消融确认。
- 常见坑:把 HyDE 当万能提升,长 query 也套导致延迟翻倍收益为负;或忽略「假设答案可能带偏」,不设降级条件就让错误先入为主。按 query 特征开启 + 消融验证净增益。
学习路径
- 读 3.3:理解查询分解的子问题数与延迟
0.4 + 0.6n的权衡 - 跑内置代码:跑一次多跳问题分解并逐子查询检索汇总
- 完成动手练习:对比静态分解与 Agentic 分解的延迟与准确率
- 对接 M13:为多跳问答案例配置查询分解并计入延迟预算
核心知识点详解
- 分解子问题 2–4 个:多跳问题先拆成 2–4 个子问题分别检索再汇总回答,能显著提升多跳 / 复杂查询的准确率。子问题太少不够细、太多引入冗余与错误累积——2–4 是常用档。
- 延迟代价公式 0.4 + 0.6n:分解的延迟近似
0.4 + 0.6n(基准单跳约 0.4,每个子问题额外约 0.6 单位):n=1 约 1.0,n=3 约 2.2——延迟约随子问题数线性涨。要量化:分解把准确率提多少,买这个延迟值不值。 - 静态 vs Agentic 分解:静态分解:一次把主问题拆成固定子问题并行检索;Agentic 分解:agent 逐步决定下一步查什么、用到哪再做。Agentic 更灵活但延迟更高、需 harness 支撑;静态更可控、适合有固定模板的多跳。
- 常见坑:对单跳简单问题也强行分解,白白加延迟无收益;或分解出的子问题检索结果互相冲突,汇总时模型乱编。先测「该问题是否需要多跳」,再算
0.4+0.6n的延迟账。
3.1 查询改写、多查询扩展与路由
用户的原始问题往往不适合直接检索:口语化、太短、隐含多意图。查询理解的目标是把「人怎么问」转成「检索器怎么找」。
| 技术 | 做法 | 解决的问题 | 代价 |
|---|---|---|---|
| 查询改写 | 模型把问题改写成规范的检索式 | 用词不一致、歧义 | 一次调用 |
| 多查询扩展 | 生成 3–5 个角度的查询,分别检索后合并 | 单 query 覆盖不全 | N 倍检索成本 |
| 查询路由 | 先判断「该检索还是不检索 / 检索哪个库」 | 简单问题不必检索,省成本 | 分类器误判风险 |
| 查询分类 | 区分事实 / 总结 / 比较 / 闲聊 | 不同问题用不同管线 | 一次调用 |
python# 多查询扩展 + 路由:把一个问题变成多个检索,再合并去重
def expand_queries(q, llm, n=3):
prompt = f"为检索生成 {n} 个不同角度的查询,覆盖问题可能的意图:\n{q}"
return llm.generate(prompt).splitlines()[:n]
def route(q, llm):
# 返回 "retrieve" / "long_context" / "chat"
return llm.classify(q, labels=["retrieve", "long_context", "chat"])
def retrieve_multi(q, chunks, k=6):
qs = expand_queries(q, llm)
hits = []
for sub in qs:
hits += vector_search(sub, chunks, k=k)
# 按 id 去重,保留最高分(或用 RRF 重新融合多 query 结果)
return dedupe_by_score(hits)[:k]
# 路由:若 route(q)=="chat",直接回答不检索,省下检索 + 重排成本。
python# 多查询扩展后去重:按 doc id 保最高分(或再 RRF 融合)
def merge_multi_query(hits_per_query, top_k=6):
best = {}
for hits in hits_per_query:
for rank, h in enumerate(hits):
s = 1 / (rank + 1)
if h["id"] not in best or s > best[h["id"]]["s"]:
best[h["id"]] = {"s": s, "doc": h}
ranked = sorted(best.values(), key=lambda x: -x["s"])
return [b["doc"] for b in ranked[:top_k]]
# 经验:多查询扩到 3–5 个收益趋平;每个 query 各取 Top-6 再合并,
# 比单 query 取 Top-30 覆盖更全(不同角度命中不同片段)。
3.2 HyDE:假设文档嵌入
HyDE(Hypothetical Document Embeddings)用一个反直觉但有效的技巧解决「短 query 语义信息不足」:先让模型生成一段「假设的答案文档」,再用这段文档(而非原问题)去做向量检索。因为假设答案的语义密度远高于短问题,检索召回往往更好。
python# HyDE:用「假设答案」去检索,而非用短问题
def hyde_retrieve(q, embedder, vdb, k=6):
# 1) 让模型先编一个「如果资料里有答案,它会长什么样」
hypo_doc = llm.generate(f"假设资料中存在该问题的答案,写出那段答案:\n{q}")
# 2) 用这段假设文档的嵌入去检索(而不是用原 query 的嵌入)
qv = embedder.encode(hypo_doc)
return vdb.search(qv, k=k)
# 为什么有效:短 query(如「违约金上限」)语义稀疏,和长文档向量距离大;
# 而假设答案文档复述了相关概念,与真文档更近。
python# HyDE 的效果对比(示意数字):短 query 上提升明显
def recall_direct(): return 0.58 # 直接用短 query 的向量检索
def recall_hyde(): return 0.71 # 用假设答案文档的向量检索
print("直接检索 recall@5:", recall_direct()) # 0.58
print("HyDE recall@5:", recall_hyde()) # 0.71
print("提升:", round(recall_hyde() - recall_direct(), 2)) # +0.13
# 结论:短 query(语义稀疏)受益最大;但假设答案若含错误事实会带偏检索,
# 所以 HyDE 只用于召回,最终答案仍严格基于真实召回材料。
3.3 查询分解:把复杂问题拆成可检索的子问题
当问题需要跨文档、多步推理(「A 公司的供应商 B 在 2025 年的合规记录如何?」),单跳检索答不全。查询分解让模型先规划子问题,分别检索再汇总。
python# 查询分解 + 顺序 / 并行检索 + 汇总
def decompose(q, llm):
plan = llm.generate(f"把问题拆成 2-4 个可独立检索的子问题:\n{q}")
return [line for line in plan.splitlines() if line.strip()]
def answer_complex(q, chunks, llm):
subs = decompose(q, llm)
evidence = []
for sub in subs: # 简单场景可并行检索
ev = retrieve(sub, chunks, k=4)
evidence.append(f"子问题: {sub}\n" + format_chunks(ev))
# 最后把所有子证据汇总回答原问题
return llm.generate(f"基于下列证据回答原问题:\n{q}\n\n{evidence}")
# 注意:分解粒度要适中。拆太粗(1 个)等于没拆;拆太细(>5)延迟爆表且
# 子答案之间容易矛盾。
python# 查询分解的可控性:子问题数量与延迟、矛盾之间的权衡
def latency(n): return 0.4 + 0.6 * n # 规划 0.4s + 每个子检索约 0.6s(示意)
for n in [1, 3, 5, 9]:
print(f"子问题数={n} 预计延迟≈{latency(n):.1f}s")
# 预期输出:1->1.0s;3->2.2s;5->3.4s;9->5.8s
# 结论:拆太粗(n=1)=没拆;拆太细(n>5)延迟爆表且子答案互相矛盾,甜点 2–4。
3.4 动手练习与自测
- 用 3.1 的 merge_multi_query 给两路召回结果去重排序,检查是否出现「同一文档被多次命中」。
- 用 3.3 的延迟模型,找出 n 取多少时延迟仍可接受(阈值 3s),并说明为什么不再往上加。
- 查询改写 / 多查询 / 路由分别解决什么问题?各自的代价是什么?
- HyDE 为什么对短 query 有效?它的主要风险是什么?
- 查询分解与 Agentic RAG 的取舍是什么?什么场景才值得上 Agentic?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 多查询去重 | 同一 doc 保最高分;合并后覆盖比单 query Top-30 更全 |
| 2 | 分解粒度 | n<=4 时延迟 <=2.8s;再大延迟线性上升且子答案易矛盾 |
| 3 | 查询理解三件套 | 改写解决用词不一致、多查询解决覆盖不全、路由解决「该不该检索」 |
| 4 | HyDE 机制 | 假设答案语义密度高、与真文档更近;风险是假设事实错误会带偏检索 |
| 5 | 分解 vs Agentic | 分解=静态、便宜、可控;Agentic=动态、贵、适合高度不确定的多轮试探 |
4. 进阶结构:GraphRAG / RAPTOR / 压缩
学习路径
- 读 4.1:吃透实体-关系抽取 → Leiden 社区 → 摘要的 GraphRAG 链路
- 跑内置代码:在小语料上建图并生成社区摘要,观察预处理 token 放大 5–10 倍
- 完成动手练习:判断任务是否需要全局总结场景,给出依据
- 对接 M13:仅在全局关联型查询上旁路启用 GraphRAG 并对比收益
核心知识点详解
- GraphRAG 链路:
实体-关系抽取 → 构建图 → Leiden 社区检测 → 对社区生成摘要 → 查询时按社区检索。它擅长跨实体关联类和全局总结类查询(「整体趋势是什么」「A 与 B 有什么联系」),这是传统向量 RAG 的弱项。 - 成本放大 5–10 倍:GraphRAG 预处理 token 放大 5–10 倍:实体/关系抽取、社区摘要都要对全量文本反复过 LLM。构建成本显著高于普通索引,且建图是离线的。所以只在真需要全局总结时启用。
- 何时该用 GraphRAG:查询是「全局总结 / 跨实体关系 / 需要聚合多段落」→ 值得;若全是「定位某一句话」的局部查询,二维向量 RAG 就够,GraphRAG 纯属浪费。按查询类型路由。
- 常见坑:在所有查询旁路无脑开 GraphRAG,成本涨 5–10 倍、延迟翻倍收益有限;或抽取质量差(实体识别不准)导致图结构噪声大。只在全局关联型查询上旁路启用并对比收益。
学习路径
- 读 4.2:理解递归聚类摘要的层级树如何兼顾细节与概览
- 跑内置代码:构建一层摘要树并观察叶子 vs 上层对回答的影响
- 完成动手练习:对比 RAPTOR 与 GraphRAG 的构建成本与适用任务
- 对接 M13:为长文档问答选用 RAPTOR 或 GraphRAG 并在消融里自证
核心知识点详解
- 递归聚类摘要:RAPTOR 自底向上把段落按 embedding 聚类、对每簇生成摘要,逐层构建层级摘要树:叶子是原始细节、上层是概览摘要。检索时可去合适层级取,兼顾细节与概览的连续光谱。
- 叶子给细节、上层给概览:查具体事实去叶子(原始 chunk),查全局概览去上层(聚类摘要)。关键设计是「检索时选哪一层」——按 query 需求决定,不是每层都查。
- 构建成本 < GraphRAG:RAPTOR 只做「聚类 + 摘要」,不做实体 / 关系抽取,构建成本显著低于 GraphRAG——适合长文档问答,而 GraphRAG 更适合多文档全局总结。两者按「单长文档 vs 跨文档关系」取舍。
- 常见坑:查询细节却命中上层摘要(信息丢失);或每层都查导致 token 膨胀。先明确 query 要细节还是要概览,再选层,并用构建成本对比决定上哪套。
学习路径
- 读 4.3:分清抽取式与摘要式压缩及指代消解的作用
- 跑内置代码:对同一长文档走两种压缩并对比信息保留
- 完成动手练习:定义自己的压缩红线(保留哪些字段)
- 对接 M13:把上下文压缩 + token 预算分配接入生成侧
核心知识点详解
- 抽取式 vs 摘要式压缩:抽取式:按规则 / 模型选保留句子,不改变原文(信息保真、可溯源);摘要式:让 LLM 重新组织,压缩率更高但可能引入偏差。抽取保真性强、摘要省 token——按场景选,两者可混用。
- 上下文无关化 / 指代消解:摘要丢掉上文后,「它 / 该方案 / 上述」等指代会悬空。压缩时必须做上下文无关化:把指代展开成具体实体(「该公司」→「Acme Corp」),否则压缩后的上下文无法独立理解。
- 结构化提取 + 压缩红线:把结论 / 关键字段按结构化提取(如保留日期、金额、名称、结论四字段),其余压缩。同时设压缩红线:必须保留哪些字段 / 结构,红线以下不压缩。衡量保留用「压缩后能否仍答出原问题」。
- 常见坑:只盯压缩后 token 数,不验证信息保留(重要字段丢了);或压缩时未做指代消解,摘要上下文悬空无法独立使用。先定红线再压缩,用回溯问答验证。
4.1 GraphRAG:实体图 + 社区摘要
GraphRAG 由微软提出,针对的是基础 RAG 答不好的两类问题:全局性总结(「这份语料整体讲了什么?」)和跨实体关联(「A 和 B 通过哪些事件关联?」)。做法是先把文档抽取成「实体-关系」图,再对图做社区检测(Leiden),为每个社区生成摘要,查询时既做向量检索也做图遍历。
| 维度 | 基础 RAG | GraphRAG |
|---|---|---|
| 擅长 | 局部事实、点查 | 全局总结、跨实体关联、多跳 |
| 索引成本 | 低(嵌入即可) | 高(抽取 + 建图 + 社区摘要,token 消耗大) |
| 查询成本 | 低 | 中—高(图遍历 + 多社区摘要) |
| 维护 | 增量嵌入简单 | 图更新复杂,需重算受影响社区 |
| 适用 | 绝大多数 QA | 企业知识库、法规、研报等需「鸟瞰」的场景 |
python# GraphRAG 的核心构建步骤(概念性)
def build_graphrag(docs, llm):
# 1) 抽取 (实体, 关系, 描述) 三元组
triplets = [extract_triplets(d, llm) for d in docs]
graph = build_kg(triplets) # 实体-关系图
# 2) 社区检测(Leiden),把图分成语义社区
communities = leiden_communities(graph)
# 3) 为每个社区生成摘要(这一步消耗大量 token)
summaries = {c: llm.summarize(community_text(c)) for c in communities}
return graph, communities, summaries
# 查询:
# - 局部问题:图 + 向量检索定位相关实体与社区摘要
# - 全局问题:直接聚合多个社区摘要做鸟瞰式回答
python# GraphRAG 的构建 token 消耗:为什么它「贵」在预处理
docs, chunks_per_doc = 1000, 20
extract_tokens = docs * chunks_per_doc * 300 # 每块抽取三元组的 token 量级
summary_tokens = 500_000 # 社区摘要(示意)
print("抽取 token 量级:", f"{extract_tokens:.0e}") # 6e+06
print("总计(含摘要):", f"{extract_tokens + summary_tokens:.0e}") # 7e+06
# 预期输出:抽取约 6e6、总计约 7e6 token。
# 对比基础 RAG(仅嵌入)几乎为零 -> GraphRAG 预处理 token 可能是基础版的 5–10 倍。
# 结论:只在需要「全局总结 / 跨实体关联」时才上,且要接受维护成本。
4.2 RAPTOR:层级摘要树
RAPTOR 用另一种方式解决全局问题:把检索到的 chunk 递归聚成簇,为每个簇生成摘要,再聚再摘要,形成一棵从细到粗的摘要树。查询时同时检索叶子(细节)和上层节点(概括),兼顾局部与全局。
python# RAPTOR 层级摘要树的构建(概念性)
def build_raptor(chunks, embedder, llm):
layer = chunks
tree = [layer]
while len(layer) > 1:
# 1) 对当前层做聚类(如高斯混合 / 层次聚类)
clusters = cluster(layer, embedder, k=auto_k(layer))
# 2) 每个簇生成一条摘要,作为上一层节点
layer = [llm.summarize(texts(c)) for c in clusters]
tree.append(layer) # 越往上越概括
return tree # tree[0]=最细, tree[-1]=根摘要
# 查询:在每一层都检索最相关的节点,把所有命中(细 + 粗)喂给生成模型。
python# RAPTOR 摘要树的层级结构:越往上越概括
def build_layers(n_chunks, branching=5):
layers = []
while n_chunks > 1:
n_chunks = max(1, n_chunks // branching) # 每层按分支因子聚簇
layers.append(n_chunks)
return layers
print("层级节点数(自底向上):", build_layers(1000))
# 预期输出:约 [200, 40, 8, 1](5 层左右)
# 查询时在每一层都检索:叶子给细节、上层给概览,兼顾局部与全局。
# 构建成本远低于 GraphRAG(只需聚类 + 逐簇摘要)。
4.3 上下文压缩与摘要记忆
当检索或历史过长,直接全塞会稀释注意力并推高成本。上下文压缩的几种手段各有取舍。
- 抽取式压缩:从检索片段中只抽与问题相关的句子(如用 cross-encoder 逐句打分保留 Top-k),保留原文、可信但可能丢上下文。
- 摘要式压缩:用模型把长历史 / 长文档压成短摘要,省 token 但可能丢细节,需评估关键事实是否被保留。
- 上下文无关化:把长对话中的「它 / 这个」还原成具体实体,避免模型靠长程指代出错。
- 结构化提取:把对话结论抽成 JSON(如用户偏好、已确认事项),后续只注入结构化字段而非原文。
python# 摘要式压缩:历史超限时滚动摘要,而不是无限堆积
def compress_history(history, llm, max_turns=6):
if len(history) <= max_turns:
return history
old, recent = history[:-max_turns], history[-max_turns:]
summary = llm.summarize(render(old)) # 把旧对话压成一段
return [{"role": "system", "content": f"<history_summary>{summary}</history_summary>"}] + recent
# 关键:摘要里必须保留可操作信息(决策、待办、约束),
# 丢弃闲聊与重复确认,否则后续任务会「忘记」前提。
python# 上下文压缩的效果与风险:省了 token,别丢了关键事实
def compressed(text_tokens, keep_ratio):
return int(text_tokens * keep_ratio)
for r in [1.0, 0.5, 0.2, 0.1]:
print(f"保留比例 {r:.0%} -> 约 {compressed(12000, r):>5} token")
# 预期输出:100%->12000;50%->6000;20%->2400;10%->1200
# 经验:抽取式压缩保真度高于摘要式;摘要必须强制保留实体与决策;
# 压缩后要在评测集上验证答案质量不下降,否则宁可多花 token 也不压。
4.4 动手练习与自测
- 用 4.1 的估算,把文档数从 1000 提到 5000,重算 GraphRAG 预处理 token 量级。
- 用 4.2 的 build_layers,改分支因子为 10,观察树高变化并说明对检索成本的影响。
- 基础 RAG / RAPTOR / GraphRAG 分别擅长哪类问题?构建成本如何排序?
- 上下文压缩为什么「抽取式优于摘要式」?压缩的红线是什么?
- 什么时候「长上下文直接塞」反而优于 RAPTOR / GraphRAG?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 构建成本 | 抽取 ≈ 5000×20×300 = 3e7 token;随文档数线性增长 |
| 2 | 树的分支 | branching=10 -> 树更矮(约 3 层),每层节点少、检索更快但概览粒度更粗 |
| 3 | 三者对比 | 基础=局部点查;RAPTOR=从细节到概览;GraphRAG=跨实体关联;成本 基础 |
| 4 | 压缩红线 | 抽取保原文更保真;摘要可能丢关键事实,须评测验证,否则宁可不压 |
| 5 | 长上下文适用 | 文档小且稳定、需全局鸟瞰、检索失败零容忍时直接塞更简单 |
5. 记忆系统:让系统不“忘事”
学习路径
- 读 5.1:理清工作 / 会话 / 长期 / 程序性四类记忆的分工与生命周期
- 跑内置代码:演示一次会话如何从工作记忆沉淀进长期记忆
- 完成动手练习:为你的系统画出记忆分层表
- 对接 M13:把多轮上下文落地为会话 + 长期两层记忆供 RAG 兜底
核心知识点详解
- 四类记忆分层:工作记忆(本轮回话 / 当步临时状态,内存即可)、会话记忆(一次会话的历史,滚动窗口 + 摘要)、长期记忆(跨会话要点,向量 / 键值存储)、程序性记忆(工具用法 / 技能,独立于对话)。按生命周期归层决定读写路径。
- 记忆的读写路径:写入:会话滚动摘要 → 重要性筛选 → 沉淀进长期记忆;读取:当前任务按相关度检索工作 / 会话 / 长期记忆合并注入。要能说清每个信息「从哪写入、按何时读取、何时被删除」。
- 分层解决上下文腐坏:不把全部历史塞进窗口而是分层存取,是控制 token 成本与上下文腐坏的关键——工作记忆保当前、长期记忆按需注入。层间有明确迁移规则(什么进长期、什么丢弃)。
- 常见坑:只一层(全塞会话)导致历史膨胀、检索到过期信息;或不写迁移规则,工作记忆旧数据残留影响当前任务。先画分层表再实现读写。
学习路径
- 读 5.1:掌握重要性筛选、去重消解与检索打分原则
- 跑内置代码:按相关度 + 重要度 + 新近度打分并 top-k 注入
- 完成动手练习:设计自己的记忆写入去重规则
- 对接 M13:让记忆检索与向量检索共用打分并把结果合并进生成
核心知识点详解
- 写入先筛选:不是所有对话都进长期记忆——写入前做重要性筛选(含明确偏好 / 关键事实 / 指令才保留,寒暄丢弃)。只写不筛会污染记忆检索,随时间质量下降。筛选规则要显式可解释。
- 去重与冲突消解:同一事实多次出现要去重合并;新信息与旧记忆矛盾时要消解(新覆盖旧 或 保留冲突并标注),否则检索时模型随机选边。这是社区记忆持久化的关键。
- 多因子打分 + top-k 注入:检索记忆用 相关度 + 重要度 + 新近度 多因子打分,再 top-k 注入生成上下文。权重可调(如
score = α·rel + β·importance + γ·recency),且只需注入与当前任务相关的记忆,别全量塞。 - 常见坑:只写不筛 → 记忆越积越杂,检索到全是过期噪音;或打分只看相关度,把冷门但重要的用户偏好漏掉。写筛选 + 多因子打分 + top-k 配合才能让记忆真正帮上忙。
学习路径
- 读 5.1:理解滚动摘要压缩、结构化提取与半衰期遗忘机制
- 跑内置代码:把长对话压成滚动摘要并核对信息保留 8–15x
- 完成动手练习:为记忆设时间衰减与低价值删除策略
- 对接 M13:压缩后的记忆作为低成本上下文注入避免上下文腐坏
核心知识点详解
- 滚动摘要 8–15x:把一段对话压成滚动摘要(保留关键结论、丢弃过程),可把历史 token 压到 1/8–1/15 同时保持信息。摘要要上下文无关(指代展开成实体),否则脱离原会话语境没法用。
- 结论结构化提取:除摘要外,把关键结论 / 偏好 / 任务状态结构化提取成记录(键值 + 时间戳),便于定向检索。混合「摘要(保留整体的历史叙述)+ 结构化(精确的事实检索)」是常态。
- 遗忘机制:半衰期衰减 + 低价值删除:记忆需要遗忘:按半衰期衰减(如 7 天未活跃的冷门记忆降权或过期)、低价值记忆定期删除,避免长期记忆无限膨胀。
半衰期 7 天意为活跃度每 7 天减半,配低价值淘汰维持记忆保鲜。 - 常见坑:只写不遗忘,长期记忆越积越满、检索命中过期信息;或摘要丢了指代上下文导致回看看不懂。跑通「写入→筛选→压缩→遗忘」闭环,并说明每一步保留了什么、丢了什么。
学习路径
核心知识点详解
- 用户可见可修正:记忆系统必须暴露查看 / 修改 / 删除接口:用户能看系统记住了什么、改掉错误条目、删掉敏感内容。可解释性 = 用户有权知道系统用了它的哪些记忆。
- GDPR 删除与更正权:面向真实用户要满足 GDPR 知情权 / 更正权 / 被遗忘权:能按实体清空其记忆、能更正不实条目、保留审计日志。合规不只是法律要求,也是产品信任。
- 验证以三次操作为准:验收即证明:用户可 ①查看当前记忆、②修正错误条目、③删除指定记忆,且操作后检索结果同步变化。三次操作跑通,记忆的可纠正性才算闭环。
- 常见坑:记忆系统只写不读接口,用户没法改错,错误记忆长期污染后续回答;或无审计、删了也无法记录。把查看 / 修改 / 删除入口接进产品并留审计,别只做存储。
5.1 三层记忆模型
| 层级 | 内容 | 存储 | 生命周期 |
|---|---|---|---|
| 工作记忆 | 当前任务的中间状态、已执行步骤、待办 | 进程内 / 上下文 | 单次任务 |
| 会话记忆 | 本轮对话的历史与结论 | 会话存储 + 滚动摘要 | 一次会话 |
| 长期记忆 | 用户偏好、事实、历史结论、经验 | 向量库 / KV / 关系库 | 跨会话持久 |
| 程序性记忆 | 成功的方法、Prompt 模板、工具用法 | 结构化知识库 | 长期迭代 |
- 写入策略:不是所有信息都值得记。按「重要性 + 未来可用性」筛选,写入时做去重与冲突消解(同一事实的新旧版本)。
- 检索策略:按相关性 + 新颖度 + 重要性加权;只注入 top-k,避免记忆淹没当前任务。
- 压缩策略:会话变长时滚动摘要;把关键结论结构化提取(而非保留原文),显著省 token。
- 遗忘机制:过期与低价值记忆应被衰减或删除,否则检索质量随时间下降。
- 可解释与可纠正:用户应能看到并修正被记住的内容——这是合规与信任的要求。
| 压缩策略 | 触发条件 | 做法 | 典型收益 |
|---|---|---|---|
| 滚动摘要 | 会话超过 N 轮(如 12 轮) | 把最早 K 轮压成 1 段摘要,保留结论与实体 | 压缩比约 8–15 倍 |
| 结论提取 | 出现明确事实 / 偏好 | 结构化写入 key-value,不保留原句 | 长期记忆体积降 60%+ |
| 去重合并 | 同一 key 再次写入 | 覆盖旧值(如「预算」只保留最新) | 消除冲突检索 |
| 衰减遗忘 | 低重要度且久未命中 | 按天数指数衰减,低于阈值删除 | 索引规模可控 |
python# 记忆衰减与多因子打分:验证「相关度 + 重要度 + 新近度」谁主沉浮
def recency(age_days, half_life=7.0):
return 0.5 ** (age_days / half_life) # 半衰期 7 天
def score(sim, imp, age_days, w=(0.6, 0.3, 0.1)):
return w[0]*sim + w[1]*imp + w[2]*recency(age_days)
# A:很相关但旧(30 天前);B:相关度略低但重要且新(1 天前)
print(round(score(0.92, 0.5, 30), 3)) # 0.707
print(round(score(0.80, 0.9, 1), 3)) # 0.841
# 结论:B 胜出 —— 高重要度 + 新,能压过「纯相似度高」。
print(round(recency(7), 3), round(recency(30), 3)) # 0.5 0.051
# 30 天后新近度几乎归零,若某记忆全靠新近度命中,说明它本就该被遗忘。
python# 分层记忆:写入时筛选 + 冲突消解,读取时多因子加权
from dataclasses import dataclass
import time
@dataclass
class Memory:
content: str
kind: str # "preference" | "fact" | "episode"
importance: float # 0-1,由模型或规则评估
ts: float
key: str | None = None # 用于冲突消解,如同一偏好只保留最新
def write(db, m: Memory, min_importance=0.4):
if m.importance < min_importance:
return # 低价值信息不写入,避免污染
if m.key: # 冲突消解:同一 key 保留最新
db.delete(where={"key": m.key})
db.insert(m.to_dict())
def read(db, query_vec, k=5, w_rel=0.6, w_imp=0.3, w_new=0.1):
cands = db.search(query_vec, k=50)
now = time.time()
def score(c):
recency = 1 / (1 + (now - c["ts"]) / 86400) # 按天衰减
return w_rel * c["similarity"] + w_imp * c["importance"] + w_new * recency
return sorted(cands, key=score, reverse=True)[:k]
5.2 动手练习与自测
- ① 给三层记忆各写一个「不该记」的例子,并说明它会被拦在哪一步。
- ② 用 5.1 的 score 函数手算:sim=0.9、imp=0.4、age=14 天,得分多少?
- ③ 同一用户的「收货地址」被改两次,检索时应返回哪一个?为什么?
- ④ 会话涨到 40 轮、token 超预算,给出两种压缩方案并估压缩比。
- ⑤ 为什么记忆必须能「被用户看到并修正」?至少给两条理由。
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 写入筛选 | 低重要度(如寒暄)被 min_importance 拦下;可推导信息(如「今天是周三」)不该记 |
| 2 | 多因子打分 | ≈0.685(0.6×0.9 + 0.3×0.4 + 0.1×0.25,recency(14)=0.5²=0.25) |
| 3 | 冲突消解 | 按 key=收货地址 覆盖,只保留最新一条;旧值删除而非并存 |
| 4 | 上下文压缩 | 滚动摘要约 8–15 倍;结论结构化提取可让长期记忆体积降 60%+ |
| 5 | 可解释可纠正 | 合规(GDPR 删除权 / 更正权)+ 信任 + 纠错闭环,三者任一即可 |
6. 长上下文 vs RAG:四维对比与取舍
学习路径
- 读 6.1:在成本 / 延迟 / 长尾精度 / 可更新性四维上对比长上下文与 RAG
- 跑内置代码:用预填充延迟与成本公式算一次长上下文查询账
- 完成动手练习:为自己的场景做四维打分并选主方案
- 对接 M13:决定哪些查询走 RAG、哪些可直投长上下文并自证
核心知识点详解
- 四维对比:成本(RAG 只付相关片段,长上下文付全部,差 10–100 倍)、延迟/prefill TTFT(长上下文 prefill 慢,重排可控制在 100–300ms)、长尾事实精度(长上下文全可见更全)、可更新性(RAG 改文档即可,长上下文要重传 / 重索引)。按这四维给具体查询打分。
- 成本是最大分叉:示例:同任务下 RAG 只注入 top 8 片段(几千 token),长上下文要传全库(几十万 token)——单次成本差可达 10–100 倍。文档越大、更新越频,RAG 越划算。
- 延迟四维之一 TTFT/prefill:首 token 时间(TTFT)≈ prefill 时间,随输入长度线性涨。长上下文把大量与答案无关的内容整段 prefill 掉,延迟不可控;RAG 的检索 + prefill 都可预期。
- 常见坑:一言断言「长上下文取代 RAG」——忽略了成本 10–100 倍、更新要重传重索引;或反过来「一律 RAG」,忽略了需要全局鸟瞰的查询。按四维打分逐 query 决策,不是一刀切。
学习路径
核心知识点详解
- 三条可不用 RAG 的判据:①文档小且稳定(全量塞窗口成本也低、无需更新);②全局鸟瞰需求(要看到全文相互联系,RAG 只返回局部);③零容忍检索失败(检索一旦漏就不可接受,直接全量长上下文兜底)。
- 全局鸟瞰场景:"通读一遍做总结 / 对比全文两处关系"这类查询,RAG 的 top-k 会漏掉局部以外的关联——此时直投长上下文更好。RAG 天生面向「定位式」检索,不擅长「全景式」。
- 零容忍检索失败:某些场景检索失败代价极高(如法律 / 合规 / 关键事实),此时给这些查询配置长上下文直投兜底,RAG 只做快速路径。判据是失败成本是否允许可承受的漏检率。
- 常见坑:为了「显得 UI 高级」硬套 RAG,小且稳定文档全量塞窗口更简单可靠;或不识别零容忍场景,检索漏了直接报错 / 乱答。按「文档大小 / 稳定性 / 鸟瞰需求 / 失败容忍」四问判断。
学习路径
- 读 6.1:理解 RAG 为主 + 长上下文兜底的组合与 lost in the middle 现象
- 跑内置代码:复现长上下文中部信息丢失并设兜底触发阈值
- 完成动手练习:配置自己的兜底路由与触发条件
- 对接 M13:让 RAG 兜底与直投长上下文在同一路由里协同
核心知识点详解
- lost in the middle:模型对上下文正中间的信息关注最弱、两端(开头 / 结尾)最强——这是长上下文与 RAG 注入都要面对的位置偏置。复现:把同一事实放中间 vs 开头,观察准确率差异(可掉 5–20%)。
- RAG 为主 + 长上下文兜底:生产常用组合:RAG 为主(快、便宜、可更新)+ 关键 / 全局 / 零容忍查询直投长上下文兜底(全量、无召回风险)。两者在同一路由里协同,兜底是例外不是常态。
- 兜底触发阈值:用召回质量 / 置信度 / 查询类型设兜底触发阈值:如单方法 recall@5 低于红线(< 0.8)时触发长上下文兜底;或全局 / 零容忍类查询直接走兜底。阈值以消融实测校准。
- 常见坑:没有兜底路由,零容忍查询检索失败就崩;或兜底与 RAG 每次都同时跑,成本 / 延迟白翻倍。把「关键证据放中间」也踩一遍位置偏置,用阈值只在需要时触发长上下文。
6.1 什么时候可以不用 RAG
长上下文模型(128K–1M 窗口)普及后,很多人问「还要 RAG 吗」。答案是看四个维度,而不是非此即彼——很多系统两者并用。
| 维度 | RAG | 长上下文 | 谁优 |
|---|---|---|---|
| 成本 | 只付相关片段的 token | 付全部文档的 token(可能大 10–100 倍) | RAG 优 |
| 延迟 | 短上下文,生成快 | 长上下文 prefill 慢、首个 token 延迟高 | RAG 优 |
| 精度(长尾事实) | 受召回限制,可能漏 | 全文档可见,长尾事实更全 | 长上下文优 |
| 可更新性 | 改文档即可,无需重算 | 改文档要重传 / 重索引整个上下文 | RAG 优 |
| 全局总结 | 需 GraphRAG / 多跳 | 直接放进去做鸟瞰 | 长上下文优(小语料) |
什么时候可以不用 RAG:① 文档总量小(几万字内)且稳定,整份塞进上下文就够了;② 问题天然需要全局鸟瞰(如「总结这份合同的风险点」),检索反而丢信息;③ 对检索失败零容忍、且窗口足够容纳全部资料。反之,文档海量、更新频繁、成本敏感,就仍应以 RAG 为主,长上下文当兜底。
python# 长上下文 vs RAG 的成本 / 延迟分界:算一笔账
DOC_TOKENS = 800_000 # 全量文档(token)
SNIPPET_TOKENS = 6_000 # 检索后注入(token)
PRICE_IN = 3.0 / 1e6 # 输入单价(元/token,示意值)
rag = SNIPPET_TOKENS * PRICE_IN
full = DOC_TOKENS * PRICE_IN
print(round(rag*1000, 2), round(full*1000, 2)) # 每千次 18.0 元 vs 2400.0 元
print(round(full/rag, 1)) # 133.3 倍
# 结论:文档量大时,全塞上下文比 RAG 贵两个数量级;
# 而精度上 RAG 只在「召回漏了」时才输,故默认 RAG + 长上下文兜底。
6.2 动手练习与自测
- ① 语料 5 万字、每天更新、QPS 低,选 RAG 还是长上下文?
- ② 复算 6.1 的账:文档 80 万 token、片段 6K、单价 3 元/百万,两者差多少倍?
- ③「总结这份 300 页合同的 20 个风险点」用哪种方案更稳?为什么?
- ④ 设计「RAG 为主 + 长上下文兜底」的触发条件。
- ⑤ 长上下文的两个隐性成本是什么?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 四维取舍 | 更新频繁 → RAG 为主;文档小且稳定才考虑整份塞入 |
| 2 | 成本量级 | 每千次 18 元 vs 2400 元,约 133 倍 |
| 3 | 全局鸟瞰 | 长上下文或 Map-Reduce 分块总结;纯 Top-k RAG 易漏关键风险点 |
| 4 | 兜底触发 | 检索最高分低于阈值,或命中片段少于 2 个 → 升级为全文档 |
| 5 | 隐性成本 | prefill 慢导致 TTFT 高;「lost in the middle」使中间信息被忽略 |
7. Harness Engineering:让 Agent 跑得久而不崩
学习路径
- 读 7.1:把记忆、工具、规划、失败恢复、检查点串成一个 harness
- 跑内置代码:让 agent 在多步任务里跑完并沉淀可续跑的检查点
- 完成动手练习:给自己管线装上最小 harness 并验证长跑不崩
- 对接 M13:让 RAG 管线能接管失败恢复与断点续跑
核心知识点详解
- harness 五要素:记忆与上下文(滚动 + 分层)、工具调用与权限(schema + 授权)、多步规划与分解(状态机)、失败恢复(重试 / 修复 / 挂起)、检查点续跑(中断后从断点恢复)。这五块把「一句 prompt」升级成「一套可运行框架」。
- 检查点续跑:每完成一步把状态落地(当前步骤、已获证据、中间结果),崩溃 / 超时后从检查点续跑而非重头开始。这是让多步任务从「演示」走向「长跑不崩」的关键工程。
- 多步任务稳定跑完:验收标准:一条多步任务能稳定跑完不崩(无死循环 / 无状态丢失),且中断后可续跑。用 harness 串起步骤,而不是让模型一个 for 循环硬推到底。
- 常见坑:只给模型一个长指令让它自己多步推进,无支架无状态,随便一步出错就整体失败;或没有检查点,中断后全部重来。装上最小 harness(状态机 + 检查点)再测长跑。
学习路径
- 读 7.1:按可重试 / 可修复 / 不可重试 / 人工门禁给失败归类
- 跑内置代码:构造三类失败并观察重试 / 换策略 / 挂起的处置路径
- 完成动手练习:为自己的步骤编写失败分类与恢复策略
- 对接 M13:把失败恢复策略接到检索 / 生成环节保证管线健壮
核心知识点详解
- 按失败类型分类处置:可重试(超时 / 限流 / 5xx,重试 3–5 次)→ 可修复 / 换策略(输入不合法改参数、换工具)→ 不可重试(4xx / 逻辑错误,立即失败并告警)→ 人工门禁挂起(高风险动作)。分类决定恢复路径,决不一律重试。
- 可重试的退避:重试要指数退避 + 抖动(
base×2^n + jitter,封顶 30s),否则把对端限流打满;可重试类才重试,永久失败类重试只浪费配额。先分错再重试。 - 高风险动作人工审批:对不可逆 / 高风险操作(发钱、删除、外部影响)设人工门禁:hit 到即挂起、等人工审批通过才继续,不自动放行。这是「演示 agent」与「能跑生产 agent」的分水岭。
- 常见坑:所有失败都重试(对 4xx / 逻辑错误做无意义重试);或把高风险动作默默自动执行无审批,出事无法负责。分类 + 退避 + 审批三层配合,让一次失败可定位、可续行。
学习路径
- 读 7.1:吃透状态机 + 检查点、退避 + jitter、审批、tracing 的实现骨架
- 跑内置代码:实现带状态机的中断续跑并加指数退避
- 完成动手练习:为高风险步骤加审批闸门并接 tracing
- 对接 M13:用骨架保证 RAG 管线可追溯、可续跑、危险操作需审批
核心知识点详解
- 状态机 + 检查点:骨架核心是显式状态机(
idle → planning → executing → waiting_approval → done)+ 每步落检查点。状态机保证步骤有序、可重入;检查点保中断可续。比让模型自行编排可靠得多。 - 指数退避 + jitter:对外部调用封装指数退避 + 抖动(
min(base*2^n*rand(), max),封顶约 30s、2–4 次),避免瞬时重试风暴。这是所有会被限流 / 超时影响的步骤的统一保险丝。 - 全链路 tracing:每次 LLM / 工具调用都打 trace(输入摘要、耗时、模型、token、状态转移、失败原因),跨步骤可拉成链路。出问题时能定位「哪一步、哪个调用、为什么」,这是调试多步 agent 的命门。
- 常见坑:无状态机全靠模型自己编排,出错无法重入;或不打 tracing,线上失败完全黑盒、查不了。先把骨架(状态机 + 检查点 + 退避 + 审批 + tracing)搭起来,再往上长功能。
7.1 运行框架的五个要素
2026 年 AI 工程师的核心工作已经从「怎么问模型」转向「怎么设计模型的运行环境」。行业给出的经验很直白:同样是 GPT-4o 级别的模型,搭建了良好 Harness 的团队能跑起来 6 步以上的自主流程,没有 Harness 的团队停留在单次问答。
| 失败模式 | 典型例子 | 恢复策略 | 重试上限 |
|---|---|---|---|
| 可重试(瞬时) | 限流 429、网络超时、工具暂不可用 | 指数退避 + jitter 后原样重试 | 3–5 次 |
| 可修复(换策略) | 参数 schema 不符、检索空结果 | 改写查询 / 调参数后重试,不原样重放 | 2–3 次 |
| 不可重试(致命) | 权限不足、输入非法、依赖缺失 | 立即失败并上报,不消耗重试预算 | 0 次 |
| 需人工门禁 | 删数据、发邮件、付款、改生产 | 挂起落盘,等人工确认后再继续 | — |
python# Harness 的核心骨架:状态机 + 检查点 + 失败恢复 + 审批门禁
from dataclasses import dataclass, field, asdict
import json, time
@dataclass
class State:
task: str
steps: list = field(default_factory=list) # [{name, input, output, ok, error}]
artifacts: dict = field(default_factory=dict)
status: str = "running" # running | blocked | done | failed
def save(state: State, path: str):
json.dump(asdict(state), open(path, "w"), ensure_ascii=False, indent=2)
def needs_approval(action: str) -> bool:
"""高风险动作必须人工确认 —— 这是生产 Agent 的安全底线"""
return action in {"delete_records", "send_email", "execute_payment", "modify_prod"}
def run_step(state: State, step, max_retry=3):
for attempt in range(max_retry):
try:
if needs_approval(step.name) and not step.confirmed:
state.status = "blocked" # 挂起等待人工
save(state, CKPT)
return state
out = step.run() # 实际执行(工具调用/模型推理)
state.steps.append({"name": step.name, "output": out, "ok": True})
save(state, CKPT) # 每步落盘,可中断续跑
return state
except RetryableError as e:
if attempt == max_retry - 1:
state.steps.append({"name": step.name, "error": str(e), "ok": False})
state.status = "failed"
else:
step = step.variant(attempt) # 关键:换策略重试,不是原样重放
time.sleep(2 ** attempt) # 指数退避
except FatalError as e:
state.steps.append({"name": step.name, "error": str(e), "ok": False})
state.status = "failed"
break
save(state, CKPT)
return state
7.2 动手练习与自测
- ① 列出五个必须走人工门禁的动作。
- ② 给「限流 429」和「参数 schema 不符」各配一套恢复策略。
- ③ 为什么重试要「换策略」而不是原样重放?
- ④ 设计检查点内容,使任务能在崩溃后续跑。
- ⑤ 没有 tracing 时最典型的坑是什么?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 审批门禁 | 删数据、发邮件、付款、改生产、对外发布,任一即可 |
| 2 | 错误分类 | 429 → 退避重试 3–5 次;schema 不符 → 换策略重试 2–3 次 |
| 3 | 重试本质 | 相同输入下同一错误大概率复现,原样重放只是烧预算 |
| 4 | 检查点 | 任务目标、已完成步骤、每步产物、当前状态、失败原因都要落盘 |
| 5 | 可观测 | 多步 Agent 出错后无法定位是规划、工具还是模型的锅 |
8. 结构化输出与工具调用
学习路径
- 读 8.1:掌握 JSON Schema 与 Grammar / FSM 约束解码的原理
- 跑内置代码:用约束解码保证合法率并用 Pydantic 校验语义
- 完成动手练习:为一个输出定义 schema 并测结构合法率
- 对接 M13:让引用溯源输出走 strict 解码且通过 Pydantic 校验
核心知识点详解
- 结构合法 ≠ 语义正确:约束解码(Grammar / FSM / JSON 模式)只在采样时保证结构合法——字段不缺、类型不错、多余键过滤,可让合法率逼近 100%;但它不保证语义正确,金额 / 日期 / 单位仍可能算错。两层必须分开做。
- JSON Schema + Pydantic 双层校验:输出先按 JSON Schema(约束解码)保证结构,再用 Pydantic(
model_validate)做语义 / 范围校验:枚举值、必填、类型、数值范围全查一遍。结构打标是「壳」,Pydantic 是「核」。 - 约束解码的原理:Grammar / FSM 把 JSON 语法建成状态机,每步只允许「能继续构成合法 JSON」的 token——从根上杜绝非法结构。相比让模型自由生成再解析,合法率更高、解析不抛异常。
- 常见坑:以为约束解码保证语义:金额算错、日期越界照样合法;或只要求「输出 JSON」不约束结构,解析失败概率高。结构用约束解码保、语义用业务规则 / Pydantic 校验保,两层各司其职。
学习路径
- 读 8.2:理解 JSON Schema 签名、tool_calls 带 id 与 role=tool 回填
- 跑内置代码:跑并行工具调用并核对调用 id 与结果回填一一对应
- 完成动手练习:控制工具数避免超过 50 掉点
- 对接 M13:为 RAG 接入检索 / 查询改写工具并走完整调用回填
核心知识点详解
- JSON Schema 签名 + tool_calls 带 id:每个工具用 JSON Schema 声明参数签名,模型输出
tool_calls且每个调用带独立 id。id 是并行调用回填的一一对应关键——tool 消息用tool_call_id指回对应调用,否则结果张冠李戴。 - role=tool 回填:工具结果以
role=tool消息回填,且必须原样保留带 tool_calls 的 assistant 消息——否则模型「失忆」刚才想做什么,多轮工具对话就断。这是 Function Calling 最容易踩的坑。 - 工具数 >50 掉点:工具声明越多,模型选择越容易出错(注意力被稀释):列表超过约 50 个工具后调用准确率明显下降。超大工具集要分组 / 路由 / 分层描述,别一把全塞进 schema。
- 常见坑:并行调用每个 tool_call 不写独立 id、或 tool 消息不带 tool_call_id,结果错位;漏回传带 tool_calls 的 assistant 消息致模型失忆;工具数过多导致选错工具。三处都要按协议原样保真。
学习路径
- 读 8.3:掌握修复重试 → 宽松抽取 → low_confidence → 转人工的降级链
- 跑内置代码:构造一次解析失败并走完整降级链条
- 完成动手练习:为自己的输出配置降级并标记低置信
- 对接 M13:让生成解析失败时优雅降级并转人工可介入
核心知识点详解
- 降级金字塔:解析 / 校验失败按层级降级:修复重试(命模型 self-correct → 重试 1–2 次)→ 宽松抽取(正则 / 字段级抓取,只取能取到的)→ low_confidence 标记(保住流程但打低置信标)→ 转人工(最终兜底)。逐级兜住,不让整条管线崩。
- 修复重试:把解析失败的错误信息回喂给模型让它修正再输出(
把上次错误贴给它重试),成功率高但多一次调用;重试 1–2 次就换更低层,避免无限循环烧 token。 - low_confidence + 人工兜底:宽松抽取拿到的字段标记
low_confidence,高风险 / 无法确定时转人工介入。宁可标注不确定转人,也不要「自信的错误答案」直接进下游。 - 常见坑:解析失败直接抛异常让整条管线崩,没有兜底链;或重试到天荒地老烧钱。从「修复 → 宽松 → 标记 → 人工」逐级降级,每一级都要有退出条件。
8.1 JSON Schema 与约束解码
让模型输出可被程序消费,比「让它写 JSON 再正则解析」可靠得多的方式是约束解码:在生成时就把 token 限制在与 JSON Schema 一致的空间里,模型「无法不遵循」。
| 方法 | 机制 | 可靠性 | 适用 |
|---|---|---|---|
| JSON 模式(API 层) | 模型端保证输出合法 JSON | 高(结构合法,但字段语义不一定对) | 绝大多数结构化输出 |
| Grammar / FSM 约束解码 | 用上下文无关文法或有限状态机在每一步限制可生成 token | 最高(逐 token 收敛到合法结构) | 强结构化、开源模型 |
| 后处理校验 + 重试 | 先生成再 Pydantic 校验,失败重试 / 降级 | 中(依赖重试次数) | 快速实现、无约束解码支持时 |
| 正则 / 模板填充 | 给定模板只填槽位 | 低(易错位) | 极简单场景 |
python# 约束解码(以 outlines / xgrammar 思路为例):用 grammar 限制生成
# 概念:把 JSON Schema 编译成可接受的 token 序列约束,
# 每生成一个 token,就只允许「使当前前缀仍可能合法」的 token。
from pydantic import BaseModel
class Invoice(BaseModel):
invoice_no: str
amount: float
currency: str
items: list[str]
# 开启 structured output 后,模型在语法层面只能输出符合 Invoice 的 JSON,
# 不会出现「漏字段」「类型错」「多余键」等结构性错误。
# 注意:约束解码保证「结构合法」,不保证「语义正确」(如金额算错),
# 仍需业务层校验。
8.2 Function Calling 协议细节
Function calling(工具调用)已成为 Agent 的事实标准。理解协议细节才能写出稳的调用层:工具以 JSON Schema 描述签名,模型返回 tool_calls(含 id 与参数),你执行后用 role: tool 消息把结果回填,且并行 tool_calls 要各自带 id 对应。
python# MCP 风格的工具体量控制:工具越多,模型选错的概率越高
def select_tools(all_tools, query, top_k=8):
# 全量灌入(如 120 个工具)会挤爆上下文并拉低选择准确率
# 生产做法:先用向量/规则预筛,只把最相关的 top_k 注入
return rank(all_tools, query)[:top_k]
# 经验数字(业界公开经验):
# - 工具数 < 10 时,选择准确率通常 > 95%
# - 工具数 > 50 且描述重叠时,准确率可掉到 70% 以下
# - 每个工具的 schema 描述尽量控制在 2-3 句,参数用 enum 收敛取值
# 关键:把「工具选择」当成检索问题,而不是全量罗列。
python# Function calling 的请求 / 响应协议(概念性)
tools = [{
"type": "function",
"function": {
"name": "search_orders",
"description": "按订单号或用户查询订单状态",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"user_id": {"type": "string"}
},
"required": ["order_id"]
}
}
}]
# 模型返回(注意并行调用时每个 tool_call 都有独立 id):
assistant_msg = {
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_1", "type": "function",
"function": {"name": "search_orders", "arguments": '{"order_id": "A123"}'}}
]
}
# 回填结果:role=tool 且 tool_call_id 与上面对应
tool_msg = {"role": "tool", "tool_call_id": "call_1", "content": '{"status": "shipped"}'}
# 常见坑:多轮里漏掉把 assistant 的 tool_calls 也回传,模型会「失忆」刚才想调什么。
8.3 解析失败的降级路径
再稳的结构化输出也会有失败(网络、超长、模型抽风)。生产代码必须预设降级路径,而不是抛异常中断整个链路。
| 层级 | 触发条件 | 动作 | 返回标记 |
|---|---|---|---|
| 1 正常解析 | schema 校验通过 | 直接返回结构化对象 | confidence=high |
| 2 修复重试 | JSONDecode / ValidationError | 回灌错误,要求只改格式不动语义 | confidence=high |
| 3 宽松抽取 | 重试次数耗尽 | 正则 / 规则抽字段,缺失字段置 null | confidence=low |
| 4 人工兜底 | 关键字段仍缺失 | 转人工队列 / 返回错误码 | status=needs_review |
python# 解析失败的降级金字塔:重试 → 修复 → 降级 → 人工
def robust_parse(client, messages, schema, max_retry=3):
for attempt in range(max_retry):
try:
raw = client.chat(messages, response_format=json_schema(schema))
return schema.model_validate_json(raw) # 1) 正常解析
except (json.JSONDecodeError, ValidationError) as e:
# 2) 让模型「修复」:把错误回灌,请求只修正格式
messages = repair_prompt(messages, raw, e)
# 3) 降级:用宽松解析(正则抽字段)或回退规则引擎
loose = loose_extract(raw, schema)
if loose: return loose
# 4) 最终降级:转人工 / 返回错误码,绝不让链路静默崩
return fallback_to_human(messages)
# 关键:降级也要返回「可消费」的结果(哪怕带 low_confidence 标记),
# 这样下游能决定是继续还是升级人工。
low_confidence 标记,让下游决定是继续、重试还是转人工。静默给错值比报错更危险——它会让错误传播到看不见的地方。8.4 动手练习与自测
- ① 约束解码能保证什么、不能保证什么?
- ② 并行 tool_calls 时最容易出的错是什么?
- ③ 写出解析失败的四层降级顺序。
- ④ 为什么「静默返回默认值」是反模式?
- ⑤ 给订单查询工具设计参数 schema,压住参数幻觉。
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 约束解码边界 | 保证结构合法;不保证语义正确(金额、日期、单位仍需业务校验) |
| 2 | 并行调用 | tool_call_id 未一一对应,导致结果张冠李戴 |
| 3 | 降级金字塔 | 正常解析 → 修复重试 → 宽松抽取 → 人工兜底 |
| 4 | 不确定性 | 静默给默认值会掩盖不确定性,错误向上下游传播且无法察觉 |
| 5 | schema 设计 | required 必填 + enum 收敛取值 + 约束解码,三者叠加 |
9. 缓存:prompt caching 与语义缓存
学习路径
- 读 9.1:理解前缀需逐 token 相同与命中降 90% 成本的原理
- 跑内置代码:把稳定前缀放最前并实测命中后的成本与 TTFT
- 完成动手练习:设计系统提示与长文档的前缀复用结构
- 对接 M13:用 prompt caching 降低 RAG 高频前缀的推理成本
核心知识点详解
- 前缀逐 token 相同:Prompt 前缀缓存的前提是前缀逐 token 完全相同(连一个空格 / 换行不同都不命中)。所以系统提示要稳定、放最前、且不夹带易变内容——任何序列化差异都让整段失效、白付十倍输入成本。
- 命中降 90% 成本:命中后命中段的 KV 复用、不再重复计算,输入成本与 prefill 延迟可降约 90%。示例:系统 + 长文档前缀恒定时,重复查询的成本从全价降到极低——是高频 RAG 最值钱的杠杆之一。
- TTL 过期与失效:缓存有 TTL(默认 Cache-Control 控时,如几分钟到小时级);知识更新后前缀变化也会自然失效。设计时把「稳定前缀」与「易变尾段」分离,最大化复用又能及时反映更新。
- 常见坑:把检索结果 / 当前时间等易变内容塞进 system 前缀,永远不命中缓存;或未标记 cache 段,白交 prefill 钱。前缀稳定化 + 显式 cache 标记,实测命中率与成本降幅。
学习路径
- 读 9.2:理解语义缓存按相似度命中及高风险场景慎用的边界
- 跑内置代码:调相似度阈值观察命中率与误命中变化
- 完成动手练习:为自己的查询设计缓存键与失效策略
- 对接 M13:给检索结果加语义缓存并记录命中率与成本节省
核心知识点详解
- 相似度 ≥ 阈值才命中:语义缓存按「query 语义相似度 ≥ 阈值」命中并复用历史回答,对近似重复问询(如客服常见问题)可省大量调用——客服类命中率常达 20–40%。阈值调高降误命中但丢命中,调低则可能答非所问,要消融校准。
- 高风险场景慎用:语义缓存命中的是「看起来像」但不完全等价的 query——高风险 / 个性化 / 时效敏感场景慎用,避免用过期或偏差答案。用于稳定、通用的 FAQ 类最安全。
- 知识更新清缓存:文档 / 政策更新后必须清 / 失效对应缓存,否则继续命中旧答案。把「命中条目的底层来源」也存下来,更新某来源时能精确失效,而不是一刀清空。
- 常见坑:阈值设太低,近似 query 命中了却不匹配的旧答案;或更新知识后不清缓存,一直返回旧结论。调阈值 + 按来源失效 + 高风险禁缓存三管齐下。
学习路径
核心知识点详解
- 三类缓存对比:精确哈希(query 完全相同才命中,最严、零误命中,覆盖超高频重复项);Prompt 前缀缓存(前缀逐 token 相同,降 prefill 成本,覆盖稳定 system + 长文档);语义缓存(相似度 ≥ 阈值命中,覆盖近似问询但有过期 / 偏差风险)。
- 命中条件与失效成本:三类的命中粒度与失效成本不同:哈希命中最窄但失效最简单,语义命中最宽但失效要按来源追、风险最高。选型 = 在「命中收益」与「误命中 / 失效复杂度」之间权衡。
- 组合分层的用法:常见组合:哈希 / 前缀处理确定性重复(高频入口)、语义缓存处理近似问询(客服)、知识更新时按来源精确失效。按场景时效与风险分层部署,而不是三选一。
- 常见坑:不分场景乱加语义缓存,命中时返回过期答案;或不考虑失效成本,知识一变全靠一刀清导致命中率归零。选 / 不选都要讲清「命中条件、收益、失效成本」三点。
9.1 Prompt Caching:前缀复用
Prompt caching(前缀缓存)利用一个事实:很多调用的上下文前缀是重复的(系统提示、工具定义、长文档)。只要前缀相同,提供商就缓存其键值,后续调用不再重新计算这部分,命中时输入 token 费用可降 90% 量级,且首个 token 延迟显著下降。
python# Prompt caching:把稳定、长的前缀标记为可缓存(以 Anthropic 风格为例)
messages = [
{"role": "system", "content": LONG_SYSTEM, "cache_control": {"type": "ephemeral"}},
{"role": "user", "content": user_q} # 只有这部分每次不同
]
# 命中条件(各提供商略有差异,但核心一致):
# - 前缀必须「逐 token 相同」且长度通常超过阈值(如 1024+ tokens)
# - 缓存有 TTL(几分钟到几十分钟),过期后重新计算
# - 放在最前面缓存收益最大:系统提示 > 工具定义 > 长文档
# 收益量化:一个 3000 token 的系统提示,1000 次调用,
# 命中后输入成本约为原来的 10%(缓存命中价远低于普通输入价)。
python# 前缀缓存命中后的成本 / 延迟收益(示意价格)
CALLS = 1000
PREFIX = 3000 # 系统提示 token 数
PRICE = 3.0 / 1e6 # 普通输入单价
CACHE_PRICE = PRICE / 10 # 缓存命中单价(约 1/10)
no_cache = CALLS * PREFIX * PRICE
with_cache = CALLS * PREFIX * CACHE_PRICE
print(round(no_cache, 2), round(with_cache, 2)) # 9.0 元 vs 0.9 元
print("省", round((1 - with_cache/no_cache)*100), "%") # 省 90 %
# 另:命中缓存还能显著降低 TTFT —— 几千 token 的 prefill 被直接跳过。
9.2 语义缓存:相似问题命中
语义缓存与 prompt caching 不同:它不要求前缀相同,而是「问题语义相似就复用答案」。对客服 / 知识库这类重复问题多的场景,是最大成本杠杆之一。
| 缓存类型 | 命中条件 | 典型命中率 | 主要风险 |
|---|---|---|---|
| 精确哈希缓存 | 输入完全相同 | 重复问句场景 30%+ | 覆盖窄,几乎无风险 |
| Prompt 前缀缓存 | 前缀逐 token 相同 | 批量场景 80%+ | 前缀一变即失效 |
| 语义缓存 | 向量相似度 ≥ 阈值 | 客服场景 20%–40% | 相似问题答案不同 → 错答 |
python# 语义缓存:命中近似问题就复用答案
import hashlib, numpy as np
class SemanticCache:
def __init__(self, embedder, threshold=0.95):
self.embedder, self.threshold = embedder, threshold
self.keys, self.vecs, self.vals = [], [], []
def get(self, query: str):
v = self.embedder.encode(query)
if not self.vecs: return None
sims = np.dot(np.stack(self.vecs), v) / (
np.linalg.norm(np.stack(self.vecs), axis=1) * np.linalg.norm(v) + 1e-8)
i = int(np.argmax(sims))
return self.vals[i] if sims[i] >= self.threshold else None
def put(self, query: str, answer: str):
self.keys.append(hashlib.md5(query.encode()).hexdigest())
self.vecs.append(self.embedder.encode(query))
self.vals.append(answer)
9.3 动手练习与自测
- ① 复算 9.1 的收益:3000 token 前缀、1000 次调用、命中价 1/10,省多少?
- ② 什么内容不该放在可缓存前缀里?
- ③ 医疗问答要不要开语义缓存?为什么?
- ④ 知识库更新后为什么必须清语义缓存?
- ⑤ 两种缓存的失效机制分别是什么?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 收益量化 | 9.0 元 → 0.9 元,省约 90%,且 TTFT 明显下降 |
| 2 | 前缀稳定 | 时间戳、每次不同的变量、高频变动的用户私有内容 |
| 3 | 高风险场景 | 不建议,或阈值 ≥0.98;相似问题答案可能截然相反 |
| 4 | 一致性 | 否则持续返回旧答案,形成「过期事实」错误 |
| 5 | 失效机制 | 前缀缓存靠 TTL + 前缀变动自动失效;语义缓存需主动清理 |
10. 成本、路由与工程质量
学习路径
- 读 10.1:吃透模型路由 / 缓存 / 压缩 / 批处理 / 输出约束 / 小模型六个杠杆
- 跑内置代码:按杠杆逐个动手并量化每个带来的成本下降
- 完成动手练习:为一次真实调用勾选适用的杠杆组合
- 对接 M13:组合缓存 + 压缩 + 路由把 RAG 单次调用成本压到预算内
核心知识点详解
- 六个成本杠杆:模型路由(简单任务用小模型)、缓存三件套(哈希 / 前缀 / 语义,重复可降 90%+)、上下文压缩(检索后摘要、历史滚动摘要)、批处理(离线任务批量)、输出约束限制(限
max_tokens)、本地化小模型。按链路逐个量化每个杠杆的降幅。 - 成本先测后优化:第一步不是猜,而是测出来:按链路统计输入 / 输出 token、单价、命中率,找出成本集中点(通常 20% 的调用占 80% 的成本)再对症优化。先记录,再降本。
- 组合满足预算:单杠杆常不够,要组合:路由降单价 + 缓存降重复 + 压缩降长度三管齐下,才能把单次 RAG 调用压到预算内。组合的前提是每个杠杆都有可复现的降幅记录。
- 常见坑:不测就优化,优化的不是成本大头;或只上路由不碰缓存 / 压缩,成本大头的长上下文依旧爆炸。先核算、再按大头逐杠杆击破,最后组合验证预算。
学习路径
- 读 10.1:掌握难度分档与 60/30/10 流量分布的路由做法
- 跑内置代码:做一档难度路由并对比昂贵模型的调用占比
- 完成动手练习:给路由设误判监控与回退率阈值
- 对接 M13:为检索/生成配置模型路由并把回退率纳入监控
核心知识点详解
- 难度分档路由:把查询按难度分档(简单 / 中等 / 难),分别路由到小 / 中 / 大模型。示例流量分布 60/30/10:60% 简单查询走最便宜模型、30% 中等、10% 难题走最强——整体成本可降至全走大模型的约 1/5。
- 路由成本核算:算例:
0.60×0.15 + 0.30×1.0 + 0.10×4.0 ≈ 0.79元 / 千 token,对比全走大模型4.0元——约省 5 倍(参考 10.1 drill 答案 ≈0.79 与 5.06 倍)。配比要实测流量分布校准。 - 误判掉点与回退率:路由最大风险是难题被误判到小模型掉点。所以要监控 回退率(命中低档但质量不达标而升级回改的比例)与分任务质量,超标才调档。守住质量的闸门在回退机制,不在分档本身。
- 常见坑:为了省 90% 成本把难题全送小模型,质量崩了没人发现;或不接「升级回退」只做死分档,误判无法自救。配回退率监控,质量回退超阈值即调。
学习路径
- 读 10.1:掌握按输入 / 输出 token 与单价核算每千次请求成本
- 跑内置代码:用实测 token 与缓存命中比例算一次真实成本
- 完成动手练习:为自己管线写一份成本测算表
- 对接 M13:给出 RAG 每千次请求成本并把因子拆进报告
核心知识点详解
- 成本核算模板:记四个数:输入 token、输出 token、模型单价、缓存命中比例。每千次
输入×单价×(1−命中率) + 输出×单价——命中率直接影响有效付费输入。用实测 token 与命中比更新,别用估算。 - 成本集中定律:成本通常集中在少数调用:20% 的调用(超长上下文 / 贵模型 / 大输出)占约 80% 的成本。核算的价值就是找出这「少数大头」再对症。
- 可拆因子进报告:把成本拆成因子(单价 × 长度 × 调用次数 × 命中率)写进报告,让每次优化能归因:改路由降单价、压缩降长度、缓存降调用、批处理降次数。可归因 = 可优化。
- 常见坑:平均每千次成本看着不高,却没拆大头调用,超长上下文那 20% 把预算吃光;或不记命中率,改缓存后算不出省了多少。按模板拆因子、定位最大来源再优化。
10.1 成本控制的六个杠杆
| 杠杆 | 做法 | 典型收益 |
|---|---|---|
| 模型路由 | 简单任务用小模型,难题升级到强模型 | 成本降 50%–80% |
| 缓存 | 精确哈希缓存 + 语义缓存 + Prompt 前缀缓存 | 重复场景降 90%+ |
| 上下文压缩 | 检索后摘要、历史滚动摘要、去掉冗余工具 | 成本与延迟同降 |
| 批处理 | 离线任务用批处理接口(通常有折扣) | 成本降 50% |
| 输出约束 | 限制 max_tokens、要求简洁输出 | 直接按比例下降 |
| 本地化 | 敏感或高频任务用自托管小模型 | 边际成本接近电费 |
python# 语义缓存:命中近似问题就复用答案,是客服/知识库场景的最大成本杠杆
import hashlib, numpy as np
class SemanticCache:
def __init__(self, embedder, threshold=0.95):
self.embedder, self.threshold = embedder, threshold
self.keys, self.vecs, self.vals = [], [], []
def get(self, query: str):
v = self.embedder.encode(query)
if not self.vecs: return None
sims = np.dot(np.stack(self.vecs), v) / (
np.linalg.norm(np.stack(self.vecs), axis=1) * np.linalg.norm(v) + 1e-8)
i = int(np.argmax(sims))
return self.vals[i] if sims[i] >= self.threshold else None
def put(self, query: str, answer: str):
self.keys.append(hashlib.md5(query.encode()).hexdigest())
self.vecs.append(self.embedder.encode(query))
self.vals.append(answer)
# 注意:高风险场景(医疗/金融/合规)阈值要很高或不使用语义缓存,
# 因为“听起来相似”的问题可能答案完全不同。
python# 成本路由:用「难度预估」决定走哪个模型
def route(complexity):
if complexity < 0.3: # 简单:分类、抽取、改写
return "small", 0.15 # 元/千 token(示意单价)
if complexity < 0.7: # 中等:常规问答、RAG 生成
return "mid", 1.0
return "large", 4.0 # 难:多跳推理、代码、长链 Agent
# 流量分布:60% 简单 / 30% 中等 / 10% 困难
mix = 0.6*0.15 + 0.3*1.0 + 0.1*4.0
print(round(mix, 3)) # 0.79 元/千 token
print(round(4.0/mix, 2)) # 全部走大模型是它的 5.06 倍
# 路由的代价:少量难题被误判到小模型会掉点,需按任务设阈值 + 监控回退率。
10.2 动手练习与自测
- ① 复算 10.1 的混合路由成本:60%/30%/10% 分别走 0.15/1.0/4.0 元。
- ② 六个成本杠杆里,最先该做哪两个?为什么?
- ③ 成本核算模板要记哪四个数?
- ④ 路由的最大风险是什么,怎么监控?
- ⑤ 为什么成本通常集中在少数几个调用上?
| 题号 | 考点 | 关键判据 / 参考答案 |
|---|---|---|
| 1 | 路由成本 | ≈0.79 元/千 token;全走大模型约是其 5.06 倍 |
| 2 | 优先级 | 缓存与上下文压缩 —— 收益大、风险低、改动小 |
| 3 | 核算模板 | 输入 token、输出 token、模型单价、命中缓存比例 |
| 4 | 路由风险 | 难题误判到小模型掉点;监控回退率与分任务质量 |
| 5 | 成本分布 | 超长上下文调用单价 × 长度,吃掉绝大多数 token 预算 |
项目里程碑
把 M3 的索引、M6 的重排、M11 的多模态能力组装成完整 RAG:混合检索(向量 + BM25)+ 交叉编码器重排 + 上下文压缩 + 引用溯源 + 多轮改写。这是 Hamauls Orion 的主干功能。
本阶段产出(直接进入项目仓库)hamauls_orion/rag/pipeline.py:查询改写 → 混合召回 → 融合(RRF)→ 重排 → 压缩 → 生成 → 引用hamauls_orion/rag/compress.py:上下文压缩与 token 预算分配(含长文档分块策略对比)- 引用溯源:每个论断可定位到原文 span,含打不开引用时的降级行为
docs/exp/rag-ablation.md:逐组件消融实验(去掉重排 / 去掉混合检索 / 去掉压缩各掉多少)- 缓存层:检索结果缓存 + 语义缓存,记录命中率与成本节省
阶段练习项目
- 端到端落地七环节 RAG 管线,100 条真实问题评测集上跑通并产出检索侧(Recall@5 / Hit Rate)与生成侧(Faithfulness / Answer Relevancy)分开的指标
- 检索侧 Recall@5 ≥ 0.8(红线),生成侧 Faithfulness ≥ 0.8,二者分开报告
- 混合检索(向量 + BM25 + RRF)+ 重排相对「仅向量」在 Recall@5 / MRR 上有可量化提升(示例报告×或+个百分点)
- 按七环节落地:解析 → 父子切分 → 嵌入(bge-m3,L2 归一化)→ HNSW 索引 → 混合检索 → cross-encoder 重排 → 生成
- 混合检索用 RRF 融合 k=60,向量 / BM25 两路分别算 recall,证明融合增益
- 每条论断带引用 chunk id、资料不足时拒答,均落入评测集统计
- 落 100 条真实问题评测集,检索侧与生成侧分开评估并做逐组件消融表
- 生成引用输出走严格解码 + Pydantic 校验,结构合法率逼近 100%
rag_pipeline.py:完整七环节 RAG 管线(可一键跑评测)eval_set.jsonl:100 条真实问题评测集(含标注 chunk 引用)M13_report.md:检索 / 生成分开指标 + 消融表 + 引用溯源样例
不训自定义嵌入 / 精调生成模型,只用现成模型(bge-m3、cross-encoder、通用 LLM)。
- 在短 query 与多跳问题两组评测集上,逐模块(改写 / 多查询 / HyDE / 分解 / 路由)报告开启前后 Recall@k 与答案质量的净变化
- 对每个模块能给出「开启的净增益 / 何时该停用」的结论,例如 HyDE 只在短 query 提升 recall、长 query 收益为负
- 核算多查询 / 分解带来的延迟代价(示例
0.4 + 0.6n)并判断值不值
- 在固定基线 RAG 上逐个叠加模块,每次只变一个模块做消融
- 短 query 与多跳问题分开评测,分别报告召回与答案质量
- HyDE 需识别带偏场景(假设答案可能带偏)并在消融里确认净增益
- 查询分解记延迟
0.4 + 0.6n,对比静态 vs Agentic 的准确率与成本 - 路由分类依据显式可解释,不拍脑袋
query_experiments.py:各模块开关可配的实验脚本query_tradeoff.md:逐模块消融表 + 延迟账 + 何时该用 / 停用的结论
不重写整条 RAG 管线,只在其上叠加查询理解模块做消融;不引入 GraphRAG。(GraphRAG 走专属卡片)
- 在同一语料上实现基础 RAG、GraphRAG、RAPTOR 三套,两类测试集(跨实体关联 / 全局总结)上都报告 Recall@k 与答案质量
- 量化 GraphRAG 的构建 token 放大(示例 5–10 倍)与 RAPTOR 的相对成本,写清构建成本对比
- 给出「何时该用 GraphRAG / RAPTOR / 基础 RAG」的选型结论,判断依据来自评测集
- 基础 RAG 作为基线,GraphRAG 走实体-关系抽取 → Leiden 社区 → 摘要,RAPTOR 走递归聚类摘要树
- 跨实体关联类与全局总结类两套测试集分开评测,不得混成一块
- 记录每套的构建成本(token 量 / 时间)与查询成本,对比放大比例
- 选型结论落到具体查询类型(局部定位用基础 RAG、全局总结用 GraphRAG、长文档概览用 RAPTOR)
advanced_rag.py:三套方法的构建与评测脚本advanced_tradeoff.md:效果 + 构建成本对比表 + 选型结论
不引入超大规模真实语料,用小而代表性语料验证差异;不训自定义图谱抽取模型。
- 三层记忆(工作 / 会话 / 长期)都落地且有明确迁移规则,跨会话信息能检索回来供后续对话使用
- 做一个「跨会话信息保持」验证:在会话 A 写入关键偏好,新会话能靠长期记忆检索并正确引用,保持率 ≥80%
- 记忆可被用户查看 / 修正 / 删除(三次操作跑通),删除后检索结果同步消失
- 写入前做重要性筛选 + 去重与冲突消解(新覆盖旧或标注冲突)
- 检索用相关度 + 重要度 + 新近度多因子打分,top-k 注入生成上下文
- 压缩走滚动摘要(示例压到 8–15x)+ 结论结构化提取,且做指代消解
- 加遗忘机制:半衰期衰减(示例 7 天)与低价值删除
- 暴露查看 / 修正 / 删除接口并记录审计,满足 GDPR 可解释可纠正要求
memory_assistant.py:三层记忆实现 + 检索 / 压缩 / 遗忘 + 可纠正接口memory_report.md:信息保持率、压缩比、遗忘行为、三次纠正操作验证
不做多用户权限 / 分布式记忆;检索复用现成向量检索,不做复杂知识图谱。
- 多步任务能稳定跑完,且中断后可从检查点续跑(不重头开始)
- 高风险动作经人工审批门禁(挂起 → 批准 / 拒绝 → 继续),不经审批不自动执行
- 两种故障演示跑通:工具参数幻觉(约束解码压掉不存在的字段 / enum)与中断后续跑,均不崩管线
- 状态机驱动步骤(
idle → planning → executing → waiting_approval → done)+ 每步落检查点 - 失败分类四类:可重试(退避 3–5 次)/ 可修复换策略 / 不可重试立即失败 / 人工门禁挂起
- 工具调用用 JSON Schema 签名 + 约束解码,tool_calls 带独立 id、tool 结果用
role=tool+tool_call_id回填 - 解析失败走降级金字塔:修复重试 → 宽松抽取 → low_confidence 标记 → 转人工
- 全链路 tracing:每次调用的输入摘要 / 耗时 / token / 状态转移 / 失败原因可查
tool_framework.py:状态机 + 检查点 + 审批闸门 + 约束解码 + 降级链 + tracingtool_demo.md:参数幻觉治理效果、中断续跑、审批门禁三类验证演示
不接入真实支付 / 外部系统高风险动作,审批以 mock 验证;不做分布式任务编排。
常见误区
- 把长上下文当策略,把所有资料无脑塞进去,成本高且关键信息被稀释。
- 只用向量检索,遇到编号、型号、专有名词就检索不到。
- 切分把表格 / 代码切断,召回到的片段不可用。
- 嵌入忘了归一化就做内积,长文本向量模长虚高导致误排。
- 跳过重排直接取 Top-1,导致答案质量不稳定。
- 只做 demo 评估,不建评测集——上线后大量「自信的错误答案」。
- 只评估最终答案,不分开评估检索质量,出问题无法定位是检索还是生成。
- 工具调用漏回传 assistant 的 tool_calls,模型「失忆」刚才想做什么。
- 认为约束解码保证语义正确——它只保证结构合法,金额 / 日期仍要业务校验。
- 记忆系统只写不筛、只增不删,检索质量随时间下降。
- Agent 工具无审批门禁、无 tracing,出问题时完全无法定位。
- 把 Prompt 当一次性文本,不做版本化与回归测试,改坏了自己都不知道。
- 忽视 prompt caching:前缀不稳定或没标记,白白多付十倍输入成本。
面试高频问题速答
上下文工程和提示词工程的区别是什么?
提示词工程关注「这一句话怎么写」(措辞、示例、格式),是上下文工程的子集。上下文工程关注「这一轮模型看到的全部内容」:系统提示、工具定义、检索到的文档、记忆、对话历史、以及 Token 预算在这些部分之间如何分配。2026 年的重心已从前者转到后者,因为限制系统表现的往往是「关键信息有没有被正确注入」而不是「措辞是否巧妙」。此外上下文工程要解决污染、干扰、冲突三类问题——塞得太多会稀释注意力。
RAG 效果不好,你怎么定位是检索问题还是生成问题?
分开评测。先看检索指标:目标片段是否被召回(Recall@k / Hit Rate)、排名是否靠前(MRR)、重排是否改善了排序。若检索没召回,是切分 / 嵌入 / 检索策略的问题;若召回了但答案错,则是生成阶段的问题(未遵循「基于资料回答」、片段被截断、多片段冲突未处理)。此外要区分 Faithfulness(是否臆造)与 Answer Relevancy(是否切题),修法完全不同。
为什么要做混合检索和 RRF 融合?
向量检索擅长语义相近,但对精确匹配弱:订单号、型号、人名这类「必须逐字匹配」的信息,向量模型可能映射到语义相近但不正确的片段。BM25 恰好相反。两者融合常用 RRF(Reciprocal Rank Fusion,k 常取 60)按排名融合,只依赖排名不依赖分数,天然解决两种检索器分数量纲不可比的问题,实践中提升明显。
长上下文模型普及后还需要 RAG 吗?
看四维对比:成本(RAG 只付相关片段,长上下文付全部,差 10–100 倍)、延迟(长上下文 prefill 慢)、精度长尾(长上下文全可见更全)、可更新性(RAG 改文档即可,长上下文要重传重索引)。结论:文档海量、更新频繁、成本敏感仍以 RAG 为主;文档小且稳定、需全局鸟瞰、或零容忍检索失败时可不用 RAG。生产常见组合是「RAG 为主 + 长上下文兜底」。
Function calling 落地有哪些坑?
三个高频坑:① 参数幻觉——模型编造不存在的字段或 enum 值,用 required + enum + 约束解码压住;② 漏回传 tool_calls——多轮对话必须把带 tool_calls 的 assistant 消息原样保留,否则模型丢失意图;③ 并行调用结果错位——每个 tool_call 必须带独立 id,tool 消息用 tool_call_id 一一对应,否则答案张冠李戴。
约束解码能解决结构化输出的所有问题吗?
不能。约束解码(grammar / FSM / JSON 模式)只保证结构合法——字段不缺、类型不错、没有多余键。它不保证语义正确,比如金额算错、日期越界、单位混淆。所以两层要分开:结构用约束解码保,语义用业务规则校验保。解析失败时还要有降级金字塔:重试 → 修复 → 宽松提取 → 转人工。
怎么控制大模型应用的线上成本?
六个杠杆:① 模型路由(简单任务用小模型);② 缓存(精确 + 语义 + Prompt 前缀缓存,重复场景可降 90%+);③ 上下文压缩(检索后摘要、历史滚动摘要、精简工具定义);④ 批处理离线任务;⑤ 限制输出长度;⑥ 高频或敏感任务本地化。前提是先做成本核算:按链路统计输入 / 输出 token 与命中率,找出成本集中的少数调用再优化——通常 20% 的调用占用 80% 的成本。