微软 AI Agent 入门课第 6 课:人工介入的中断点设在哪一行

2026-08-18

写人工介入(human-in-the-loop)这件事,最容易糊过去的就是「在哪里停下」。停在模型说完话之后,和停在副作用发生之前,代码上只差几行,后果完全不同。ai-agents-for-beginners 的第 6 课正好把这两种写法同时放在了仓库里,可以直接对着读。

README 的片段停在输出之后

06-building-trustworthy-agents/README.md 的「Human-in-the-Loop」一节给了一段 Microsoft Agent Framework 的示例:先用 FoundryChatClient 建 provider,调 provider.create_response(...) 拿到结果,然后 print(response.output_text),再执行

user_input = input("Do you approve? (APPROVE/REJECT): ")

中断点就在这一行。它的位置决定了:模型这一次调用已经发生,如果这个响应背后还带着别的动作,动作也已经发生了。

同一课的配套 notebook 06-building-trustworthy-agents/code_samples/06-human-in-the-loop.ipynb 在开篇的 markdown 里对这一点写得很直白:README 那段是起点,但生产环境常常还需要三块东西——动作执行前的 gate、风险分级、以及审计日志加修订回路。这段是仓库自述的动机,不是我们替作者推测的。

中断点被挪到了哪一行

notebook 里真正的中断点在 gate_action() 里,而这个函数被调用的时机由 run_with_revision() 决定。这条链一共四层,值得逐层点名:

run_with_revision(goal, max_revisions)propose_action(goal, prior_rejection)tiered_gate(action, attempt)classify_risk(action),只有分类结果是 high 时才继续走到 gate_action(action, tier, attempt=attempt)

关键在 run_with_revision 循环体的顺序:

for attempt in range(max_revisions + 1):
    action = propose_action(goal, prior_rejection=prior_reason)
    decision = tiered_gate(action, attempt=attempt)
    decision["attempt"] = attempt
    log_decision(decision)
    if decision["decision"] == "approve":
        return decision
    prior_reason = decision["reason"]

propose_action 拿到的只是一句「下一步要做什么」的自然语言描述,tiered_gate 审的就是这个字符串。也就是说,模型这一轮被要求产出的是提案而不是执行,审批因此排在了任何副作用之前。

这里有一处读源码时必须看清的事实:这个 notebook 的循环走到 approve 就直接 return decision 了,代码里并没有一个真正去执行动作的分支。它演示的是 gate 本身的机制,不是一个完整的执行器。你把这套结构搬到自己项目里时,「批准之后调用哪个函数」这一段是要你自己补的。

待确认信息长什么样

gate_action 的返回值是一个 dict,键固定为 decisionreasonactionrisk_tiertstsdatetime.now(timezone.utc).isoformat() 生成。decision 的取值是 approvedenyescalate 三个,docstring 写明拿不到输入(EOF)或输入不在这三个里时,安全默认是 deny——空输入记 "no input received, defaulted to deny",非法输入记 "invalid input {raw!r}, defaulted to deny"。默认往严的一侧倒,是这段代码里反复出现的态度。

run_with_revision 在写日志前又往这个 dict 里塞了一个 attempt,用来记这是第几次提案。循环跑完还没批准的话,返回值是 {**decision, "final": "max_revisions_reached"}——多出来的 final 字段是「用完了修订次数」这一种结局的唯一标记,上游要区分「被拒到底」和「压根没走到 gate」就靠它。

log_decision(decision) 把这个 dict 直接 json.dumps 后追加一行写进 GATE_LOG_PATHopen("a", encoding="utf-8"),一次一行 JSONL。日志文件名是每次运行独立的:

GATE_LOG_PATH = Path(
    f"gate_log_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.jsonl"
)

notebook 的注释自述这么做是为了让多次演示不互相覆盖,也不去碰工作目录里已有的 gate_log.jsonl。顺带说一句对 Windows 读者有用的细节:文件名用的是 %Y%m%dT%H%M%SZ 这种不带冒号的紧凑格式,而记录里的 ts 字段用的是 isoformat(),后者是带冒号的。冒号在 Windows 文件名里不合法,所以如果你照着改造,想拿 ts 直接当文件名,那是在 Linux 上能过、在 Windows 上会被挡的写法——这是通用的文件系统常识,不是该项目的官方说明。

驳回之后,什么被传回去了

这是整段结构里唯一的反向通道:prior_reason = decision["reason"],下一轮再作为 prior_rejection 传进 propose_action。在 propose_action 内部,它被拼进用户消息:

