微软 AI Agent 入门课第 3 课的 Agentic 设计模式:notebook 里是哪三个
打开 ai-agents-for-beginners 的第 3 课,容易先愣一下:03-agentic-design-patterns/README.md 从头到尾讲的是人机交互层面的原则——把 agent 拆成 Space、Time、Core 三块来谈,再给出 Transparency、Control、Consistency 三条实施指引,最后拿一个旅行 agent 举例说明产品页上该写什么、反馈按钮该放哪。通篇没有一行 Python。
而同一课的 code_samples/03-python-agent-framework.ipynb,开篇 markdown 单元列的是另外三个东西:Clear Agent Instructions、Structured Output with Pydantic Models、Single Responsibility Agents。名字对不上,层次也对不上。
这不是编排失误,而是这一课本来就有两套”设计模式”:README 那套是给做产品决策的人看的,notebook 那套是给写代码的人看的。要落到工程上,只能读 notebook。下面就沿着这个 notebook 从头走一遍,看这三个模式各自具体是哪几行、改哪个参数。
走进去之前:provider 是怎么起来的
notebook 的 setup 单元用行内魔法装依赖:
%pip install agent-framework azure-ai-projects azure-identity pydantic python-dotenv --quiet
接下来的 imports 单元做了三件事,每件都值得单独看。
第一件是压日志级别,logging.getLogger("agent_framework.foundry").setLevel(logging.ERROR)。这行在这一系列的其它课 notebook 里也逐字出现,属于这批示例的共同前置;仓库里没有写它的用意,它改动的只是日志输出的级别,不涉及调用参数。
第二件是读环境变量并主动失败:
endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
deployment_name = os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME")
missing = [k for k, v in {
"AZURE_AI_PROJECT_ENDPOINT": endpoint,
"AZURE_AI_MODEL_DEPLOYMENT_NAME": deployment_name
}.items() if not v]
if missing:
raise ValueError(...)
这一段的价值在于把「配置没读到」和「服务调不通」这两类问题分开了:抛出来的是 ValueError 且消息里列着变量名,就说明流程还没走到 Azure 那一侧,两个变量至少有一个是空的。前一行是 dotenv.load_dotenv(dotenv.find_dotenv())——注意它用的是 find_dotenv() 去定位文件,而不是写死路径。00-course-setup/README.md 的 Step 4 让你在仓库根目录用 cp .env.example .env(PowerShell 侧是 Copy-Item .env.example .env)建这个文件,而 notebook 躺在各课的 code_samples/ 下,两者不在同一层,这个定位动作正是为这种层级差准备的。至于具体查找行为以 python-dotenv 自身的实现为准,仓库里没有对它展开说明。
第三件是构造 provider:
provider = FoundryChatClient(
project_endpoint=endpoint,
model=deployment_name,
credential=DefaultAzureCredential()
)
注意 credential 这一项。00-course-setup/README.md 写明大部分 notebook 通过 Azure CLI 登录态认证——用 azure-identity 包里的 AzureCliCredential 或 DefaultAzureCredential,因此 .env 里不放 API key。Windows 上就是先在终端跑 az login;如果浏览器弹不出来或在远程会话里,同一份文档给了 az login --use-device-code 这条备选。macOS 与 Linux 侧命令相同。.env 模板里 AZURE_AI_MODEL_DEPLOYMENT_NAME 给的是 gpt-5-mini,这只是仓库当前的示例值,实际要填你在 Foundry 门户 Models + Endpoints 里那个部署的名字。
模式一:instructions 是一段带编号的硬约束
第一个模式在代码里只有一次调用:
agent = provider.as_agent(
name="TravelConcierge",
instructions="""You are a luxury travel concierge named Alex. Your role is to:
1. Understand the traveler's preferences (budget, climate, activities)
2. Check destination availability before making recommendations
3. Provide detailed, personalized travel suggestions
4. Always mention visa requirements and best travel seasons
Be warm, professional, and enthusiastic about travel.""",
)
值得注意的是 provider.as_agent() 这个入口——agent 不是独立 new 出来的,而是从 chat client 上派生的。00-course-setup/README.md 里讲 Foundry Local 的那段用的也是同一个方法名,只不过前面挂的是 OpenAIChatClient。换句话说,换 provider 不用改 agent 的构造写法。
instructions 的写法本身也有信息量:它是一段普通的三引号字符串,没有模板变量、没有占位符、也没有从外部文件加载。notebook 的 markdown 单元把好 instructions 拆成 who / what / how 三层,对照上面这段,就是”你是 Alex 这个角色”、四条编号职责、末尾一句语气约束。
模式二:response_format 与那个容易被忽略的 else 分支
第二个模式是这一课代码量最大的一段。先定义两个 Pydantic 模型:DestinationRecommendation 有五个字段(destination、available、best_season、highlights、estimated_budget_usd),TravelRecommendations 则是一个 list[DestinationRecommendation] 加一句 personalized_note。
然后是工具函数:
@tool(approval_mode="never_require")
def get_destination_details(
destination: Annotated[str, "The destination to look up"]
) -> DestinationRecommendation:
"""Get structured details about a vacation destination."""
这里三处都在传递 schema:Annotated 的第二个元素是参数说明,docstring 是工具描述,返回类型标注直接就是那个 Pydantic 模型。第 4 课的 notebook 把这层写得更明白——04-tool-use/code_samples/04-python-agent-framework.ipynb 说明 docstring 会成为模型看到的工具描述,类型标注(含带说明的 Annotated)定义工具 schema。
函数体里是一个写死的字典,键是几个城市名,值是构造好的 DestinationRecommendation 对象——其中一条的 available 填的是 False。这些都是仓库里的示例数据,换成真实数据源时整块都要替换掉。但有一处写法值得照抄:
return details.get(
destination,
DestinationRecommendation(
destination=destination,
available=False,
best_season="Unknown",
highlights=[],
estimated_budget_usd=0,
),
)
查不到的目的地不抛异常、不返回 None,而是返回一个字段齐全的降级对象。对上游来说,这个工具的返回类型始终是 DestinationRecommendation,模型拿到的永远是同一个 schema。工具函数的返回值是要喂回模型的,返回 None 或让异常冒出去,等于把一个模型没见过的形状塞进对话里——这一点在自己写工具时很容易忘。
真正把输出锁住的是运行时那个参数:
response = await structured_agent.run(
"Recommend 3 destinations for a culture-loving traveler with a $2500 budget",
options={"response_format": TravelRecommendations},
)
结构化结果不在 response 本身,而在 response.value 上。这一点如果没注意,很容易直接 print(response) 然后困惑为什么拿到的还是一坨文本。
而这段代码里最该被拎出来的,是紧跟着的判断:
if response and response.value:
...
else:
print("No validated structured response was returned.")
print(response)
课程示例自己留了兜底分支,并把 fallback 消息写成”没有返回经过校验的结构化响应”。也就是说,传了 response_format 不等于一定拿得到 response.value。你把这个模式搬到自己项目里时,这个 else 分支就是必须保留的那一半——别写成”设了 response_format 就一定是结构化的”。
模式三:两个 agent,其实是手工串起来的
第三个模式建了两个 agent:destination_agent 挂着 tools=[get_destination_details],logistics_agent 不带任何工具。两段 instructions 的结尾各有一句大写的排他约束,destination_agent 那句是 Do NOT discuss flights, hotels, or logistics — another agent handles that.,logistics_agent 反过来写 Do NOT recommend destinations — another agent handles that.
关键在于两者怎么衔接。notebook 里没有 workflow、没有 orchestrator、没有任何调度对象,就是两次 await:
dest_response = await destination_agent.run(
"I want a week of culture and food for under $2500. Where should I go?"
)
logistics_response = await logistics_agent.run(
f"Plan a week-long trip based on this recommendation:\n{dest_response}"
)
跨 agent 传的东西,是把第一个 agent 的响应对象直接插进 f-string 拼成的一段自然语言。对比上一节:模式二好不容易用 Pydantic 把输出锁成了带类型的对象,到了模式三这里,两个 agent 之间又退回成了字符串。这两处都是仓库里白纸黑字的写法,摆在一起看会发现,response_format 管的是”一次调用的出口”,而不是”多个 agent 之间的接口”。如果你想让第二个 agent 收到的是结构化数据,那就得自己在中间加一层:让第一个 agent 也带 response_format,再从 response.value 里取字段拼提示词。第 3 课的示例没有做这一步。这是本课”单一职责”的实际形态:职责的隔离靠 instructions 里的 Do NOT 句子,交接靠字符串拼接。真要做条件分支、并行、状态传递,那是后面几课的 workflow 范畴,第 3 课这里没有。
把 README 那套和代码这套对上一处
README 的三条指引里,Control 那条要求用户能控制系统、能修改、能删除。在这一课的代码里,唯一能找到的对应开关是 @tool 上的 approval_mode——而第 3 课的示例统一填的是 never_require。这个取值的含义由第 4 课写明:never_require 表示工具自动执行、不需要用户确认;另一个取值 always_require 表示每次调用都要用户批准后才执行,仓库文档自述其用途是给有副作用的工具(示例里举的是订机票、扣款)留一个人在环路里。
所以第 3 课示例里的 never_require 是一个查询类工具的示例取值,不是推荐配置。你把这套模式挪到会写数据的工具上时,这个参数就是必须重新决定的那一个。
如果要把两课的写法合到一起,大致是这个形状:
@tool(approval_mode="always_require")
def book_trip(
destination: Annotated[str, "The destination to book"]
) -> str:
"""Book a trip. Requires approval before executing."""
...
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
最后一件容易踩的事:.NET 那份不是同一套
第 3 课 README 底部的 Sample Codes 列了两条,Python 指向 .ipynb,.NET 指向 code_samples/03-dotnet-agent-framework.md。但打开 .NET 那份会发现,它讲的是 Factory、Builder、Repository 这类经典软件工程模式,以及依赖注入、IConfiguration、ILogger 这些 .NET 侧的东西,标题也写明用的是 Azure OpenAI 的 Responses API。它和 Python notebook 那三个模式不是一一对应的两种语言实现。你按 .NET 那份的目录去 Python notebook 里找 Factory 模式,是找不到的;反过来也一样。
这个课程仓持续更新,上面提到的文件路径、类名、装饰器参数与示例取值都可能随版本变动,以仓库最新内容为准。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。