RAG 与 Agent
RAG 让模型学会「查资料再回答」,Agent 让模型学会「规划并调用工具完成任务」——两者共享同一根主干:模型的概率生成 + 外部系统提供的确定性事实。对应论文原理见 RAG 与 ReAct 精读。本文给出从零搭建到可评测、可上线的完整方法。
一、先想清楚:什么时候用 RAG / 长上下文 / 微调
| 需求 | 首选方案 | 为什么 |
|---|---|---|
| 知识常变、量大、要可溯源 | RAG | 改库不改模型,回答带引用 |
| 单次会话内读整本长文档 | 长上下文窗口 | 语境连续,但要防「中间丢失」与成本 |
| 语气/格式/领域术语要「定型」 | 微调(见 模型微调) | 把行为写进权重,不靠每次提示词 |
| 精确计算/状态操作 | 工具/代码(Agent) | 模型不擅长的交给确定系统 |
RAG 与长上下文不是对立:答案权威源在外部且常变 → RAG;权威内容就是给模型一次性读完的手册 → 长上下文。组合拳(先检索再塞窗口)最常见。
二、RAG 管线全景:五环一图流
文档 → ①加载清洗 → ②切分(chunk)→ ③嵌入 + 向量库 → ④检索(召回+重排)
↓
回答 ← ⑥生成(带引用) ← ⑤组装上下文(检索片段 + 指令)- ① 加载清洗:PDF/HTML/表格各用合适解析器(表格别转纯文本丢结构),去页眉页脚、去模板噪声;
- ② 切分:目标不是「每块一样大」,而是块内语义完整、块间可定位——按结构(标题/段落/表格/代码块)切比按固定字符切好;相邻块加 10-15% 重叠,防句子在接缝被切开;中文注意按句/按语义单位;
- ③ 嵌入:把文本映射成向量,相似语义 = 向量距离近;选嵌入模型看检索任务效果而非排行榜幻觉;
- ④ 检索:召回(粗筛候选) + 重排(精排 top-k),见下节;
- ⑤ 组装:把 top-k 片段 + 「忠实于片段」的指令拼进上下文,标注片段来源序号;
- ⑥ 生成:要求逐句引用出处编号、禁止编造;生成后做「引用可查证」检查(见评测)。
三、检索:召回与重排
- 召回三件套,建议混合:
| 方法 | 原理 | 强项 | 短板 |
|---|---|---|---|
| BM25(稀疏) | 词频 + 逆文档频率 + 文档长度归一 | 术语精确、无训练 | 同义改写就失配 |
| 稠密检索(向量) | 嵌入余弦相似度 | 语义泛化 | 冷门术语/长尾差 |
| 混合 + RRF | 各取前 k 名按 1/(60+rank) 合并计分 | 互为补充 | 需调权重 |
- 重排(rerank):召回给 50-100 条,用交叉编码器/排序模型精排到 3-10 条——重排是「便宜大碗」的精度提升点;
- 召回前的 query 变换(性价比极高):
- 查询改写(query rewrite):把模糊问句改写为检索友好的关键词问句;
- 多查询(multi-query):同一问题拆 2-3 种问法并行检索,合并去重;
- HyDE:先让 LLM 生成假设答案,再用答案去检索(答案比问题更像文档语言);
- 迭代检查顺序:先看召回再怪生成——生成差往往因 top-k 没命中正确片段。
四、向量化与向量数据库选型
- 数据量从百到亿级,选型梯度:
| 场景 | 方案 | 说明 |
|---|---|---|
| 原型/教学 | FAISS / Chroma | 内存/单机足够,零运维 |
| 已有 PostgreSQL | pgvector | 与业务数据同库,支持混合检索,少一个中间件 |
| 万级以上 + 高并发 | Milvus / Qdrant / Weaviate | 分布式、过滤、别名/多租户、监控齐全 |
- 索引默认 HNSW(图式 ANN 索引):召回率/速度/内存三者的默认平衡点;数据量级变化后重建索引并验收召回率(暴力检索对比抽样);
- 生产必做:向量库与源文档的版本对齐(文档更新 → chunk 重新切分入库 → 旧向量删除),并保留「片段 → 源文件行号」的溯源元数据用于引用回查。
五、RAG 评测:没有数字就没有迭代
| 层 | 测什么 | 常用指标/方法 |
|---|---|---|
| 检索层 | 该片段是否被召回 | Hit Rate、Recall@k、MRR |
| 生成层 | 答案是否忠实于片段、是否可溯源 | 忠实度(faithfulness)、引用命中 |
| 端到端 | 最终答案对不对 | 答案正确率、LLM-as-judge 或人工标注 |
| 成熟套件 | 综合 | RAGAS(faithfulness / answer relevancy / context precision 等) |
- 工程建议:先建 50-100 条金标问答对(每条带「标准证据片段」),跑检索 Recall@k 定位「切分/嵌入」问题,再跑端到端定位「组装/提示」问题;
- 上线后定期采样重放,文档更新后全量回归(详见 AI 工程化 评测门禁)。
六、从 RAG 到 Agent:工具、规划与循环
- Agent = LLM + 工具 + 循环:模型决定下一步调哪个工具,代码执行并回填结果,循环直到任务完成;
- 最小循环即 ReAct:思考 → 行动(调工具)→ 观察(工具返回)→ 再思考;
- 实现三要素:
- 工具封装:每个工具 = 名称 + 描述 + JSON schema 参数(描述要写给「模型」看:何时用、参数怎么填);
- 循环控制:最大步数、异常重试、目标达成判断(让模型显式输出 FINISH);
- 记忆:会话内短期记忆(消息历史)+ 长期记忆(向量库存事实/偏好);
- 框架速查:
| 框架 | 定位 | 适合 |
|---|---|---|
| LangChain | 组件齐全的生态胶水 | 快速拼装、调研 |
| LlamaIndex | 数据/RAG 为中心 | 文档密集型应用 |
| LangGraph | 图式状态机,循环可控 | 生产级复杂流程 |
| CrewAI / AutoGen | 多智能体协作 | 角色分工试点 |
| MCP(协议) | 工具标准接口(2025+ 主线) | 工具接入的开放标准 |
- 忠告:框架是脚手架不是银弹——先用手写 50 行循环跑通,再加框架;Agent 最难的从来不是调用工具,而是步数爆炸、错误传播与不可控,必须配步数上限 + 工具权限最小化 + 审计日志(安全见 Prompt 工程 注入防线与 AI 工程化 可观测)。
6.1 ReAct 最小循环实测(deepseek-chat, 2026-09-04)
手写一个 ReAct 循环并真实跑通。为呼应本页主题,工具是一个「本地笔记检索」
search_notes(模拟 RAG 检索层):模型上下文里不放笔记原文,事实只能靠检索工具拿到,禁止编造。实测轨迹与三个真实踩坑见代码与注释:
python
# ReAct 最小循环 —— 真实可跑版(DeepSeek deepseek-chat)
# 依赖: python3 + requests; 环境变量 DEEPSEEK_API_KEY
# 用法: export DEEPSEEK_API_KEY=sk-... && python3 本文件
import os, json, re, requests
API = "https://api.deepseek.com/chat/completions"
MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")
# 模拟「内部技术笔记库」: 只有检索工具能读到, 模型上下文里不放原文
NOTES = [
{"title": "python", "body": "Python 是解释型高级语言, 1989 年由 Guido van Rossum 开始设计, 1991 年发布, 以易读与生态著称。"},
{"title": "golang", "body": "Go(常称 golang) 是 Google 于 2009 年发布的静态编译语言, 以并发原语与编译速度著称。"},
{"title": "reactjs", "body": "React 是 Facebook 于 2013 年发布的前端 UI 库, 组件化 + 虚拟 DOM。"},
{"title": "llama", "body": "Llama 是 Meta 开源的大模型系列, 首个版本发布于 2023 年。"},
]
TOOL_SPEC = [{"type": "function", "function": {
"name": "search_notes", "description": "在内部技术笔记库按关键词检索, 返回命中笔记的标题与要点。要查编程语言/框架的事实背景时才用, 不要凭空编造。",
"parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "检索关键词, 如 python / golang"}}, "required": ["query"]}}}]
def execute_tool(args):
q = args["query"].lower()
hits = [n for n in NOTES if q in n["title"].lower() or q in n["body"].lower()]
if not hits:
return {"ok": False, "error": f"没有命中 {q!r} 的笔记"}
return {"ok": True, "hits": [f"《{n['title']}》: {n['body'][:120]}" for n in hits]} # 结果加工, 控制回灌
def chat(messages, tools=None):
payload = {"model": MODEL, "messages": messages, "stream": False}
if tools:
payload["tools"] = tools # 踩坑: 不挂 tools, 模型根本看不见工具, 只会文本模拟调用
r = requests.post(API, headers={"Authorization": "Bearer " + os.environ["DEEPSEEK_API_KEY"]},
json=payload, timeout=60)
r.raise_for_status()
return r.json()["choices"][0]["message"], r.json()["usage"]["total_tokens"]
def run_react(goal, max_steps=12):
state = {"trace": [], "done": False, "reason": "", "tokens": 0}
# 踩坑: 冗长 system「教模型 ReAct」会诱导它在正文里写 action/action_input 文本模拟,
# 反而压制原生 tool_calls; 规则并入首条消息 + 让工具 schema 自己说话即可。
prompt = goal + ("\n要求: 必须调用 search_notes 工具分别查到 python 与 golang 的笔记后再作答;"
" 年份与类型必须与检索结果一致; 最终只输出 JSON: "
'{"python":"一句话", "golang":"一句话"}')
history = [{"role": "user", "content": prompt}]
state["trace"].append("THINK: " + goal)
step = 0
while not state["done"] and step < max_steps:
step += 1
msg, toks = chat(history, tools=TOOL_SPEC) # ACT: 模型决策调哪个工具
state["tokens"] += toks
history.append(msg)
if msg.get("tool_calls"):
for tc in msg["tool_calls"]: # OBSERVE: 执行并回填
args = json.loads(tc["function"]["arguments"])
result = execute_tool(args)
state["trace"].append(f"ACT search_notes({args['query']}) -> " +
("; ".join(result["hits"]) if result["ok"] else result["error"]))
history.append({"role": "tool", "tool_call_id": tc["id"],
"content": json.dumps(result, ensure_ascii=False)})
else:
# 无工具调用 => 视为最终答复, 程序化验证(年份必须来自检索)
j = json.loads(re.search(r"\{.*\}", msg["content"], re.S).group(0))
ok = ("1989" in str(j.get("python", "")) and "2009" in str(j.get("golang", "")))
state["trace"].append(f"ANSWER: {msg['content']}")
if ok:
state["done"], state["reason"] = True, "validated(年份均有检索支撑)"
else:
# 踩坑: 打回反馈绝不能写正确答案(=评测数据泄漏), 只描述缺什么, 事实让 Agent 自己查
missing = [k for k in ("python", "golang") if not re.search(r"\d{4}", str(j.get(k, "")))]
history.append({"role": "user",
"content": f"验证失败: 字段 {missing} 缺少年份事实。请先 search_notes 检索对应笔记再补全。"})
if not state["done"]:
state["reason"] = "step_budget_exhausted"
return state
if __name__ == "__main__":
assert os.environ.get("DEEPSEEK_API_KEY"), "请先 export DEEPSEEK_API_KEY=sk-..."
s = run_react("分别查清 python 与 golang: 各自是什么类型、诞生年份各是多少。")
for t in s["trace"]:
print(" *", t)
print("done:", s["done"], "| reason:", s["reason"], "| tokens:", s["tokens"])
# ---- 实测输出(2026-09-04, deepseek-chat, 1144 tokens) ----
# * THINK: 分别查清 python 与 golang: 各自是什么类型、诞生年份各是多少。
# * ACT search_notes(python) -> 《python》: Python 是解释型高级语言, 1989 年由 Guido van Rossum 开始设计...
# * ACT search_notes(golang) -> 《golang》: Go(常称 golang) 是 Google 于 2009 年发布的静态编译语言...
# * ANSWER: {"python":"Python 是解释型高级语言,1989 年开始设计,1991 年发布...","golang":"Go 是 Google 于 2009 年发布的静态编译语言..."}
# done: True | reason: validated(年份均有检索支撑) | tokens: 1144- 实测中三个真实踩坑(都已写进代码注释):工具没挂载(请求里少了
tools字段,模型看不见工具,只能用文本假装调用)、system 过度教学(教它 ReAct 反而诱导出action/action_input文本格式,盖过原生 function calling)、验证反馈泄题(把正确答案写进"验证失败"提示 = 评测数据泄漏,Agent 不再检索直接抄答案)。这三个坑分别对应本页「工具封装」「循环控制」与下页「评测集/验证器」的工程要点。
七、常见失败模式与调试
| 症状 | 根因方向 | 先查 |
|---|---|---|
| 答案像没看过资料 | 检索没召回正确片段 | 打印 top-k 片段人工看命中 |
| 引用对不上 | chunk 定位粒度粗 / 溯源丢失 | chunk 元数据与切分策略 |
| 问句一换就不行 | query 与文档语言脱节 | 上 query rewrite / 多路召回 |
| 表格/代码问不好 | 切分把结构切碎 | 结构化块按类型切分+单独检索 |
| Agent 死循环/乱调工具 | 工具描述不清 / 缺步数上限 | 审工具 schema 与终止条件 |
| 越问越错 | 上下文污染(检索噪声进提示) | 过滤低相关片段、调重排 |
八、踩坑清单
- 跳过清洗直接切分:PDF 噪声、页眉页脚进库,检索质量崩在源头;
- 固定字符切分:句子/表格/代码被拦腰斩断,召回与忠实度双降;按结构切 + 重叠;
- 只上向量不做 BM25 混合:术语型问题(型号/代码标识)召回全灭;
- 召回看数量不看质量:top-k 放大只会把噪声塞进上下文,先重排再做质量检查;
- 忘 query rewrite:用户口语问法检索不到文档术语,先改写再检索;
- 引用是「贴上去的」:不做来源校验,幻觉引用照样出现;逐句核对来源;
- 上线不评测:不建金标、不回放,文档一更新效果断崖却无法定位;
- Agent 不给步数上限与权限墙:生产事故多发于此;
- 迷信框架:框架隐藏细节,debug 困难;先手写循环理解再上框架;
- 忽视成本:每轮 Agent 循环 = N 次 LLM 调用,失控时账单感人——加缓存与预算闸门。
继续学习:RAG 与 Agent 都建立在 LLM 能力之上,原理见 LLM 原理;需要改变模型本身行为(领域格式稳定输出)时进入 模型微调;把整套系统跑稳、跑便宜、可观测见 AI 工程化。