《Deep Agents 实战笔记》第 12 篇 | 基于 deepagents (Python) 官方文档 原文:https://docs.langchain.com/oss/python/deepagents/rag 、https://docs.langchain.com/oss/python/deepagents/rubric
上一篇的 Deep Research 代理解决的是"信息从哪来"。这一篇换个更工程化的问题:答案凭什么可信。官方 RAG 教程给出的答案不是换个更强的模型,而是把 Deep Agents 的组件箱打开——检索工具、文件系统 Backend、子代理、技能、评分标准(rubric)——重新组装一遍 RAG。
一、Baseline:没有检索的"RAG"会怎样
教程的起点很犀利:先证明"不检索有多离谱"。同一个问题——"如何从子代理流式传输中间工具结果?"——交给一个没有任何检索工具、只挂了个"文档助手"提示词的 Deep Agent:
from deepagents import create_deep_agent
from langchain.messages import HumanMessage
EXAMPLE_QUERY = "How do I stream intermediate tool results from a subagent?"
baseline_agent = create_deep_agent(
model="google_genai:gemini-3.6-flash",
tools=[],
system_prompt=(
"You are a helpful LangChain documentation assistant. "
"Answer questions about LangChain APIs and patterns."
),
)
result = baseline_agent.invoke(
{"messages": [HumanMessage(content=EXAMPLE_QUERY)]}
)
print(result["messages"][-1].text)
结果不出所料:回答流于泛泛,遗漏关键指引,甚至混入过时信息——模型只靠训练数据作答,永远不会以你的事实来源为依据。这就是所有 RAG 系统存在的理由。
二、传统 RAG 与 Agentic RAG:差的不只是"多调几次"
传统 RAG 是一条流水线:嵌入 → 检索一次 → 拼上下文 → 生成,检索质量好坏全看那一次相似度搜索的运气。官方教程实现的 Agentic RAG 模式叫检索、卸载与委派(retrieve, offload, delegate),整条链路变成了一个会自我修正的循环:
flowchart TB
subgraph BASE["Baseline RAG:单程流水线"]
Q1["用户问题"] --> R1["相似度检索一次"]
R1 --> G1["拼进上下文直接生成"]
G1 --> A1["答案(对错听天由命)"]
end
subgraph AG["Agentic RAG:检索、卸载与委派"]
Q2["用户问题"] --> P2["规划聚焦的搜索查询"]
P2 --> S2["search_documentation 检索"]
S2 --> O2["分块卸载到文件系统<br/>只返回文件路径"]
O2 --> T2["task 委派 chunk-analyst<br/>子代理并行读文件"]
T2 --> Y2["综合子代理摘要成答案"]
Y2 --> V2{"校验:证据够吗?"}
V2 -->|"不足,换查询再搜"| P2
V2 -->|"充分"| A2["带文档链接的最终答案"]
end
style A1 fill:#fde8e8,color:#8b1a1a,stroke:#e05252
style A2 fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
style V2 fill:#fdf3ff,color:#504b5f,stroke:#7e65ae
正在渲染图表…
三个结构性差异值得记住:
- 检索结果不进上下文,进文件系统——主代理只拿到文件路径,这是卸载(offload)思想在 RAG 场景的直接应用;
- 分析外包给子代理——每个分块一个
chunk-analyst,独立上下文、可并行,主代理的窗口始终干净; - 生成之后有校验回路——证据不足就换查询再搜,而不是硬着头皮编一个。
三、索引:标准四步,没什么花活
索引阶段就是经典 RAG 的四步曲:加载、切分、向量化、存储。教程对 docs.langchain.com 的 14 个页面建索引:
import requests
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
def load_langchain_docs(doc_paths: list[str] | None = None) -> list[Document]:
"""Fetch LangChain documentation pages as Documents."""
paths = doc_paths or DOC_PATHS
docs: list[Document] = []
for path in paths:
url = f"{DOCS_BASE}/{path}.md"
try:
response = requests.get(url, timeout=20)
response.raise_for_status()
except requests.RequestException:
continue
source = f"{DOCS_BASE}/{path}"
docs.append(
Document(page_content=response.text, metadata={"source": source})
)
return docs
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_splits = text_splitter.split_documents(docs)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = InMemoryVectorStore(embedding=embeddings)
vector_store.add_documents(documents=all_splits)
实测数据:14 个页面共约 59 万字符,切成 782 个分块。教程特意提醒:即便某些模型窗口塞得下整个语料库,模型也很难在超长输入里精准找信息,token 效率也不划算——"窗口大"不等于"不用检索"。另外生产环境记得把 vector store 持久化,并按计划在文档更新时刷新索引。
四、核心巧思:检索工具返回的不是内容,是文件路径
整个教程最漂亮的一笔在 search_documentation 工具里——它把检索和卸载焊在了一起:
from langchain_core.utils.uuid import uuid7
from deepagents.backends import StateBackend
from langchain.tools import tool
backend = StateBackend()
@tool(parse_docstring=True)
def search_documentation(query: str) -> str:
"""Search LangChain documentation and save matching chunks to the agent filesystem.
Args:
query: Natural language search query.
Returns:
File paths where retrieved chunks were saved under /retrieved/.
"""
retrieved_docs = vector_store.similarity_search(query, k=4)
batch_id = uuid7().hex[:8]
uploads: list[tuple[str, bytes]] = []
saved_paths: list[str] = []
for index, doc in enumerate(retrieved_docs, start=1):
path = f"/retrieved/{batch_id}/chunk_{index}.md"
content = (
f"# Source: {doc.metadata.get('source', 'unknown')}\n\n"
f"{doc.page_content}"
)
uploads.append((path, content.encode("utf-8")))
saved_paths.append(path)
backend.upload_files(uploads)
return (
f"Saved {len(saved_paths)} documentation chunks:\n"
+ "\n".join(saved_paths)
)
三个细节:工具内部调用 backend.upload_files() 把分块写入 Backend,必须把同一个 backend 实例传给 create_deep_agent,否则内置的 read_file、grep 读不到这些路径;每个分块头部写入 # Source: 来源 URL,为后续引用做准备;返回值只有路径清单,比如 /retrieved/a1b2c3d4/chunk_1.md——主代理的上下文里从头到尾没出现过分块正文。
五、提示词分工与子代理并行分析
和第 11 篇的 Deep Research 一样,这个项目也是"主代理指令 + 委派指令 + 子代理指令"三段式。主代理的五步工作流:
1. Plan:把复杂问题拆成聚焦的搜索查询
2. Search:调用 search_documentation,拿到文件路径
3. Analyze:用 task() 把每个分块文件委派给 chunk-analyst,
多个分块就在一次响应里发起多个并行 task 调用
4. Synthesize:合并子代理摘要,附上文档来源链接
5. Verify:如果摘要没能完整回答问题,用更精准的查询再搜
委派指令里有一条铁律值得抄进你的项目:"Do not paste full chunk contents into your own messages. Let subagents read files."——不要把分块全文粘进自己的消息,让子代理自己去读文件。这正是第 7 篇讲的上下文工程纪律在实战里的落点。
子代理 chunk-analyst 的指令则极其克制:读分配到的文件,提取有助于回答问题的事实,返回 300 字以内的摘要,附上来源 URL。注意它没有检索工具,只用内置文件系统工具——职责单一,上下文天然干净:
chunk_analyst_subagent = {
"name": "chunk-analyst",
"description": (
"Analyze one retrieved documentation chunk file. "
"Pass the user question and a single file path under /retrieved/."
),
"system_prompt": CHUNK_ANALYST_INSTRUCTIONS,
}
agent = create_deep_agent(
model=model,
tools=[search_documentation],
backend=backend,
system_prompt=INSTRUCTIONS,
subagents=[chunk_analyst_subagent],
)
运行时代码就是标准的 invoke。跑一次示例查询,代理会:检索 → 收到文件路径 → 发起一个或多个 task()(每个负责一个分块)→ 综合出带文档链接的答案。用 LangSmith 可以清楚看到搜索调用、文件写入、子代理委派的完整链路。
六、Rubric:给答案装一个"评审判官"
检索回来了、答案生成了,但怎么保证答案确实以检索到的材料为依据(grounded),而不是模型又偷偷靠记忆发挥?官方的答案是 RubricMiddleware——把 LLM-as-a-judge 模式搬进运行时(需要 deepagents>=0.6.5,目前处于 beta 阶段,API 可能变化)。
用法分两步。第一步,创建代理时挂上评中间件:
from deepagents import RubricMiddleware, create_deep_agent
from langgraph.checkpoint.memory import InMemorySaver
agent = create_deep_agent(
model="google_genai:gemini-3.6-flash",
middleware=[
RubricMiddleware(
model="anthropic:claude-haiku-4-5",
max_iterations=3,
),
],
checkpointer=InMemorySaver(),
)
第二步,调用时在输入状态里传 rubric 字符串。不传就不会触发评分循环:
from langchain.messages import HumanMessage
config = {"configurable": {"thread_id": "my-rubric-thread"}}
result = agent.invoke(
{
"messages": [HumanMessage("Write a haiku about spring.")],
"rubric": (
"- The poem has three lines\n"
"- Lines follow a 5-7-5 syllable pattern\n"
"- The theme is spring"
),
},
config=config,
)
工作循环长这样:
flowchart LR
Start["调用时携带 rubric"] --> Agent["Deep agent 产出答案"]
Agent --> Grader{"评分子代理判定"}
Grader -->|"satisfied"| Done["结束:全部标准通过"]
Grader -->|"failed 或 grader_error"| Done
Grader -->|"needs_revision"| Cap{"迭代次数小于<br/>max_iterations?"}
Cap -->|"是"| Inject["逐条标准反馈注入对话<br/>代理再次运行"]
Cap -->|"否"| MaxOut["max_iterations_reached 终止"]
Inject --> Agent
style Grader fill:#fdf3ff,color:#504b5f,stroke:#7e65ae
style Done fill:#f0f7f0,color:#1d4a1d,stroke:#4a9e4a
style MaxOut fill:#fde8e8,color:#8b1a1a,stroke:#e05252
正在渲染图表…
判定有五种:satisfied(全部通过)、needs_revision(至少一条未过,反馈注入后重跑)、max_iterations_reached(到上限仍未过)、failed(评分标准本身有问题)、grader_error(评分子代理自身异常)。几个实践要点:
- 评分模型用小模型:
RubricMiddleware的model参数接受"provider:model-id"字符串,官方示例用比工作模型更小更便宜的claude-haiku-4-5——评分是判断题不是创作题,没必要用大模型烧钱; - 给评分器配工具:
tools参数可以让评分器在判定前收集证据。官方的代码生成示例给评分器挂了个run_test_suite工具,直接跑测试套件验证代码,而不是抽象地"推理"代码对不对——这一步把评分从主观变成客观; - 观察迭代进度:用
on_evaluation回调(每次评分迭代后收到RubricEvaluation,含逐条标准的criteria和未通过项的gap反馈),或stream_events(version="v3")配合CustomTransformer从stream.custom读取rubric_evaluation_start/rubric_evaluation_end事件; - 跨调用延续:配上 checkpointer 并复用同一个
thread_id,同一份 rubric 会持续作用于后续调用,直到传入新 rubric。
在 RAG 场景里,rubric 的典型条目就是"答案中的每个论断都能对应到检索到的源材料""引用的文档路径真实存在于检索结果中"——评分子代理逐条核对,逼着工作模型有据可查。
七、技能引导检索:把检索经验写成文件
教程还列了几种 RAG 模式,其中技能引导的检索(skills-guided retrieval)值得一提:用户提问后,代理加载一个相关技能,技能里写清楚"搜哪个索引、查询怎么构造、引用用什么格式",代理照着指引调检索工具再综合答案。
本质上,这是第 8 篇讲的技能系统在 RAG 场景的应用:把"怎么搜"的方法论从提示词里搬进 SKILL.md 文件,启动时只占一行摘要,检索时才展开全文。同一个代理可以按问题类型加载不同的检索技能——技术文档怎么搜、营销资料怎么搜、代码仓库怎么搜,各自一份。教程还提到其余两种模式:基于评分标准(rubric)的依据校验(上文已实现)、待办清单驱动的调查(启用任务规划后由 write_todos 列出要查的页面或查询)。这些模式共享同一套基础组件,按语料规模、延迟要求和"答案必须贴合源数据的程度"自由组合。
八、实战作业
用你自己领域的文档库跑通整套流程:把教程里的 DOC_PATHS 换成你的文档地址(内部 wiki、产品手册、团队规范都行),索引 → 检索 → 卸载 → 子代理分析 → 综合,最后加一条 rubric:"每个论断必须引用真实存在的文档 URL"。对比开篇那个裸模型 baseline 的回答,差距会非常直观。
本篇踩坑点
- 间接提示词注入:这是官方专门用警告框强调的风险——检索到的文档里可能含有看似指令的文本,与你的系统提示词共享上下文窗口,模型可能遵从文档里的"指令"。没有任何提示词策略能完全防住;教程的做法是让模型把检索内容当纯数据处理、分块头部加
# Source:区分元数据,并在输出呈现给用户前人工验证引用。生产环境务必重视; - backend 实例不一致:
search_documentation里写入的 backend 和传给create_deep_agent的 backend 必须是同一个实例,否则子代理读不到文件,报的错却很像"路径不存在"; - InMemoryVectorStore 不持久:教程用它图省事,进程一退索引就没了,生产要换持久化 vector store 并规划索引刷新;
- rubric 是 beta 功能:
deepagents>=0.6.5才有RubricMiddleware,且 API 未来可能变化,锁定版本再上生产; - 评分标准写得太主观:rubric 条目要写成可逐条判定的清单,"写得专业一点"这种条目评分子代理没法判,只会带来
needs_revision空转,直到撞上max_iterations。
下一篇预告:实战做完了,第 13 篇是系列收官——走向生产:速率限制与重试、模型回退、LangGraph Platform 部署与自托管、LangSmith 可观测性与评估,把 demo 补成能扛流量的系统。
参考资料:
- 官方教程 Build a RAG agent with Deep Agents:https://docs.langchain.com/oss/python/deepagents/rag
- 评分标准(rubric)文档:https://docs.langchain.com/oss/python/deepagents/rubric
- 完整示例 trace(LangSmith 公开记录):https://smith.langchain.com/public/6028e45a-23d0-4073-968c-19d161910ef8/r