user_text = f"Goal: {goal}"
if prior_rejection:
    user_text += f"\n\nPrior proposal was denied. Reason: {prior_rejection}"

所以回传给模型的不是「被拒了」这个布尔量,而是那条 reason 字符串本身。这也意味着 gate 里写的 reason 文案直接决定了下一轮提案的质量:如果人工审批时随手敲一句什么都不说的理由,模型下一轮拿到的就是这句什么都不说的话。系统提示里对此有明确要求,写的是若给出了 prior rejection reason,就要提出一个能应对该驳回的不同动作。

调用侧用的是 Azure OpenAI 的 Responses API,client.responses.create(model=deployment, input=[...], store=False),取 response.output_text.strip()store=False 是仓库当前代码里的写法,随版本可能变动。

分级把大部分动作挡在中断点之外

classify_risk() 是纯关键词启发式,notebook 自述这样做是为了让演示保持确定且低成本,并明说生产系统应换成学习型分类器或策略引擎。判断顺序是先 high、再 low、最后 medium,匹配用的是 any(kw in text for kw in ...)——子串包含,不是分词。这两点合起来会产生一个你必须知道的效果:一句话里只要出现了 high 关键词(仓库示例里的取值包括 senddeletecancel 这类,此处只是举例,不列全表),无论后面还有多少 low 关键词,结果都是 high

不认识的动作默认落到 medium 而不是 low,docstring 把理由写出来了:只读关键词表必然有盲点,而本课 README 列的威胁(关键系统访问、知识库投毒、级联错误)都不是单看动作名就能排除的,所以未知动作走批量复核比自动执行更保守。

tiered_gate 的分流很短:tier 命中 AUTO_APPROVE_REASONS 里的键(lowmedium)就地拼一个 approve 的 dict 返回,只有 high 才落到 gate_action。换句话说,中断点虽然写在 gate_action 里,但大多数动作根本走不到那一行——这才是分级的意义所在。

打开 DEMO_MODE 时哪些不是真的审批

DEMO_MODE = True 是 notebook 顶部的默认取值(仓库当前代码里的默认值,随版本可能变动),置为 False 才会走真实的 input()。这时 gate_action 走的是脚本化分支:highattempt == 0 时返回 denyattempt >= 1 时返回 approve。docstring 特意大写强调了这不是修订驱动的重新分类,只是把「拒绝 → 重试 → 批准」的回路跑给你看;真正由人根据修订后的提案重新判断,需要 DEMO_MODE = False 加一个真人操作员。开篇 markdown 里也重复了这句。读这个 notebook 最容易误读的就是这里——看到日志里第二次变成 approve,会以为是模型改好了提案。

另外,notebook 明确划了 out of scope:认证与访问控制(对应 README 的第二类威胁)、工具调用中间件(第 14 课的 MAF 深入)、多智能体辩论模式,都不在这个文件里。结尾的 Additional resources 一节还点了三种别处的实现形态——LangChain 把人工介入包成 tool,AutoGen 用 UserProxyAgent 这个角色来代表人(并注明 AutoGen v0.4+ 对此做了重构),Microsoft Agent Framework 用函数调用中间件。想抄一套到自己项目里,这三条是它给的比较对象。

跑之前要配的东西

第一个 code cell 会读 AZURE_OPENAI_ENDPOINT,取不到就直接 raise RuntimeError,错误信息里写明需要一个支持 Responses API 的模型部署,并要求先 az loginAZURE_OPENAI_DEPLOYMENT 用的是 os.environ[...] 直取,缺了会抛 KeyError。鉴权走 Entra ID:get_bearer_token_provider(DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"),把返回的 provider 当 api_key 传给 OpenAI(base_url=f"{endpoint.rstrip('/')}/openai/v1/", ...)。这段的注释还写明 GitHub Models 已被弃用且不支持 Responses API,所以别拿旧教程里的那条路线套过来。

Windows 侧要注意的是 az login 依赖本机装好的 Azure CLI,DefaultAzureCredential 才能拿到凭据;密钥与 endpoint 建议放在项目目录下的 .env 里由 load_dotenv() 读取,写进正文里的一律用 <YOUR_API_KEY> 这类占位,别把真实 endpoint 和密钥留在 notebook 单元格里。

只想看结构不想连云端的话,classify_riskgate_actionlog_decision 这三个函数都不依赖 client,能单独拿出来读;依赖 client 的只有 propose_action


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

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

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