ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,245 @@
"""离线对比:上下文化记忆块 vs 原始记忆块对『用户事实召回』的影响(实验 3-11)。
本模块是一个**完全离线、无需 API/外部服务**的对照实验,用于量化本章核心论点:
在把对话记忆块送入索引/嵌入之前,先为每个块生成一段『上下文前缀』(情境锚定),
能显著提升脱离上下文的孤立片段(如『好的,就订这个吧』)被正确召回的概率。
方法说明(诚实边界):
- 生产管线用 LLM 逐块生成 context、并用神经嵌入 + 检索服务做稠密/混合检索(需 API Key)。
- 这里用**确定性的 BM25 词法检索**作为无需 API 的代理:对同一份 context
分别度量『不拼接(plain)』与『拼接后再索引(contextual)』两种方式的召回。
变量只有『索引文本是否含 context 前缀』,因此结果直接反映上下文化本身的贡献。
- 指标:Recall@1、Recall@3、MRR(口径同书中 recall@k:前 k 个结果命中即算召回)。
数据集见同目录 memory_qa_eval.json(受控教学集,可自行替换 --dataset)。
"""
import argparse
import json
import math
import re
from pathlib import Path
from typing import Dict, List, Tuple
DEFAULT_DATASET = str(Path(__file__).parent / "memory_qa_eval.json")
_CJK = r"一-鿿"
def tokenize(text: str) -> List[str]:
"""CJK 感知的轻量分词:英文/数字按词,中文按『单字 + 相邻双字』。
对相邻双字(bigram)保留位置邻接关系,避免跨片段产生虚假二元组。
"""
text = text.lower()
tokens: List[str] = []
tokens.extend(re.findall(r"[a-z0-9]+", text))
for run in re.findall(f"[{_CJK}]+", text):
chars = list(run)
tokens.extend(chars)
for i in range(len(chars) - 1):
tokens.append(chars[i] + chars[i + 1])
return tokens
class BM25:
"""标准 BM25,纯 Python 实现,无第三方依赖。"""
def __init__(self, corpus_tokens: List[List[str]], k1: float = 1.5, b: float = 0.75):
self.k1 = k1
self.b = b
self.corpus = corpus_tokens
self.N = len(corpus_tokens)
self.doc_len = [len(d) for d in corpus_tokens]
self.avgdl = sum(self.doc_len) / self.N if self.N else 0.0
self.tf: List[Dict[str, int]] = []
df: Dict[str, int] = {}
for doc in corpus_tokens:
counts: Dict[str, int] = {}
for t in doc:
counts[t] = counts.get(t, 0) + 1
self.tf.append(counts)
for t in counts:
df[t] = df.get(t, 0) + 1
self.idf = {
t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in df.items()
}
def score(self, query_tokens: List[str], idx: int) -> float:
counts = self.tf[idx]
dl = self.doc_len[idx]
s = 0.0
for t in query_tokens:
if t not in counts:
continue
idf = self.idf.get(t, 0.0)
freq = counts[t]
denom = freq + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
s += idf * (freq * (self.k1 + 1)) / denom
return s
def rank(self, query_tokens: List[str]) -> List[int]:
scored = [(self.score(query_tokens, i), i) for i in range(self.N)]
# 稳定排序:分数降序,平局按原始下标升序
scored.sort(key=lambda x: (-x[0], x[1]))
return [i for _, i in scored]
def _gold_rank(ranked_ids: List[str], gold_id: str) -> int:
"""返回 gold 在排序结果中的名次(1-based);未找到返回 0。"""
for pos, cid in enumerate(ranked_ids, start=1):
if cid == gold_id:
return pos
return 0
def evaluate(chunks: List[dict], queries: List[dict], mode: str) -> Tuple[dict, List[dict]]:
"""按指定模式索引并检索,返回聚合指标与逐条明细。
mode='plain' 索引文本 = chunk['text']
mode='contextual' 索引文本 = chunk['context'] + '\n' + chunk['text']
"""
ids = [c["id"] for c in chunks]
if mode == "plain":
docs = [c["text"] for c in chunks]
elif mode == "contextual":
docs = [f"{c.get('context','')}\n{c['text']}" for c in chunks]
else:
raise ValueError(f"unknown mode: {mode}")
bm25 = BM25([tokenize(d) for d in docs])
r1 = r3 = 0
mrr = 0.0
details = []
for q in queries:
ranked = [ids[i] for i in bm25.rank(tokenize(q["q"]))]
rank = _gold_rank(ranked, q["gold"])
hit1 = 1 if rank == 1 else 0
hit3 = 1 if 1 <= rank <= 3 else 0
r1 += hit1
r3 += hit3
mrr += (1.0 / rank) if rank else 0.0
details.append({"q": q["q"], "gold": q["gold"], "rank": rank,
"hit@1": hit1, "hit@3": hit3, "top": ranked[:3]})
n = len(queries)
metrics = {
"recall@1": r1 / n,
"recall@3": r3 / n,
"mrr": mrr / n,
"n_queries": n,
}
return metrics, details
def run_comparison(dataset_path: str, output_path: str = None, verbose: bool = True) -> dict:
data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
chunks, queries = data["chunks"], data["queries"]
plain_m, plain_d = evaluate(chunks, queries, "plain")
ctx_m, ctx_d = evaluate(chunks, queries, "contextual")
if verbose:
print("=" * 68)
print("实验 3-11 · 上下文化记忆块对用户事实召回的影响(离线 BM25 代理)")
print(f"数据集: {dataset_path}")
print(f"记忆块: {len(chunks)} 查询: {len(queries)}")
print("=" * 68)
print(f"{'方法':<28}{'Recall@1':>10}{'Recall@3':>10}{'MRR':>10}")
print("-" * 68)
print(f"{'Plain(直接索引原始块)':<24}{plain_m['recall@1']:>10.3f}"
f"{plain_m['recall@3']:>10.3f}{plain_m['mrr']:>10.3f}")
print(f"{'Contextual(上下文化后索引)':<22}{ctx_m['recall@1']:>10.3f}"
f"{ctx_m['recall@3']:>10.3f}{ctx_m['mrr']:>10.3f}")
print("-" * 68)
d1 = ctx_m["recall@1"] - plain_m["recall@1"]
d3 = ctx_m["recall@3"] - plain_m["recall@3"]
dm = ctx_m["mrr"] - plain_m["mrr"]
print(f"{'提升(Δ)':<26}{d1:>+10.3f}{d3:>+10.3f}{dm:>+10.3f}")
print("=" * 68)
print("\n逐查询名次(gold 在检索结果中的位次,越小越好;0 表示未召回):")
print(f"{'查询':<38}{'Plain':>8}{'Ctx':>8}")
print("-" * 68)
pd = {x["q"]: x["rank"] for x in plain_d}
for x in ctx_d:
q = x["q"][:36]
print(f"{q:<38}{pd[x['q']]:>8}{x['rank']:>8}")
print("=" * 68)
print("说明:孤立片段(如『好的,就订这个吧』)在 Plain 下缺乏可检索信号,")
print("上下文化后被『锚定』回其情境,因而召回名次明显提升。")
result = {
"dataset": dataset_path,
"n_chunks": len(chunks),
"plain": plain_m,
"contextual": ctx_m,
"delta": {
"recall@1": ctx_m["recall@1"] - plain_m["recall@1"],
"recall@3": ctx_m["recall@3"] - plain_m["recall@3"],
"mrr": ctx_m["mrr"] - plain_m["mrr"],
},
"plain_details": plain_d,
"contextual_details": ctx_d,
}
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text(json.dumps(result, ensure_ascii=False, indent=2),
encoding="utf-8")
if verbose:
print(f"\n结果已保存至: {output_path}")
return result
def single_query(dataset_path: str, query: str, top_k: int = 3, verbose: bool = True) -> dict:
"""针对单条查询,离线对比 plain 与 contextual 两种索引下的 Top-K 检索结果。"""
data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
chunks = data["chunks"]
ids = [c["id"] for c in chunks]
qt = tokenize(query)
out = {"query": query}
for mode in ("plain", "contextual"):
if mode == "plain":
docs = [c["text"] for c in chunks]
else:
docs = [f"{c.get('context','')}\n{c['text']}" for c in chunks]
bm25 = BM25([tokenize(d) for d in docs])
ranked = bm25.rank(qt)
out[mode] = [{"id": ids[i], "score": round(bm25.score(qt, i), 4)}
for i in ranked[:top_k]]
if verbose:
print("=" * 60)
print(f"查询: {query}")
print("=" * 60)
for mode in ("plain", "contextual"):
label = "Plain(原始块)" if mode == "plain" else "Contextual(上下文化)"
print(f"\n[{label}] Top-{top_k}:")
for rank, item in enumerate(out[mode], 1):
print(f" {rank}. {item['id']:<18} score={item['score']}")
print("=" * 60)
return out
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="离线对比上下文化记忆块 vs 原始记忆块的用户事实召回(实验 3-11,无需 API)",
)
p.add_argument("--dataset", default=DEFAULT_DATASET,
help="记忆问答对照集 JSON 路径(默认:memory_qa_eval.json")
p.add_argument("--output", default=None,
help="将对比结果(含逐查询明细)保存为 JSON 的路径(默认不保存)")
p.add_argument("--quiet", action="store_true", help="仅输出结果 JSON,不打印表格")
return p
def main():
args = build_arg_parser().parse_args()
run_comparison(args.dataset, output_path=args.output, verbose=not args.quiet)
if __name__ == "__main__":
main()