Agentic RAG 检索谁来决定:微软 AI Agent 入门课第 5 课的 @tool 封装
写过 RAG 的人大概都遇到过这两种别扭:用户只说了句「你好」,管道照样先去向量库检索一轮;换个需要横向对比几个对象的问题,管道只检索一次就开始生成,缺的那半边信息永远补不回来。问题不在检索质量,在于检索发生的时机被写死在代码里。
ai-agents-for-beginners 第 5 课处理的就是这件事。它的 Python 示例在 05-agentic-rag/code_samples/05-python-agent-framework.ipynb,做法说穿了只有一句:把检索写成一个普通函数,用 @tool 装饰后挂到 agent 上,剩下的「要不要查、查几次、查什么词」交给模型在对话循环里决定。这篇就顺着这个 notebook 把三件事说清楚——检索工具怎么封装、决定权到底落在哪,以及文档源的接入在 Python 和 .NET 两份示例里是两条完全不同的路。
前置条件:这一课比你想的少一样东西
先说少的那样:这一课的 Python notebook 不需要 Azure AI Search。notebook 开头的 Setup 单元格里写明,它用的是内存里的知识库,好让你专注在 Agentic RAG 模式本身,要换成真实索引再去看 00-course-setup/AzureSearch.md。所以不要照着「RAG 就得先建索引」的惯性去开一堆资源。
需要的东西是这些:
- Python 3.12+(
00-course-setup/README.md的 Requirements 一节写明,并建议用该版本建虚拟环境) - Azure CLI 已登录。notebook 的 Prerequisites 三条里第三条就是
az login .env里两个变量:AZURE_AI_PROJECT_ENDPOINT和AZURE_AI_MODEL_DEPLOYMENT_NAME
依赖安装是 notebook 里第一个代码单元格原样的一行:
%pip install agent-framework azure-ai-projects azure-identity python-dotenv -q
Windows 这边有两处和课程文档里 zsh/bash 的写法不一样,都在 00-course-setup/README.md 里给了对应命令。虚拟环境激活在 Command Prompt 下是 venv\Scripts\activate;复制环境变量模板在 PowerShell 下是 Copy-Item .env.example .env,而不是 cp。
.env.example 里 AZURE_AI_MODEL_DEPLOYMENT_NAME 给的值是 gpt-5-mini——这只是仓库里的示例值,填你自己在 Foundry 项目里那个部署名。凭据这边,课程 setup 文档解释过为什么走 az login:notebook 用 azure-identity 的 AzureCliCredential 或 DefaultAzureCredential,两者都会拿你的 Azure CLI 会话,.env 里就不用放密钥。第 5 课这个 notebook 用的是 DefaultAzureCredential。
第一步:把检索函数变成一个 tool
notebook 里的知识库就是一个模块级 dict,TRAVEL_KNOWLEDGE_BASE,键是目的地名,值是一段描述文本。紧跟着是检索函数:
@tool(approval_mode="never_require")
def search_travel_knowledge(
query: Annotated[str, "The search query about a travel destination"]
) -> str:
"""Search the travel knowledge base for destination information."""
这三行里有三处是给模型看的,不是给你看的:
装饰器 @tool,从 agent_framework 导入。它把普通函数登记成 agent 可调用的工具。参数 approval_mode="never_require" 表示这次调用不需要人工确认就能执行——课程 .agents/skills/deploying-scalable-agents/SKILL.md 里提到另一种取值 @tool(approval_mode="always_require"),用于像大额退款这类动作。检索通常是只读的,所以这里选了不要求确认;但如果你把这个模式套到会写数据的工具上,这个参数就得重新想。
Annotated[str, "The search query about a travel destination"],参数类型后面挂的那句英文描述。模型是靠它决定往 query 里塞什么的。
docstring,"""Search the travel knowledge base for destination information."""。模型判断「这个工具是干嘛的、这个问题该不该调它」,读的就是这一句。
函数体本身很朴素:遍历 dict,用 query.lower() in destination.lower() 匹配目的地名,或者把 query 按空格切开、看是否有词落在描述文本里,命中就拼成 **目的地**: 描述 的形式返回。这不是向量检索,没有 embedding、没有相关性排序。notebook 结尾的 Summary 自己也写了,生产环境里要把内存 dict 换成真实的 Azure AI Search 索引。
值得单独拎出来的是没命中的那个分支——返回的是固定一句 "No matching destinations found in the knowledge base."。这句字符串是模型判断「库里确实没有」的唯一信号。你换自己的检索后端时,空结果返回 None、空列表还是一句明确的话,直接影响 agent 会老实说不知道还是接着编。
第二步:把工具挂上去,然后用 instructions 定规矩
agent = client.as_agent(
tools=[search_travel_knowledge],
name="TravelRAGAgent",
instructions="""You are a knowledgeable travel advisor. Before answering questions about destinations:
1. ALWAYS search the travel knowledge base first
...""",
)
client 是上面那个 FoundryChatClient(project_endpoint=..., model=..., credential=DefaultAzureCredential()),as_agent() 把它变成一个带工具的 agent。
这里有个容易误会的点,值得说白:「什么时候检索」并不是框架里的某个开关,而是写在 instructions 里的自然语言,外加模型自己的判断。这个 agent 的第一条指令是「回答目的地问题前一定先搜知识库」,第三条是「知识库里没有的信息,明确说出来」。换句话说,这一课所谓的 agent 自主决定,落到代码上就是:框架负责把工具签名和描述交给模型、把模型的调用请求路由到你的函数,而策略层完全靠提示词。
notebook 后半段那个 checker_agent 把这一点体现得更清楚。它挂的是同一个工具、同一个客户端,只有 instructions 不同——第二条要求「对每个找到的目的地,再用目的地名字搜一次拿完整信息」,第五条要求「任何细节看着不全就再搜一次确认后再回答」。所谓 maker-checker 的多轮检索,代码里没有多写一行循环,是写在指令里的。
第 5 课的 README 把这条循环写成了「初始调用 → 工具调用 → 评估与修正 → 满意为止」,并且明说这套自主性被限定在开发者提供的工具、数据源和策略之内,agent 不能发明自己的工具(README 的 Boundaries of Agency 一节)。这是仓库自述,不是我们的评价。
另外 notebook 顶部还有一行容易被略过的:
logging.getLogger("agent_framework.foundry").setLevel(logging.ERROR)
它把 agent_framework.foundry 这个 logger 的级别调到 ERROR。学的时候没所谓,真要看 agent 在多轮里到底调了几次工具、传了什么 query,这行就是第一个要改的地方。
上面几段代码均照抄自该 notebook;如果你要按自己的场景改写,请注意这些是按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
边界:几处必须照实说的
文档源的接入,Python 和 .NET 走的不是一条路。 同目录下有个 document.md,内容是几行 Contoso 旅行社的业务描述。它只被 .NET 示例用到——05-dotnet-agent-framework.cs 里 string pdfPath = "./document.md";(变量名叫 pdfPath,指的却是 .md 文件),随后 Files.UploadFileAsync 上传、VectorStores.CreateVectorStoreAsync 建 vector store,再把 FileSearchToolDefinition() 作为工具、通过 toolResources 的 VectorStoreIds 绑定到 agent 上。也就是说 .NET 那份是把检索交给服务端的文件检索工具,Python 这份是自己写函数。你读哪份就照哪份做,别把两边的写法拼起来。
.NET 那份依赖里带预发布标记。 05-dotnet-agent-framework.cs 头部的包引用中,Azure.AI.Agents.Persistent 标的是 beta,Microsoft.Agents.AI 与 Microsoft.Agents.AI.AzureAI 标的是 preview。按预发布件对待就好。
仓库里有一处对不上,指出来但不替它解释。 根目录 .env.example 在 Azure AI Search 那节的注释写着第 05 和 16 课「fall back to in-memory search」,而第 5 课的 Python notebook 里根本没有读取 AZURE_SEARCH_SERVICE_ENDPOINT 的代码路径,它自始至终只有内存 dict,notebook 的说明也是让你去看那份可选的 Search 配置指南。想接真实索引的话,这一步得你自己写。
这条链路会往云端发数据。 用户问题、instructions、检索返回的原文都会作为上下文发给 Foundry 项目里的模型部署。知识库里放的是什么,心里要有数。
怎么验证配对了
仓库自带了这一课的冒烟测试目录:tests/lesson-05-smoke-tests.json。它的用法在 tests/README.md 里写明——先按第 16 课把 agent 部署成 Foundry 托管 agent,部署时用的 agent 名字要和 tests/README.md 那张对照表里写的一致,第 5 课这一行对应的名字就是 TravelRAGAgent;再由 .github/workflows/smoke-test.yml 这个工作流去消费该 JSON。
真正值得学的是这份 JSON 的断言写法。前三条是「问知识库里有的东西,看回答里有没有对应关键词」,用的是 contains_any。最后一条 no-fabrication 反过来:拿一个虚构城市去问日均花费,断言用的是 contains_none,被禁的字符串是知识库描述里那个固定前缀 "Average daily cost: $"。这一条测的不是检索准不准,而是没检索到的时候 agent 会不会顺手编一个出来——这正是把检索交给模型决定之后最该守住的地方。
不部署也能做个粗验:拿一个知识库里没有的目的地去问,看回答是不是按 instructions 第三条明确说了「知识库里没有」。如果它开始给你介绍这座城市的最佳季节和日均花费,问题多半出在两处——要么工具的空结果返回值不够明确,要么 instructions 里没把「查不到就说查不到」写死。
课程持续更新,上面涉及的文件路径、参数名与依赖写法都可能随版本变动,以仓库最新内容为准。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。