workflow 还是自主 Agent:微软 AI Agent 入门课第 8 课的两种写法
第 8 课的目录里其实塞了两组不太一样的代码。一组在 08-multi-agent/code_samples/08-python-agent-framework.ipynb,另一组在 08-multi-agent/code_samples/workflows-agent-framework/ 下面,按 01. 到 04. 编号,Python 和 dotNET 各一份。第一次翻的人容易把它们当成同一件事的详略两版,但真正值得看的是它们在同一个问题上给出的不同答案:下一步该跑哪个 agent,这个决定写在谁那里。
分界线落在 edge 上,不落在 agent 上
先看最短的那段。08-python-agent-framework.ipynb 里建图只有三行:
workflow = WorkflowBuilder(start_executor=planner_agent) \
.add_edge(planner_agent, concierge_agent) \
.build()
planner_agent 和 concierge_agent 都是 client.as_agent(...) 造出来的,各自带一段 instructions。紧接着的 cell 里又加了个 budget_agent,注意它不是在原对象上补一条边,而是重新 build 了一个 extended_workflow,多一句 .add_edge(concierge_agent, budget_agent),链条就从两段变三段。这里每个节点内部是模型说了算,但节点之间的顺序是你在 Python 里写死的——模型没有任何机会说”我觉得不用过预算这一关”。
真正把分界线暴露出来的是 04.python-agent-framework-workflow-aifoundry-condition.ipynb。这个样例里有个分支:草稿写完交给审稿 agent,通过就发布,不通过就打回。分支逻辑长这样:
def select_targets(review: ReviewResult, target_ids: list[str]) -> list[str]:
handle_review_id, save_draft_id = target_ids
if review.review_result == "Yes":
return [save_draft_id]
else:
return [handle_review_id]
一个普通的 Python 函数,通过 add_multi_selection_edge_group(to_reviewer_result, [handle_review, save_draft], selection_func=select_targets) 挂到图上。.NET 那份 04.dotnet-agent-framework-workflow-aifoundry-condition.cs 做法不同但落点一样,它把条件写成 Func<object?, bool> GetCondition(string expectedResult) => reviewResult => reviewResult is ReviewResult review && review.Result == expectedResult;,然后在 AddEdge(contentReviewerExecutor, publishExecutor, condition: GetCondition(expectedResult: "Yes")) 上挂条件。
注意这里有一层很容易被忽略的错位:条件判断是硬的,条件的输入是软的。 select_targets 读的是 ReviewResult 这个 dataclass 的 review_result 字段,而这个字段的值来自审稿 agent 的输出——ContentReviewerInstructions 里写的是让模型判断草稿是不是超过 200 个英文单词(原文 more than 200 words,这只是仓库示例 instructions 里的阈值),超过就把 review_result 设成 Yes,不到就设成 No 并把 reason 填成内容太短。所以路由这一跳是确定的,但喂给路由的那个值是模型算出来的。你在图上看到的两条分支,可靠程度取决于模型有没有老老实实按 instructions 填字段。
自治的那一侧在同一个文件里也能找到:evangelist_agent 拿到的是 tools=[HostedWebSearchTool()],publisher_agent 拿到的是 tools=HostedCodeInterpreterTool(),而 PublisherInstructions 只交代了”运行代码把草稿存成 Markdown 文件,文件名用年月日时分秒”。搜什么、怎么搜、文件名具体拼成什么样,都不在你的代码里。
可预测性:能不能在跑之前把路径画出来
这是两种写法差距最直白的地方。workflows-agent-framework/python/ 下四个 notebook 里都有同一段:
viz = WorkflowViz(workflow)
print(viz.to_mermaid())
print(viz.to_digraph())
try:
svg_file = viz.export(format="svg")
print(f"SVG file saved to: {svg_file}")
except ImportError as e:
svg_file = None
print(f"SVG export skipped (install graphviz to enable): {e}")
build() 之后、run() 之前就能把整张图打出来。凡是控制流写在 edge 上的部分,都会出现在这张图里;凡是交给 agent 自己决定的部分,图上只是一个节点。所以”跑前能预演”这句话要说得更准确一点:能预演的是编排层,不是节点内部。
顺带一个装环境时会撞到的坑,仓库自己在注释里写明了:SVG 导出需要可选的 graphviz extra(02. 的注释里逐字写成 pip install graphviz)加上 graphviz 的系统二进制。这两样是两回事,装了 pip 包不等于系统那份就在 PATH 里,仓库把它们并列写出来正是为了这个。所以 01.、02.、03. 三份都用 try / except ImportError 兜住,捕获到就打印 SVG export skipped (install graphviz to enable) 然后继续;不想折腾就只用 to_mermaid(),纯文本,没有额外依赖。04. 那份没有这层兜底,直接 viz.export(format="svg")。
自治那一侧有没有对应的”跑前导出”能力?我们在第 8 课的代码与 README 里没有找到相应写法,这一点不比。同样地,08-multi-agent/README.md 的「Multi-Agent Patterns」一节讲 Group chat 与 Hand-off 时只给了示意图和文字——而且 Hand-off 那段自己写的是 agents hand off tasks to other agents based on predefined rules,落点仍在预定义规则上。第 8 课的 code_samples 下没有这两种模式对应的代码文件,所以”完全由模型自己挑下一棒交给谁”这一档,本课里没有可对照的实现,我们不替它补。
还有一件跟预测性直接相关、但读文档时容易滑过去的事:编排层是按消息类型分发的。 workflows-agent-framework/README.md 的核心组件一节写明,每个 executor 可以有多个 message handler,框架依据收到的消息类型自动调用对应的那个。落到代码上,Python 侧表现为 WorkflowContext[ReviewResult] 这种带类型参数的上下文声明,.NET 侧表现为 ReflectingExecutor<ContentReviewExecutor>, IMessageHandler<ContentResult, ReviewResult> 这样的接口签名。
这里正好有一处 README 与 notebook 对不上、又跟消息类型直接相关的地方,值得单独记一笔:同一个 workflows-agent-framework/README.md 讲多步样例时,输入写的是一条同时装着 TextContent(text=...) 与 DataContent(uri=image_uri, media_type="image/png") 的多模态消息;而 02.python-agent-framework-workflow-ghmodel-sequential.ipynb 里的注释写明,迁移后为了把这一课聚焦在顺序编排机制本身,改成传一段纯文本描述,并说明 agent 接受普通字符串、与 basic 和 concurrent 两份样例保持一致——image_uri 那几行还留在上面,但没有再进消息体。照 README 抄输入结构,和照 notebook 抄,拿到的消息类型不是一回事。所以当某个节点”没被触发”时,先怀疑上游发出的消息类型和下游声明的类型对不上,再去怀疑条件函数写错了——这两种现象在日志里长得很像。
调试难度:故障落在哪一层
编排式的好处不在于不出错,而在于出错的位置是可枚举的。还是看 04. 那个 Python 样例,它在解析节点里留了一行:
@executor(id="to_reviewer_result")
async def to_reviewer_result(response: AgentExecutorResponse, ctx: WorkflowContext[ReviewResult]) -> None:
print(f"Raw response from reviewer agent: {response.agent_run_response.text}")
parsed = ReviewAgent.model_validate_json(response.agent_run_response.text)
这一行 print 是有讲究的。审稿结果走的是”模型按 instructions 吐 JSON 文本 → model_validate_json 解析成 pydantic 模型 → 转成 dataclass → 交给 select_targets”这条链。链上至少有两类完全不同的故障:模型吐的不是合法 JSON(解析就炸),和模型吐的 JSON 合法但把 Yes 判成了 No(解析成功、路由走错)。有了 raw 打印才分得清是哪一类。
这里还有一处值得记下来的细节:这个 notebook 里三个 agent 的 response_format 只有 publisher 那个是启用的(response_format=PublisherAgent),evangelist 和 reviewer 的 response_format 都被注释掉了,格式约束只靠 instructions 里那句”return result as JSON with fields …”撑着。翻代码的时候看到被注释掉的行,别当成无关紧要的残留——它恰好落在上面那条解析链的第一环上。
至于”谁在说话”,框架是给了归属信息的。Python 侧 08-python-agent-framework.ipynb 在流式循环里靠 update.author_name 判断当前输出属于哪个 agent,作者一变就打一条分隔线;.NET 侧 08-dotnet-agent-framework.cs 是从 AgentResponseUpdateEvent 上取 ExecutorId 打印。04. 的 .NET 版更直接,每个自定义 executor 内部自己 Console.WriteLine,比如 ContentReviewExecutor 会把 review result 和 reason 一起打出来。
再提醒一件调试时会撞到的事:同一课里调用形态不止一种。 01. 的注释逐字写着 Workflow.run_stream is no longer part of the public API,它用的是 await workflow.run(...) 再 get_outputs();而 04. 仍然是 async for event in workflow.run_stream(task);08-python-agent-framework.ipynb 又是第三种,workflow.run(..., stream=True) 然后异步迭代。这三处摆在一起是矛盾的,照着其中一篇改另一篇很容易踩空。课程持续更新,具体以仓库最新代码为准。
一条按处境倒推的选择路径
不排优劣,只说边界怎么划:
- 要对外解释”为什么走了这条路” → 把这一跳放进 edge。
select_targets或GetCondition是可以逐行读、可以写单测的代码,图也能导出来。 - 分支条件能不能落成一个可判等的字段 → 能就编排。
04.里ReviewAgent把review_result声明成Literal["Yes", "No"],正是为了让下游那个==有意义。落不成字段(比如”看情况再定”),硬塞进 edge 只是把模糊挪个位置。 - 必须由模型决定用哪个工具、怎么命名产物 → 留在节点内,同时接受这一段不可预演、只能靠日志回看。
- 并行这一档,Python 和 .NET 的写法不对称,别照搬。 Python 的
03.是自定义一个InputDispatcher(Executor)做透传,再用WorkflowBuilder(start_executor=dispatcher, output_executors=agents).add_fan_out_edges(dispatcher, agents);而同目录README.md讲这一课时给的是ConcurrentBuilder().participants([research_agent, plan_agent]).build()。README 与 notebook 这两处对不上,以你实际要跑的那个文件为准。.NET 侧按 README 的说法要自己写ConcurrentStartExecutor与ConcurrentAggregationExecutor,配AddFanOutEdge/AddFanInEdge。
最后是环境这一层,这几个文件名里的 ghmodel 现在已经名不副实:01.、02.、03. 三份实际用的都是 FoundryChatClient(project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential()),注释里写明它面向 Azure OpenAI Responses API;00-course-setup/README.md 也写明这些样例以前用 GitHub Models,而 GitHub Models 已废弃且不支持 Responses API。04. 又换了一套,装的是 agent-framework-azure-ai,客户端是 AzureAIAgentClient,凭据取自 azure.identity.aio 的 AzureCliCredential,另外要求环境变量 BING_CONNECTION_ID。Windows 上这几份都得先在终端跑 az login,因为 AzureCliCredential 读的是 Azure CLI 已登录的状态;04. 的 .NET 版头部把 workflows 相关包钉在 preview 版本上,属于预览阶段的接口,签名随时可能变。
以上路径与写法均按仓库代码中的接口语义整理,未经实测,以仓库最新代码为准。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
本文对照的是同一项目内的两种用法,依据均为上述仓库内容,不对两种用法做优劣排名, 选型结论只在仓库文档写明的能力边界内成立。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。