为什么需要 nano-graphrag
这一部分不写代码。先把问题讲清楚:大模型缺什么、普通 RAG 补了什么、还差什么,最后才引出 GraphRAG。
大模型回答不了的两个问题
入门假设你手里有一批公司内部文档,你想让 AI 帮你回答问题。会遇到两类麻烦:
文档 A:「小李在杭州的茶百道工作,负责新茶饮研发。」
文档 B:「茶百道是一家 2008 年成立于成都的奶茶连锁品牌。」
你问:「小李的公司成立于哪一年?」
答案是 2008 年,但必须经过两步:多跳推理——先由「小李的公司」推出是「茶百道」,再由「茶百道」查到「2008 年」。单独任何一段文字里都没有完整答案。如果你的搜索只返回了文档 A,模型就会答"不知道"。
检索增强生成(RAG):先查再答
入门RAG(Retrieval-Augmented Generation,检索增强生成)的思路一句话就能说完:
开卷考试 = 允许翻书,但先把最相关的几页翻出来,再基于翻出来的页作答。
RAG 就是"开卷考试"里的翻书动作(检索)+ 作答动作(生成)。
完整流程分两个阶段:
存进检索库
找出最相关的几块
一起交给 LLM
大幅减少编造
被切成的每一小块叫一个 分块(chunk)。为什么按块存,而不是整篇存?因为检索时"粒度"要小——一篇 5000 字的文章里可能只有 200 字和你的问题相关,返回整篇既浪费又会淹没重点。
那么"找出最相关的几块"具体怎么实现?这就引出下一课的向量检索。
向量检索:让机器"读懂"语义
入门怎么判断"哪几块和问题相关"?最朴素的办法是关键词匹配——搜"奶茶"就找含"奶茶"的块。但"开奶茶店"和"经营饮品连锁"没有共同关键词,语义上却是近义的。
Embedding(向量化)解决这个问题:用一个专门的模型把文字变成一串数字(一个坐标点),规则是——意思越接近的文字,坐标离得越近。
「开奶茶店」和「经营饮品连锁」是两座挨得很近的城市;
「光伏板清洗」是十万八千里外的另一座城市。
所谓检索,就是以"问题"这座城为圆心,找最近的几座城。
存这些坐标的地方叫向量数据库(向量库)。查询时:把问题也转成坐标 → 计算距离 → 返回最近的 top-k 块。距离常用余弦相似度衡量。
nano-vectordb 的超轻量向量库(作者自己写的),数据就是一个 JSON 文件,开箱即用——初学者不用装任何数据库服务。传统 RAG 的三个短板
入门普通 RAG(切块 → 向量检索)很好用,但有三个天然短板,正好对应第一课的两个问题:
| 短板 | 用茶百道例子说明 | 本质原因 |
|---|---|---|
| ① 链条断裂 | 问「小李的公司成立于哪年」,系统只召回文档 A(提到小李和茶百道),答案所在的文档 B 没被召回,模型答不出 2008。 | 检索是"一步到位"的,不会顺着线索再查第二步。 |
| ② 全局失明 | 问「这批文档里的公司整体情况如何」,只能返回 top-10 个碎片,谁也代表不了全貌。 | top-k 天然只给局部,无法回答"整体怎么样"。 |
| ③ 信息孤岛 | 同一个实体「茶百道」在 5 个文档里各有一段描述,检索时可能只命中其中 1 段,其他 4 段白白浪费。 | 块与块之间是割裂的,没有"这些块说的是同一件事"的概念。 |
知识图谱与 GraphRAG 思路
入门知识图谱(Knowledge Graph)就是上一课说的那张"网":
- 节点(Node)= 实体:人、公司、地点、事件…… 如「小李」「茶百道」「杭州」
- 边(Edge)= 关系:两个实体之间有联系。如 小李 —就职于→ 茶百道
- 每个节点/边上还可以挂一段描述文字和一个权重(关系多强)
用例子画出来是这样:
[小李] ──就职于(权重8)──▶ [茶百道] ──成立于2008──▶ [成都] │ │ └──位于──[杭州] ──总部位于──[成都] 问题「小李的公司成立于哪年?」在图上就是走两步: 小李 ──就职于──▶ 茶百道 ──成立于──▶ 2008 ✔ 两个块的信息被边"串"起来了
GraphRAG 的完整思路(微软 2024 年提出,nano-graphrag 是它的极简实现)分两个阶段:
让 LLM 抽出实体关系
拼成知识图谱
给每个社区生成
一份摘要报告
读社区报告(治②)
三个短板与解法一一对应:
| 短板 | GraphRAG 的解法 |
|---|---|
| 链条断裂 | 答案相关的实体和关系被边连着,检索时沿着边走一步、两步,把断开的链条接上 |
| 全局失明 | 提前为每个社区写好摘要报告,查询时读报告而不是读碎片 |
| 信息孤岛 | 同一实体在图里是同一个节点,所有文档里的描述都合并挂在这个节点上 |
先看全景:一次写入、一次查询
先不纠结细节,把整个系统的"输入 → 中间产物 → 输出"走一遍,建立地图感。后面第三、四部分再逐站深入。
insert:把文章变成知识
入门使用这个库只有两个动作,这是第一个。你写代码时它长这样:
from nano_graphrag import GraphRAG graph = GraphRAG(working_dir="./my_kg") graph.insert("小李在杭州的茶百道工作……") # 把文档喂进去
这一行 insert 背后发生了 6 件事,每件都在磁盘 ./my_kg/ 留下产物:
query:三种提问方式
入门第二个动作是提问,通过 QueryParam(mode=...) 选三种模式之一:
from nano_graphrag import QueryParam graph.query("小李的公司成立于哪一年?", QueryParam(mode="local")) # 问细节 graph.query("这批公司整体发展如何?", QueryParam(mode="global")) # 问全局 graph.query("茶百道总部在哪?", QueryParam(mode="naive")) # 翻原文
| 模式 | 适用提问 | 直觉类比 | 数据来源 |
|---|---|---|---|
| local 本地/局部检索 |
关于某个实体的具体事实,需要串线索 | 顺着人脉查:先找到当事人,再问他的同事、他的事迹、相关的会议纪要 | 向量库找回实体 → 沿图扩展 → 报告+原文 |
| global 全局检索 |
整体情况、主题归纳、跨社区的大问题 | 开总结大会:每个小组(社区)先交一份发言稿,再把所有发言稿汇总成最终报告 | 全部社区报告的 Map-Reduce |
| naive 朴素 RAG |
简单事实、或想对比"不用图"的效果 | 直接翻书:不查人脉不看报告,向量搜原文照读 | 分块向量库 top-k 原文 |
项目文件地图:每个文件管什么
入门clone 下来的项目长这样(只看核心目录 nano_graphrag/):
| 文件 | 用大白话说它干嘛的 | 何时会细读 |
|---|---|---|
graphrag.py | 总指挥:定义所有配置项,串起 insert/query 的每一步 | 第一站(第 6、7 课的流程都在这) |
_op.py | 加工厂:切块、抽取、合并、写报告、三种查询的全部算法 | 第三、四部分的主体 |
base.py | 零件接口图:定义"一个存储零件必须有哪些方法" | 第 19 课 |
_storage/ | 仓库:接口的具体实现(JSON 文件版、图数据库版……) | 第 19 课 |
_llm.py | 话务员:怎么调 OpenAI/Azure 等接口,怎么重试、怎么缓存 | 第 18 课 |
_utils.py | 工具抽屉:数 token、算 MD5、限并发、容错解析 JSON 等小工具 | 第 17、18 课 |
prompt.py | 话术本:所有发给 LLM 的提示词都存在这一个字典里 | 第 10、13 课 |
_splitter.py | 按标点符号切块的备选切法 | 第 9 课顺带看 |
写入流程逐步拆解
对应 insert 的第 ②~⑥ 步(第 9~13 课)。每课结构固定:做什么 → 为什么 → 例子走一遍 → 对应源码在哪。
第一步:切块与去重
基础做什么
把文档切成小块(chunk)。默认策略是按 token 数滑动窗口硬切:每块最多 1200 个 token,相邻块重叠 100 个 token。
chunk-3f2a…(内容的 MD5)。如果 5 天后你又 insert 了一次完全相同的文档 A → 系统算出的编号一模一样 → 查库发现已存在 → 直接跳过,不花一分钱 LLM 调用。这就是"幂等去重"。
为什么要 MD5 编号?
MD5 是把任意内容压缩成固定长度指纹的函数:内容相同 → 指纹必然相同;内容差一个字 → 指纹完全不同。用指纹当主键,去重、增量更新、"只处理新内容"全都免费获得了。
两种切法
| 策略 | 做法 | 特点 |
|---|---|---|
chunking_by_token_size默认 | 无视语义,每 1200 token 一刀 | 简单可预测,什么语料都能用 |
chunking_by_seperators | 先按段落→换行→中英文句号→空格的优先级切开,再拼到 1200 以内 | 切口落在自然边界,句子完整,对后续 LLM 抽取更友好 |
对应源码
_op.py:31 chunking_by_token_size · _op.py:94 get_chunks · _utils.py:186 compute_mdhash_id
# _op.py 滑动窗口,就 3 行核心逻辑: for start in range(0, len(tokens), max_token_size - overlap_token_size): chunk_token.append(tokens[start : start + max_token_size]) # 每次前进 1100,取 1200
第二步:让 LLM 圈出实体
基础做什么
把每个块发给 LLM,让它按照固定话术,输出这段文字里的实体(是什么)和关系(谁和谁怎么样)。这是整个系统最花钱的一步,所有块并发进行(同时发很多请求)。
发给 LLM 的提示词长什么样
提示词存在 prompt.py,简化后核心是这样:
识别文中所有实体,格式:("entity" <|> 实体名 <|> 类型 <|> 描述)
识别实体间关系,格式:("relationship" <|> 源 <|> 目标 <|> 描述 <|> 强度)
用 ## 分隔每条记录,全部完成后输出 <|COMPLETE|>
--- 示例(few-shot)---
("entity" <|> "Alex" <|> "person" <|> "Alex 是个观察力敏锐的角色……")##
("relationship" <|> "Alex" <|> "Taylor" <|> "Alex 受 Taylor 影响" <|> 7)
--- 真实数据 ---
Text: {你切好的那个块}
LLM 返回:
("entity" <|> "小李" <|> "person" <|> "小李是茶百道的研发人员") ("entity" <|> "茶百道" <|> "organization" <|> "茶百道是小李就职的公司") ("entity" <|> "杭州" <|> "geo" <|> "杭州是小李工作的城市") ("relationship" <|> "小李" <|> "茶百道" <|> "小李就职于茶百道" <|> 8)而这种分隔符协议非常皮实:
## 切成一条条记录,<|> 切开一条记录的字段——某一条坏了只丢那一条,解析用一个正则就够。对能力较弱的开源模型尤其友好。Gleaning:追问"还有漏的吗?"
第一次抽取后,系统会再发一句 "MANY entities were missed in the last extraction. Add them below…"(上次漏了很多,补上),让模型补充;再问一句"还有吗?YES/NO"决定是否继续。这叫 gleaning(打谷扬场式的复查),就像写完作文后再检查一遍错别字。entity_extract_max_gleaning 控制最多复查几轮(默认 1 轮)。
第三步:合并成一张图
基础做什么
每个块各自抽出了实体,现在要把全库结果汇成一张图。核心问题是:10 个块都提到了"茶百道",图里应该有 10 个节点还是 1 个?——1 个。同名实体合并为同一节点。
合并时三个字段怎么处理
| 字段 | 合并策略 | 为什么这么设计 |
|---|---|---|
| 类型 entity_type | 投票:谁票多算谁 (5 次说 organization,1 次说 company → 取 organization) | 不同块对同一实体的判断可能不一致,众数最稳 |
| 描述 description | 去重后字典序拼接,用 <SEP> 连起来 | 每块贡献的信息都要保留,字典序保证结果确定 |
| 来源 source_id | 并集拼接:记录"这个节点被哪些块提到" | 后面查询时靠它反查原文(第 14 课会用到) |
边(关系)合并同理,两点差异:
- 权重累加:两个实体在不同块里反复共同出现 → weight 相加,说明关系越铁
- 图是无向的:(A,B) 和 (B,A) 是同一条边,聚合前统一按字母序排好
合并完成后,每个实体(名称+描述)会被向量化存进实体向量库——这就是第 6 课流程里的最后一步⑥,local 查询正是靠它找回相关实体的。
[小李] —就职于(8)→ [茶百道] —成立于(?)→ [成都],[茶百道] 旁边挂"总部:成都",[小李] 旁边挂"工作地:杭州"。
跨文档的链条已经连上了——这正是第 4 课病历里"链条断裂"的治愈现场。
_op.py:182 _merge_nodes_then_upsert · _op.py:230 _merge_edges_then_upsert · _op.py:111 描述超长摘要
第四步:社区——给图分组
进阶做什么
图建好后,可能有成千上万个节点。把互相连接紧密的一簇节点划成一组,一个组就叫一个社区(community)。这一步由聚类算法完成,nano-graphrag 用的是 Leiden 算法。
为什么要"层级"?
圈子可以套圈子:「茶百道-研发圈」内部又分「小李-小王-小张」小组。Leiden 输出的就是多层结果(level 0、1、2……):层数字越小圈越大(level 0 ≈ 全公司大部门,level 2 ≈ 项目小组)。同一节点可以同时属于多个层的社区。
level 0: [====== 一个大社区(100 人)======]
level 1: [== 社区A(30人)==] [== 社区B(40人)==]
level 2: [小组A1] [小组A2] [小组B1] [小组B2]
代码里每个节点存一份归属记录:
"clusters": [{"level":0,"cluster":5}, {"level":1,"cluster":12}, ...]
occurrence(出现度)是什么?
每个社区会算一个 0~1 的值:该社区覆盖的文本块数 ÷ 全库最大社区的文本块数。直觉上,覆盖原文越多的社区,越"有料"——后面查询排序时会用到它。
_storage/gdb_networkx.py:230 _leiden_clustering · _storage/gdb_networkx.py:170 community_schema
第五步:给社区写报告
进阶做什么 & 为什么
对每个社区,把它内部的实体、关系打包,让 LLM 写一份结构化摘要报告。这一步是在为"全局问题"预存答案:
报告里有什么
LLM 被要求返回一个 JSON(提示词在 prompt.py 的 community_report):
{
"title": "茶百道研发与成都总部社区",
"summary": "该社区围绕茶百道展开,包含其成都总部、杭州研发中心及人员……",
"rating": 6.5, // 0~10 分:这个圈子的重要程度
"rating_explanation": "涉及核心业务实体,影响中等偏上",
"findings": [ // 5~10 条要点,每条=标题+展开
{"summary":"茶百道的双城布局", "explanation":"成都总部与杭州研发……"},
...
]
}
报告会存成两份:JSON(保留 rating 等结构化字段,供排序过滤用)和 Markdown 文本(供直接阅读/拼进提示词)。
怎么保证不超模型窗口:打包预算
一个社区可能有几百个节点,全塞进提示词会爆窗口。系统有一套打包策略(_pack_single_community_describe):
_op.py:625 generate_community_report · _op.py:464 打包预算逻辑
查询流程逐步拆解
insert 之后知识就备好了。这一部分回答:三种模式各自怎么把问题变成答案?
local:刨根问底式提问
基础它怎么回答"小李的公司成立于哪一年?"
五步走,全部发生在 _build_local_query_context 里:
注意第②步:召回的实体可能只在文档 A 出现,但顺着一条边就把文档 B 拽出来了——这就是"沿图扩展"对第 4 课病历①的治愈。
- 报告:被越多召回实体指向的社区越相关,评分高的优先
- 边:两端都是"社交达人"(度数高)且 weight 大的边更重要
- 原文:不只看"是不是召回实体写的",还看"有多少邻居共同提到它"——被多方佐证的原文更可信
_op.py:844 _build_local_query_context · _op.py:935 local_query
global:我要的是全局
进阶它完全不走实体向量,只读"年报"
问「这批文档里的公司整体发展如何?」——没有任何单个实体能回答这个问题。global 的策略是把第 13 课写好的所有社区报告拿来,走一轮 Map-Reduce:
注意:发一摞报告给"人"而不是发一张清单,是因为一摞报告能装下更多 token——分组本身就是控制长度的手段。
对比 local,二者的分工非常清晰:
| local | global | |
|---|---|---|
| 读什么 | 实体 + 边 + 原文 + 少量报告 | 只读社区报告 |
| LLM 调用 | 1 次 | 分组数 + 1 次 |
| 擅长 | "X 的 Y 是多少"(细节、多跳) | "整体怎么样"(归纳、主题) |
_op.py:1017 global_query · _op.py:970 _map_global_communities
naive:不看图直接翻书
入门最简单的一个模式,四步:
它与 local 的区别只有一个开关:enable_naive_rag=True 时才会额外建一个分块向量库(local 用的是实体向量库,两者是不同的库)。
底层支撑机制
前几课反复出现的几个"配角"——token 预算、缓存、限流、存储接口——单独拎出来讲透。懂了这四课,你就理解了这个项目 80% 的代码细节。
token 与预算:为什么到处截断
基础先说 token
token 是 LLM 的"字数单位":模型有上下文窗口上限(如 128k token),API 按 token 计费。项目里所有"1200"、"500"、"16384"之类的数字都是 token 数。项目用一个叫 TokenizerWrapper 的工具把文字数成 token(底层是 tiktoken),全项目数 token 只走这一个口,换成别的分词器也不影响别处。
核心矛盾
装得下:固定的上下文窗口。
解法只有一个:排序 + 截断——先把重要的排前面,再按预算从头装。
全项目的统一工具:truncate_list_by_token_size
def truncate_list_by_token_size(list_data, key, max_token_size, tokenizer): tokens = 0 for i, data in enumerate(list_data): # 按顺序累加 tokens += len(tokenizer.encode(key(data))) + 1 if tokens > max_token_size: return list_data[:i] # 装满就停,返回前缀 return list_data
它在源码里出现了十几次,每次调用前列表都已经排好序,所以"返回前缀"="保留最重要的"。这就是前面各课反复看到的"预算 4000 tokens""按评分截断"背后的同一行代码。
best_model_max_token_size 减去模板长度)。查询侧:预算写死在
QueryParam 里(local 三路 4000+4800+3200,global 16384),你可以按自己模型的窗口改这几个数。truncate_list_by_token_size 一处实现,全局复用。调用 LLM 的三件保险
基础LLM API 会限流、会抖动、会重复计费。_llm.py 给每次调用上了三道保险:
保险一 · 重试(摔倒了爬起来)
遇到限流错误(RateLimitError)或连接错误时,最多再试 5 次,间隔 4→10 秒指数变长。用的是 tenacity 库的 @retry 装饰器,套在每个调用函数外面,调用方无感知。
保险二 · 缓存(同一道题不答两次)
② 先查缓存:这个指纹见过吗?见过 → 直接返回上次的答案,一分钱不花;
③ 没见过 → 调 API → 把答案连同指纹存进
kv_store_llm_response_cache.json,并立刻落盘。实际效果:重建社区报告时,只要社区内容没变,提示词就一样,第二次全部命中缓存。中途失败重跑 insert,也不用为已完成的部分再付一次钱。
_llm.py:50 openai_complete_if_cache · _utils.py:220 compute_args_hash
保险三 · 限流(进闸门先排队)
抽取几百个块时会同时发几百个请求,API 必然报错。系统给每个 LLM/Embedding 函数外面套了一层并发闸门(limit_async_func_call):同时最多 16 个在飞(可配),第 17 个请求必须等前面有人完成才放行。
_utils.py:276 limit_async_func_call
三类存储与"可插拔"
基础系统里的数据其实分三种形态
| 形态 | 例子 | 对应接口 | 默认实现 |
|---|---|---|---|
| 键值对 id → 记录 |
文档、分块、LLM 缓存、社区报告 | BaseKVStorage |
JsonKVStorage 一个 JSON 文件 |
| 向量 文字 → 坐标 |
实体库(local 用)、分块库(naive 用) | BaseVectorStorage |
NanoVectorDBStorage JSON + 暴力算距离 |
| 图 节点 + 边 |
实体关系图 + 社区归属 | BaseGraphStorage |
NetworkXStorage 内存图 → graphml 文件 |
"可插拔"是怎么实现的?
base.py 只定义接口(一堆 raise NotImplementedError 的方法声明),不关心后面是谁。具体实现放在 _storage/ 里。想换零件?改一个字段:
from nano_graphrag._storage.gdb_neo4j import Neo4jStorage graph = GraphRAG( graph_storage_cls=Neo4jStorage, # 图存储从 NetworkX 换成 Neo4j,就这一行 vector_db_storage_cls=HNSWVectorStorage, # 向量库同理 )
__post_init__ 里写 self.graph_storage_cls(...) 而不关心具体类。还有个容易忽略的设计:统一的落盘时机
所有存储都继承同一个基类,带三个钩子函数:index_start_callback(开始写入前调用)、index_done_callback(全部写完后调用)。默认实现是"整个 insert 过程都在内存里改,最后一次性把 JSON/graphml/向量库写到磁盘"——避免写一半崩了留下脏数据。
base.py:59-186 接口定义 · graphrag.py:360 _insert_done 统一落盘
动手路线与下一步
实践建议的上手顺序(由浅入深,每步都有反馈)
examples/no_openai_key_at_all.py 或接自己的 LLM(OpenAI/DeepSeek/Ollama 都行),对一段几百字文本执行一次 insert + 三种 query。目标:见到真实回答。working_dir 下的 5 个文件:kv_store_*.json 里翻几条 chunk 和社区报告;graphml 文件用文本编辑器搜 clusters。把"第 6 课的 6 步流程"和磁盘文件对上号。QueryParam(mode="local", only_need_context=True)——直接输出检索到的 CSV 上下文,不经过 LLM。你会亲眼看到四路证据长什么样、排序截断的效果,这是理解检索质量最快的途径。prompt.py 里的 DEFAULT_ENTITY_TYPES,让抽取适配你的领域(如法律/医疗);② 调 QueryParam 里的预算数字;③ 把 chunk_token_size 从 1200 改成 600 观察差异。改完跑对比,才算真学会。可以继续学的概念(按性价比排序)
| 方向 | 要学什么 | 解决什么 |
|---|---|---|
| GraphRAG 方法论本身 | 微软 GraphRAG 原版论文(GraphRAG: From Local to Global) | nano 删掉的 covariates、原版 map-reduce 设计 |
| 图算法 | 社区发现(Leiden/Louvain)、节点度数、连通分量 | 第 12 课背后的数学在做什么 |
| 检索质量 | 召回率/精确率评测、rerank 重排 | 如何科学判断"检索得好不好" |
| 工程扩展 | 把 JsonKV/NanoVectorDB 换成 SQLite/Qdrant 等生产级存储 | 大规模语料下的性能与并发 |
| 同系项目 | LightRAG(HKUDS)、微软 GraphRAG 原版 | 对照阅读,理解不同取舍 |
附录 · 术语速查表
随查随用| 术语 | 一句话解释 | 首见于 |
|---|---|---|
| LLM | 大语言模型,如 GPT、Claude、DeepSeek、通义等 | 01 |
| 上下文窗口 | 模型一次能"看见"的最大文字量 | 01 |
| 多跳推理 | 需要连续串起 2+ 条线索才能得出的答案 | 01 |
| RAG | 检索增强生成:先查资料再让模型作答 | 02 |
| chunk(分块) | 被切出来的文字片段,检索的基本单位 | 02 |
| embedding(向量化) | 把文字变成坐标,语义近则坐标近 | 03 |
| 向量库 | 存坐标、按"谁离我最近"检索的数据库 | 03 |
| 余弦相似度 | 衡量两个向量方向接近程度的指标,1 为最像 | 03 |
| 知识图谱 | 节点=实体、边=关系的知识组织方式 | 05 |
| GraphRAG | 先建知识图谱再检索回答的 RAG 范式 | 05 |
| 实体 / 关系 | 图中的节点 / 连接节点的边 | 05 |
| 社区(community) | 图上互相连接紧密的一簇节点,即"圈子" | 12 |
| Leiden | 一种自动给图分圈的聚类算法,支持层级 | 12 |
| 层级(level) | 社区的嵌套深度,数字越小圈越大 | 12 |
| occurrence | 社区覆盖的原文块占比,衡量"有料程度" | 12 |
| 社区报告 | 让 LLM 给每个社区写的摘要,含评分 | 13 |
| Map-Reduce | 先分头处理再汇总的两阶段模式 | 15 |
| token | 模型的计字/计费单位 | 17 |
| 预算截断 | 排序后按 token 上限保留前缀的通用手法 | 17 |
| LLM 缓存 | 相同提问直接返回上次答案,避免重复计费 | 18 |
| 并发限流 | 同时最多 N 个请求在飞,多的排队 | 18 |
| 可插拔 | 定义接口+注入实现,换零件不动主流程 | 19 |
| 幂等 / 去重 | 重复执行不产生副作用,靠内容 MD5 实现 | 09 |
| gleaning | 抽取后再追问"还有漏的吗"的补漏机制 | 10 |
| best/cheap 双模型 | 重要任务用贵模型、体力活用便宜模型的分工 | 11 |