workflow 条件分支不走:微软 AI Agent 入门课第 8 课的判定写在哪一行

2026-08-18

ai-agents-for-beginners 第 8 课的 workflow 示例里,文件名带 basic、sequential、concurrent 的那几个都是直线或扇出扇入,看图就懂。轮到 08-multi-agent/code_samples/workflows-agent-framework/python/04.python-agent-framework-workflow-aifoundry-condition.ipynb 就容易卡住:审稿 agent 明明返回了结果,后面要么什么都没发生,要么走了一条你没预期的路。

问题不在 agent 身上。这个示例里条件判定压根不在 agent 里,也不在边的定义上,它是一个普通的 Python 函数。下面按「怎么确认 → 怎么处置 → 怎么验证 → 什么情况不是它」走一遍。

一、条件节点在哪:三处,不是一处

先把示例的图结构摆出来(notebook 里构建 workflow 的原文):

workflow = (
    WorkflowBuilder()
        .set_start_executor(evangelist_agent)
        .add_edge(evangelist_agent, reviewer_agent)
        .add_edge(reviewer_agent, to_reviewer_result)
        .add_multi_selection_edge_group(
            to_reviewer_result,
            [handle_review, save_draft],
            selection_func=select_targets,
        )
        .add_edge(save_draft, publisher_agent)
        .build()
)

判定这件事被拆在三个地方:

  1. reviewer_agent 只负责产出文本。它的行为约束写在 ContentReviewerInstructions 字符串里,要求返回带 review_result(取值 YesNo)、reasondraft_content 三个字段的 JSON。
  2. to_reviewer_result 负责把文本变成对象。它是用 @executor(id="to_reviewer_result") 装饰的异步函数,签名接 AgentExecutorResponse,内部用 ReviewAgent.model_validate_json(response.agent_run_response.text) 解析,再 ctx.send_message(...) 发出一个 ReviewResult dataclass。
  3. select_targets 才是真正的判定。它是普通函数,通过 selection_func= 参数挂到 add_multi_selection_edge_group 上。

所以「条件分支不走」这句话本身是含糊的,它至少对应三个不同的断点。

这里先记住一个后面要展开的要害:select_targets 的签名是 (review: ReviewResult, target_ids: list[str]) -> list[str],函数体第一行 handle_review_id, save_draft_id = target_ids按位置解包,而位置来自 builder 里那个列表 [handle_review, save_draft]。这两处是靠顺序对齐的,不是靠名字对齐的。

二、怎么确认卡在哪一段(可执行的判定动作)

示例自带两个观察点,不用你另外加代码。

第一个是打印。 to_reviewer_result 的函数体第一句就是:

print(f"Raw response from reviewer agent: {response.agent_run_response.text}")

看这行有没有输出、输出了什么:

  • 完全没有这行 → 流程根本没走到判定环节,问题在上游(见最后一节)。
  • 有这行,紧接着是解析报错 → 卡在 model_validate_json,agent 返回的不是干净 JSON。
  • 有这行、JSON 也正常、后面却没有下文 → 才轮到 select_targets 和边。

第二个是画图。 notebook 在构建完 workflow 之后就调了 WorkflowViz

viz = WorkflowViz(workflow)
print(viz.to_mermaid())
print(viz.to_digraph())
svg_file = viz.export(format="svg")

to_mermaid()to_digraph() 输出的是当前这张图实际连了哪些边。判定动作很直接:在图里找 handle_review 有没有出边

三、按代码语义能做的处置

1. JSON 契约目前只写在提示词里

看 agent 的创建代码,reviewer_agent 那一处的 response_format=ReviewAgent被注释掉的

reviewer_agent = AgentExecutor(chat_client.create_agent(
    instructions=(ContentReviewerInstructions),
    # response_format=ReviewAgent
), id="reviewer_agent")

也就是说,「必须返回 JSON」这个约束当前只由 ContentReviewerInstructions 的文字承担,没有在接口层强制。而下游的 ReviewAgent 是 pydantic 的 BaseModel,字段声明是 review_result: Literal["Yes", "No"],一旦模型多包了一层代码围栏或者在 JSON 前后加了寒暄,model_validate_json 就会抛错。仓库里已经写好了那行 response_format=ReviewAgent,只是注释着,把它启用是最贴近示例原意的处置。

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。

顺带一处值得注意的对应关系:ReviewAgentreview_resultLiteral["Yes", "No"],而 ReviewResult 这个 dataclass 的同名字段声明是普通 str。真正的值域收紧发生在解析那一步,不在分支那一步——所以你在 select_targets 里看到的 == "Yes" 逐字比较,能成立的前提是上游那个 Literal 已经把值卡住了。

2. 目标顺序是按位置解包的

这是最容易埋雷的一处。select_targets 的原文:

def select_targets(review: ReviewResult, target_ids: list[str]) -> list[str]:
        # Order: [handle_review, submit_to_email_assistant, summarize_email, handle_uncertain]
        handle_review_id, save_draft_id = target_ids
        if review.review_result == "Yes":
            return [save_draft_id]
        else:
            return [handle_review_id]

target_ids 的顺序来自 builder 里 [handle_review, save_draft] 这个列表。函数用位置解包接住它。你如果在 builder 里调换这两个目标的先后,而忘了同步改这两个变量名,分支会整个反过来,并且不会报任何错——它仍然是一次合法的解包,对着一个两元列表照样成立。这类故障的表现恰恰是「条件看起来是对的,走向却反了」,而且反的位置不在你盯着看的 if 那一行。所以改这个示例时,builder 里的列表和函数里的解包语句要当成一处来改,别只改一边。

另外,函数体里那行注释列的是四个名字(handle_reviewsubmit_to_email_assistantsummarize_emailhandle_uncertain),与实际解包的两个目标对不上。同一段代码里 save_draft 的函数体上方也留着一行 # Only called for long NotSpam emails by selection_func,而这个示例处理的是投稿评审,不是邮件。这两行注释在仓库里就是和代码对不上的,仓库里我们也没有找到对这处出入的说明,所以只把事实指出来:读的时候以代码为准,别照着注释去数 target_ids 的下标。

3. 「未命中」在图上是什么样

handle_review 的函数体:

@executor(id="handle_review")
async def handle_review(review: ReviewResult, ctx: WorkflowContext[str]) -> None:
    if review.review_result == "No":
        await ctx.yield_output(f"Review failed: {review.reason}, please revise the draft.")
    else:
        await ctx.send_message(
            AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=review.draft_content)], should_respond=True)
        )

把它和 builder 放在一起看:整张图里,handle_review 只有入边,没有任何出边(唯一后续的边是 .add_edge(save_draft, publisher_agent))。所以这里的 else 分支 send_message 出去的消息,在图上没有下家。这两处的对应关系就摆在代码里,值得你在改这个示例前先看清。

还有一层更常被误会的:08-multi-agent/code_samples/workflows-agent-framework/README.md 的 Case 4 一节写明,评审为 No工作流停下并输出拒绝原因。而 notebook 里那段 task 文本写的是「otherwise need to rewrite draft until it meets the requirements」。图里确实没有一条回到 evangelist_agent 的边。也就是说,很多人以为的「条件分支不走」,实际是「那条边本来就不存在」。

四、处置后怎么验证

  • 重新执行构建 workflow 那一格,比对 to_mermaid() 的输出:边的数量与走向是否是你改后想要的。
  • Raw response from reviewer agent: 打印出来的 JSON 里 review_result 的实际取值。
  • 看流式事件。notebook 的消费循环是这样写的:
async for event in workflow.run_stream(task):
    if isinstance(event, DatabaseEvent):
        print(f"{event}")
    if isinstance(event, WorkflowEvent):
        print(f"Workflow output: {event.data}")

这里有一处需要心里有数:DatabaseEvent 在前面一格定义为 class DatabaseEvent(WorkflowEvent): ...,是 WorkflowEvent 的子类,而两个 if 是并列的。同一个 DatabaseEvent 会同时满足两个判断。看日志时别把重复的一条当成分支跑了两次。

五、什么情况说明不是条件分支的问题

Raw response from reviewer agent: 都没有打印,那就跟条件无关,往上游查:

  • notebook 里取 Bing 连接用的是 conn_id = os.environ["BING_CONNECTION_ID"],直接下标取值,环境变量缺失是 KeyError。仓库根目录的 .env.example 里有这一项,00-course-setup/README.md 的环境变量表也写明了它从 Microsoft Foundry 门户的项目 → Management → Connected resources 里的 Bing 连接复制。
  • 客户端走的是 AzureCliCredential()AzureAIAgentClient,notebook 的前置说明里写了要先做 az login / az account set --subscription "your-subscription-id" / azd auth login。这是本机登录态,不是密钥。
  • notebook 的 markdown 里写「please copy .env.examples as .env」,而仓库根目录的实际文件名是 .env.example(没有 s)。按仓库里实际存在的那个文件名复制即可。Windows 侧在 PowerShell 用 Copy-Item .env.example .env,Linux/macOS 用 cp .env.example .env;这一句是通用的文件复制做法,不是课程官方内容。

如果是 import 就失败,那更不是分支的事。仓库根 requirements.txt 当前把 agent-framework-core 钉在一个具体版本上(这个钉法随仓库更新会变,以你拉到的那份为准),旁边的注释写明了钉住的理由:更高的那个版本引入了破坏性改动,移除了 ChatMessageHostedWebSearchTool、改了 Message 构造、并去掉了 Agent.run()model= 参数。而这个 notebook 的第一个代码格是 ! pip install agent-framework-azure-ai -U,带 -U。这两处摆在一起看就够了——ChatMessageHostedWebSearchTool 恰好都在这个 notebook 的 import 里,剩下的按你自己环境里实际装到的版本判断。

如果你写的是 .NET 那份,条件的位置完全不同,不要拿 Python 的结论套。同目录的 dotNET/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"))
    .AddEdge(contentReviewerExecutor, sendReviewerExecutor, condition: GetCondition(expectedResult: "No"))

两条边各自带一个 condition,各判各的;而 Python 那边是一个 selection_func 统一返回要走的目标列表,else 一定会给出一个目标。两种写法在「两个条件都不成立」时的形态并不一样——写哪一份就查哪一份的文件。

最后提醒一句:ReviewResult 在 .NET 侧是用 [JsonPropertyName("review_result")] 映射到属性 Result 的,条件里比的是 review.Result;Python 侧比的是 review.review_result。同一个 JSON 字段,两份代码里的属性名不同,搜代码时别只搜一个拼法。


本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码, 因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。