Agent 的循环本质

大家都知道,Agent 内部本质是循环。

模型本身只会读一段消息、吐一段回复,并不会反复思考、调用工具、再思考。

所谓 Agent 能自主完成任务,靠的是客户端内部的循环,把模型回复里带的工具调用捡起来执行、把结果塞回去、再让模型接着想,如此反复,直到模型不再要求调用工具为止。

在后续章节会介绍不同的 Agent 工作模式。


但只靠这一层循环还不够。很多任务不是调一次工具就完了,比如调研一个主题并写篇报告,可能要好几轮规划→搜索→写作→自检→补充才算交付。MAF 把这种需求抽象成第二层循环,由一个专门的评判器在每一轮结束后判断任务是否完成,没完成就把整个 Agent 再跑一遍。


所以 MAF SDK 里的 loop 是两层嵌套的,职责分明:

内层循环(工具调用级)外层循环(任务级)
实现FunctionInvokingChatClient(来自 Microsoft.Extensions.AILoopAgentMicrosoft.Agents.AI,Harness 命名空间)
循环体一次"调模型 → 解析工具调用 → 执行工具 → 塞回结果"一次完整的 agent run(含内层循环)
停止条件模型不再请求工具调用(或撞到迭代上限)评判器判定任务完成(或撞到 MaxIterations
谁说了算模型(它要调工具就继续)评判器(基于这一轮的产出)
挂载方式管道中间件(UseFunctionInvocation装饰器包住整个 agent


两层是正交的:外层每跑一次,内层都会自己转好几圈。下图是它们的嵌套关系:

正在渲染 Mermaid 图表...

HarnessAgent 和 LoopAgent 的关系

MAF 框架也提供了 HarnessAgent 。

MAF 里有两种 agent:

  • 普通 AIAgent:要工具循环、要压缩、要历史、要记忆,都得你自己用 AIAgentBuilder / ChatClientBuilder 一层一层手搭。
  • HarnessAgent:MAF 提供的开箱即用的封装。但它自动把前面几章讲的能力拼成一条完整管道,每件能力都用一个 Disable* 开关单独控制。创建方式是:
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions { ... });


它默认装配的栈,几乎覆盖了前面所有章节具有的能力:

能力默认关闭开关
工具调用循环 FunctionInvokingChatClient(本章内层循环)MaximumIterationsPerRequest 限制
上下文压缩 CompactionProvider(第六章)DisableCompaction
历史持久化,默认 InMemoryChatHistoryProvider(第五章)传自定义 ChatHistoryProvider
Todo 列表 TodoProviderDisableTodoProvider
文件记忆 FileMemoryProvider(第七章的"记忆")DisableFileMemory
工具自动审批 ToolApprovalAgentDisableToolAutoApproval
链路追踪 OpenTelemetryAgentDisableOpenTelemetry
任务级循环 LoopAgent(本章外层循环)LoopEvaluators 开启


现在关系就清楚了。LoopAgentHarnessAgent 的可选外挂之一,由 HarnessAgentOptions.LoopEvaluators 这个开关控制。源码里 HarnessAgent.BuildAgent 的逻辑很简单:只要你传了至少一个 evaluator,它就在装配链最外层自动套一个 LoopAgent

// HarnessAgent.BuildAgent 内部(源码简化)
if (options?.LoopEvaluators is IEnumerable<LoopEvaluator> loopEvaluators)
{
    var list = loopEvaluators.ToList();
    if (list.Count > 0)
        builder.Use((inner, _) => new LoopAgent(inner, list, options.LoopAgentOptions, loggerFactory));
}


于是就有两种等价的写法,它们做的是同一件事:

// 写法 A:让 HarnessAgent 帮你套(推荐,更简洁)
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
    LoopEvaluators = [new TodoCompletionLoopEvaluator()],   // ← 传进来,HarnessAgent 自动套 LoopAgent
    LoopAgentOptions = new() { MaxIterations = 5 },
});

// 写法 B:自己手动套(本章 sample 的演示写法,方便对照每一层)
AIAgent harnessAgent = chatClient.AsHarnessAgent(new HarnessAgentOptions { /* 不传 LoopEvaluators */ });
AIAgent loopAgent   = new LoopAgent(harnessAgent, evaluator, new LoopAgentOptions { MaxIterations = 5 });

所以本章的两层循环可以这样理解:

  • 内层循环(工具调用循环)= HarnessAgent 默认就开着的 FunctionInvokingChatClient,你什么都不用管;
  • 外层循环LoopAgent)= HarnessAgent 默认关着、需要你用 LoopEvaluators 开启的模块(或自己手动套一层)。

下面分别拆开看这两层。


内层循环

这是 Agent 最底层、也最关键的循环。

它来自 Microsoft.Extensions.AIFunctionInvokingChatClient,一个 IChatClient 装饰器。

MAF 的 HarnessAgent 在构建管道时,通过 UseFunctionInvocation 把它挂上去:

// HarnessAgent.BuildInnerAgent 里的关键一段(简化)
ChatClientBuilder pipeline = chatClientBuilder
    .UseFunctionInvocation(
        loggerFactory,
        configure: options?.MaximumIterationsPerRequest is int maxIterations
            ? ficc => ficc.MaximumIterationsPerRequest = maxIterations   // 内层循环的硬上限
            : null)
    .UseMessageInjection()
    .UsePerServiceCallChatHistoryPersistence();


FunctionInvokingChatClient 把调模型这件事包成了一个大循环,伪代码大致是:

do {
    response = chatClient.GetResponseAsync(messages, tools)   // ① 调模型
    toolCalls = 从 response 里挑出 FunctionCallContent          // ② 看模型要不要调工具
    foreach (call in toolCalls)
        result = 执行对应的 AIFunction                          // ③ 真正执行工具
        messages.Add(toolCallMessage)                          // ④ 把调用记录塞回消息
        messages.Add(toolResultMessage)                        // ④ 把工具结果塞回消息
} while (toolCalls 非空 && 迭代次数 < 上限)                      // ⑤ 模型还想要工具就继续


每转一圈,消息列表就变长一点(追加工具调用 + 工具结果),下一轮模型就能看到上一轮工具的结果继续推理。当模型觉得信息够了,可以直接回答时,它不再发 FunctionCallContent,循环自然结束。agent 能自主多步靠的就是这个,不是模型有多智能,而是循环把模型的每一步串了起来。

几个需要留意的点:

  • MaximumIterationsPerRequest 是内层循环的硬上限,防止模型无限制地调工具烧 token。它对应 HarnessAgentOptions.MaximumIterationsPerRequest默认是 null,此时沿用 FunctionInvokingChatClient 自身的默认值。撞到上限会强制结束并返回当前结果。
  • 工具调用必须成对:内层循环保证"调用消息 + 结果消息"成对追加,少了任何一条,下一次调模型时 API 会直接报错(OpenAI 等服务会校验 callId 配对)。这也是上一章压缩要把它们当作不可拆分的原子组处理的原因。
  • 内层循环是同步的、原子的:它必须在一个 RunAsync 调用里跑完,中间不能让人插手。如果某个工具需要人工审批(ApprovalRequiredAIFunction),内层循环会"卡住",把审批请求返回给调用方,这时外层循环看到 pending approval 也会停下来(见后文)。
  • 流式下的额外复杂度:流式输出时工具调用是边收边解析的,ChatClientAgent.RunCoreStreamingAsyncwhile (hasUpdates) 配合 MoveNextAsync 逐个 yield update,还要处理消费者提前退出时清理 enumerator。


内层循环回答了调不调工具的问题,却回答不了任务做完没。比如提问 "规划三件事并全部完成",内层循环可能跑完一次模型就停了(它觉得这轮没工具要调),但任务其实只做了一半。这就需要外层循环出场,把整个 Agent 反复跑,直到某个评判器说认为已完成任务


外层循环

MAF 的外层循环由 LoopAgent 实现。它是个 DelegatingAIAgent 装饰器模式,包住任意一个内部 Agent,对外暴露和普通 Agent 一样的 RunAsync/RunStreamingAsync,但内部把单次 run变成了多次 run 的循环。


LoopAgent.RunCoreAsync 的核心骨架(简化)如下,它的控制流也可以对照下图:

while (true)
{
    // ① 跑一次内部 agent(这一次内部自己就会转好几圈工具调用)
    AgentResponse response = await this.InnerAgent.RunAsync(currentMessages, activeSession, ...);
    iteration++;

    // ② 更新循环上下文(iteration、LastResponse、Feedback 日志)
    context.Iteration = iteration;
    context.LastResponse = response;

    // ③ 工具审批挂起 → 立刻停,把审批请求返回给调用方
    if (HasPendingApprovalRequests(response))
        return this.BuildResult(response, transcript);

    // ④ 撞全局安全上限 → 强制停
    if (iteration >= this._maxIterations)
        return this.BuildResult(response, transcript);

    // ⑤ 问评判器:要不要再来一轮?
    LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, ...);
    if (!step.ShouldContinue)
        return this.BuildResult(response, transcript);

    // ⑥ 评判器要继续 → 把它的 feedback 当作下一轮的输入
    currentMessages = step.Messages;
}
正在渲染 Mermaid 图表...

从骨架里能看出几个关键设计:

① 调用方的原始输入只发一次。 第一轮用调用方传进来的 messages,之后每一轮用的是评判器给的 feedback(或重新构造的消息),而不是把原始问题重发一遍,避免了"每轮都问一遍同样的问题"。

② session 默认复用。 FreshContextPerIteration = false(默认)时,整个循环共用一个 session,模型能从 session 历史里接上之前的对话,适合"渐进式完善"(每轮在前一轮基础上改)。

③ session 也可每轮重置。 FreshContextPerIteration = true 时,每轮迭代开始前把 session 重置成初始快照:循环自建的 session 直接 new 一个;调用方传入的 session 会先在循环开始时序列化存档,每轮反序列化一个新 clone。

④ 三道安全阀。 全局 MaxIterations(默认 10)是不可突破的硬上限;工具审批挂起会立刻停(循环不能自动处理审批);其余情况交给评判器决定。

骨架里的第 ⑤ 步才是外层循环真正的"大脑",评判器。LoopAgent 自己并不知道"任务做完没",这个判断完全交给 LoopEvaluator


评判器:循环的大脑

LoopEvaluator 是个抽象类,契约只有一个方法:看一眼当前的 LoopContext,返回要不要再来一轮 + 带什么 feedback。

public abstract class LoopEvaluator
{
    public abstract ValueTask<LoopEvaluation> EvaluateAsync(LoopContext context, CancellationToken ct = default);
}

返回值 LoopEvaluation 有三种构造方式:

LoopEvaluation.Stop()                                  // 停,任务完成
LoopEvaluation.Continue("还差 XX 没做,继续")            // 再来一轮,带 feedback 文本
LoopEvaluation.ContinueWithMessages([msg1, msg2])      // 再来一轮,下一轮的输入我自己定(绕过 feedback 构造)


评判器是无状态的(要求"可被并发循环共享"),每次循环的临时状态都挂在 LoopContext 上。LoopContext 提供了评判所需的一切:

属性含义
Agent被循环的内部 agent(可用来 GetService<T> 反查内部 provider)
Session当前循环用的 session(FreshContextPerIteration 时会被替换)
InitialMessages调用方最初传入的消息(第一轮的输入)
Iteration已完成的迭代次数(从 1 开始)
LastResponse最近这一轮 agent 的响应(评判器最常看的就是它的 .Text
Feedback跨迭代累积的 feedback 日志(只读列表,每轮一条)
RunOptions本次 run 的选项
AdditionalProperties评判器之间共享状态的字典


挂多个评判器时的协作规则很值得记住:LoopAgent 可以挂多个 evaluator,按顺序求值,第一个要求继续的赢,它的 feedback 驱动下一轮,剩下的不再评估。循环停止的条件是所有评判器都说停


内置的五种评判器

SDK 在 src/Microsoft.Agents.AI/Harness/Loop/ 下提供了五种开箱即用的 evaluator,覆盖最常见的停循环策略。它们对应的,其实是五种不同的任务完成信号。

这些模式你读起来可能难以理解,你可以先简单读一次,然后在后续的实战环节里面,我们会手动实现一个 Agent,这样你就明白两层循环和五种评判器的用法和原理了。


后面的案例都会介绍到 LoopAgent,涉及到 LoopAgentOptions` 控制循环的整体行为,这里先提前介绍。

属性默认作用
MaxIterations10DefaultMaxIterations全局硬上限,evaluator 突破不了
FreshContextPerIterationfalse每轮是否重置 session
OnBehalfOfAuthorNamenull循环代发的 feedback 消息打上什么作者名
ExcludeOnBehalfOfMessagesfalse是否把循环代发的消息从返回结果里剔除
NonStreamingReturnsLastResponseOnlyfalse非流式返回是聚合全部迭代,还是只返回最后一轮
SessionCreatedCallbacknull循环每次新建 session 时的回调


这里需要解释一下"循环代发的消息":当评判器说"继续 + feedback",循环会替调用方合成一条 user 消息发给内部 agent。这条消息不是真实用户发的,是循环自己造的。默认情况下它会出现在返回结果里(流式里作为一个 user turn 暴露),让调用方看到"循环在替我驱动"。两个相关选项就是控制它的:

  • OnBehalfOfAuthorName = "loop":给这些代发消息标个作者名,下游好区分"用户说的"和"循环替用户说的"。
  • ExcludeOnBehalfOfMessages = true:返回结果里只看 agent 的回复,不暴露循环的代发消息(但消息照发)。

CompletionMarkerLoopEvaluator 约定一个完成标记

最简单直接:约定一个完成标记字符串(比如 <promise>COMPLETE</promise>),agent 产出里出现这个标记就停,否则继续并反馈做完了记得带这个标记。它不需要任何额外的 LLM 调用,零成本、可靠性高(本质就是一个字符串包含判断)。适合任务有明确终点信号的场景。

new CompletionMarkerLoopEvaluator("<promise>COMPLETE</promise>")

它的 feedback 模板支持两个占位符:{completion_marker}(替换成标记)和 {last_response}(替换成上一轮回复)。后者在 FreshContextPerIteration = true 时特别有用,因为每轮 session 重置后 agent 看不到自己上轮说了什么,用 {last_response} 把上一轮产出回显给它。


例如你需要反复打磨同一个产出,直到 Agent 自己发出完成标记。配 FreshContextPerIteration,每轮独立重做、累积 feedback。然后使用 LoopAgent 判断当前轮输入里面有没有附带这个标签,然后没有,继续下一轮对话。

其原理是在提示词中让 AI 模型在输出的末尾附加 <promise>COMPLETE</promise> 标识,适合文案、命名、方案设计这类没有客观终点、靠反复打磨的任务。


using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

IChatClient chatClient = new OpenAIClient(
        credential: new ApiKeyCredential("1234"),
        options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
    .GetChatClient("qwen/qwen3.5-9b")
    .AsIChatClient();

AIAgent harnessAgent = chatClient.AsAIAgent(
    name: "ralph",
    instructions: """
        你在为一个笔记应用迭代打磨产品名。每一轮都基于反馈给出一个更好的候选名 + 简短理由。
        当你确信名字已经定稿,在回复结尾加上标记 <promise>COMPLETE</promise>。
        """);

// CompletionMarkerLoopEvaluator:标记出现就停,否则继续。
AIAgent loopAgent = new LoopAgent(
    harnessAgent,
    new CompletionMarkerLoopEvaluator("<promise>COMPLETE</promise>", options: new()
    {
        FeedbackMessageTemplate =
            "你上一版的建议是:\n" + CompletionMarkerLoopEvaluator.LastResponsePlaceholder +
            "\n\n继续打磨,满意了就回复 " + CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder + "。",
    }),
    new LoopAgentOptions { MaxIterations = 5, FreshContextPerIteration = true });

// ResponseId 变化 = 外层循环又重新跑了一次内层 agent,用来分隔每一轮。
string? currentResponseId = null;
ChatRole? currentRole = null;
var runCount = 0;
var allUpdates = new List<AgentResponseUpdate>();

bool reasoningStarted = false;
bool textStarted = false;

await foreach (AgentResponseUpdate update in loopAgent.RunStreamingAsync("给这个笔记应用起个名字。"))
{
    // ResponseId 变化 = 外层循环又重新跑了一次内层 agent
    if (update.ResponseId is { } id && id != currentResponseId)
    {
        currentResponseId = id;
        currentRole = null;
        reasoningStarted = false;
        textStarted = false;
        Console.WriteLine($"\n--- 第 {++runCount} 轮 ---");
    }

    // 角色变化时打印前缀
    if (update.Role is { } role && role != currentRole)
    {
        currentRole = role;
        var prefix = role == ChatRole.User ? "User" : role == ChatRole.Assistant ? "Agent" : role.Value;
        Console.Write($"\n{prefix}: ");
    }

    // 思考过程:逐块实时打印
    foreach (var reasoning in update.Contents.OfType<TextReasoningContent>())
    {
        if (!reasoningStarted)
        {
            Console.Write("\n[思考] ");
            reasoningStarted = true;
        }

        Console.Write(reasoning.Text);
    }

    // 正文:逐块实时打印。正文一来,就在思考块和正文块之间插个分隔。
    if (!string.IsNullOrEmpty(update.Text))
    {
        if (!textStarted && reasoningStarted)
        {
            Console.Write("\n[正文] ");
        }

        textStarted = true;
        Console.Write(update.Text);
    }

    allUpdates.Add(update);
}

Console.WriteLine();
Console.WriteLine($"\n最终回复:\n{allUpdates.ToAgentResponse().Text}");

image-20260813100548955


DelegateLoopEvaluator 交给一个回调

最灵活,传一个 Func<LoopContext, CancellationToken, ValueTask<LoopEvaluation>>,判断逻辑全交给你。适合停循环条件很业务化的场景。

也就是说,判断停止的条件完全交给开发者。比如 "响应里包含 deployed 字样就停"、"调用了某个特定工具就停"

new DelegateLoopEvaluator((context, ct) =>
{
    bool done = context.LastResponse.Text.Contains("deployed", StringComparison.OrdinalIgnoreCase);
    return new ValueTask<LoopEvaluation>(done ? LoopEvaluation.Stop() : LoopEvaluation.Continue());
})


它本身不绑定任何状态,是一个万能出口,下面的 TodoCompletionLoopEvaluator 其实就可以用 DelegateLoopEvaluator 手写出来。

让 Agent 自己把任务拆成 todo,循环到全部打勾为止。不要FreshContextPerIteration,因为 todo 状态要留在 session 里。

适合 规划→分步执行→汇总 的多步任务。

using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

#pragma warning disable MAAI001
#pragma warning disable OPENAI001


IChatClient chatClient = new OpenAIClient(
        credential: new ApiKeyCredential("1234"),
        options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
    .GetChatClient("qwen/qwen3.5-9b")
    .AsIChatClient();

AIAgent harnessAgent = chatClient.AsAIAgent(
    new ChatClientAgentOptions
    {
        Name = "ralph",
        ChatOptions = new ChatOptions
        {
            Instructions = """
                你是一个规划助手。先用 todo 工具把任务拆成若干待办项;
                每一轮推进一项,完成的及时标记为 done;全部完成后给出总结。
                """,
        },
        AIContextProviders = [new TodoProvider()],  // 注入 
    });

// 判断逻辑全交给一个委托:通过 GetService 拿到 TodoProvider,
AIAgent loopAgent = new LoopAgent(
    harnessAgent,
    new DelegateLoopEvaluator(async (context, ct) =>
    {
        var todoProvider = context.Agent.GetService<TodoProvider>()
            ?? throw new InvalidOperationException("agent 上没有挂 TodoProvider。");
        var remaining = await todoProvider.GetRemainingTodosAsync(context.Session, ct);
        return remaining.Count > 0
            ? LoopEvaluation.Continue($"还有 {remaining.Count} 项未完成,继续。")
            : LoopEvaluation.Stop();
    }),
    new LoopAgentOptions { MaxIterations = 6 });

// ResponseId 变化 = 外层循环又重新跑了一次内层 agent,用来分隔每一轮。
string? currentResponseId = null;
ChatRole? currentRole = null;
var runCount = 0;
var allUpdates = new List<AgentResponseUpdate>();

bool reasoningStarted = false;
bool textStarted = false;

await foreach (AgentResponseUpdate update in loopAgent.RunStreamingAsync("规划并写出一篇关于'等比数列'的 3 段式博客大纲。"))
{
    // ResponseId 变化 = 外层循环又重新跑了一次内层 agent
    if (update.ResponseId is { } id && id != currentResponseId)
    {
        currentResponseId = id;
        currentRole = null;
        reasoningStarted = false;
        textStarted = false;
        Console.WriteLine($"\n--- 第 {++runCount} 轮 ---");
    }

    if (update.Role is { } role && role != currentRole)
    {
        currentRole = role;
        var prefix = role == ChatRole.User ? "User" : role == ChatRole.Assistant ? "Agent" : role.Value;
        Console.Write($"\n{prefix}: ");
    }

    // 思考过程:逐块实时打印
    foreach (var reasoning in update.Contents.OfType<TextReasoningContent>())
    {
        if (!reasoningStarted)
        {
            Console.Write("\n[思考] ");
            reasoningStarted = true;
        }

        Console.Write(reasoning.Text);
    }

    // 正文:逐块实时打印。正文一来,就在思考块和正文块之间插个分隔。
    if (!string.IsNullOrEmpty(update.Text))
    {
        if (!textStarted && reasoningStarted)
        {
            Console.Write("\n[正文] ");
        }

        textStarted = true;
        Console.Write(update.Text);
    }

    allUpdates.Add(update);
}

Console.WriteLine();
Console.WriteLine($"\n最终回复:\n{allUpdates.ToAgentResponse().Text}");


TodoProvider 会在上下文注入一下这些 tool ,todos_addtodos_completetodos_removetodos_get_remainingtodos_get_all 等。

模型调用 tool 增加或完成任务,就会调用 TodoProvider 里面的函数标记为完成,开发者可以使用GetRemainingTodosAsync()(todos_get_remaining) 获取待办任务列表,从而判断是否应该结束当前循环。

image-20260813140752789


TodoCompletionLoopEvaluator 列表清空为止

专为规划-执行"模式设计:循环 设计,TodoProvider 里没有未完成项为止。它不要求你直接把 TodoProvider 传进来,而是在运行时通过 context.Agent.GetService<TodoProvider>() 反查,只要 agent 上挂了 TodoProviderHarnessAgent 默认就挂了),evaluator 不用任何额外接线就能用。还可以配 Modes 限定只在某些 agent 模式下生效。

new TodoCompletionLoopEvaluator()   // 自动发现 agent 上的 TodoProvider


未完成时,feedback 模板会把"剩余 todo 列表"塞给 agent,让它接着做。这是"让 agent 自己拆任务、自己做完"最顺手的姿势。

上一个小节,一个通过讲解 DelegateLoopEvaluator 已经实现了一个类似 TodoCompletionLoopEvaluator 的功能,所以这里就不重复讲解 TodoCompletionLoopEvaluator 了。只需要在 DelegateLoopEvaluator 一节的代码,替换 LoopAgent 实例化即可。

AIAgent loopAgent = new LoopAgent(
    harnessAgent,
    new TodoCompletionLoopEvaluator(),
    new LoopAgentOptions { MaxIterations = 6 });

AIJudgeLoopEvaluator 让 LLM 当裁判

还有一个骚操作,就是让 AI 做裁判,也就是说 单独开一个 IChatClient 当裁判,每轮结束后问它 "原始请求被完整回答了吗" ,我们可以利用一些便宜的模型来完成这个判决过程,不过可能会比较慢,整体设计也比较沉重。

裁判返回结构化的 JudgeVerdictanswered + gapAnalysis);客户端不支持结构化输出时,回落到解析文本标记 VERDICT: DONE / VERDICT: MORE(设计上 MORE 优先,模糊时宁可多跑一轮)。

new AIJudgeLoopEvaluator(judgeChatClient)   // 建议用便宜的小模型当裁判


未完成时,feedback 模板会把裁判的 gap 分析塞进去,告诉 agent "你还差 XX"。它成本最高(每轮多一次 LLM 调用)、也最不确定(裁判可能误判),适合"完成标准难以用规则表达"的复杂任务,建议同时配一个较紧的 MaxIterations


只需要在 DelegateLoopEvaluator 一节的代码,替换 LoopAgent 实例化即可。

AIAgent loopAgent = new LoopAgent(
    harnessAgent,
    new AIJudgeLoopEvaluator(chatClient),
    new LoopAgentOptions { MaxIterations = 6 });


BackgroundTaskCompletionLoopEvaluator 等后台任务跑完

当 agent 会派生子任务在后台跑(通过 BackgroundAgentsProvider)时,用这个 evaluator 让循环一直转到所有后台任务结束。它同样通过 context.Agent.GetService<BackgroundAgentsProvider>() 自动发现 provider,只把"仍在运行中"的任务当作未完成,已完成、失败、丢失的任务都是终态,不会让循环空转。

new BackgroundTaskCompletionLoopEvaluator()   // 自动发现 agent 上的 BackgroundAgentsProvider

image-20260813143523531

image-20260813143532243

using System.ClientModel;
using System.ComponentModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;

#pragma warning disable MAAI001
#pragma warning disable OPENAI001

// 一个“部署”操作:只有人工批准后才会真正执行。
[Description("部署指定的服务。需要人工审批后才会执行。")]
static string DeployService([Description("要部署的服务名称")] string serviceName)
{
    Console.WriteLine($"[工具] 正在部署服务 {serviceName} ...");
    return $"服务 {serviceName} 已成功部署。";
}

IChatClient chatClient = new OpenAIClient(
        credential: new ApiKeyCredential("1234"),
        options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
    .GetChatClient("qwen/qwen3.5-9b")
    .AsIChatClient();

// 用 ApprovalRequiredAIFunction 包裹部署工具:模型调用它时不会立即执行,
// 而是先返回一个 ToolApprovalRequestContent,等人工批准后才会真正跑 DeployService。
AIFunction deployTool = new ApprovalRequiredAIFunction(
    AIFunctionFactory.Create(DeployService, name: "DeployService"));

AIAgent agent = chatClient.AsAIAgent(
    new ChatClientAgentOptions
    {
        Name = "deploy-bot",
        ChatOptions = new ChatOptions
        {
            Instructions = """
                你是部署操作员。当用户要求部署服务时,用 DeployService 工具完成部署。
                部署完成后用一句话告诉用户结果。
                """,
            Tools = [deployTool],
        },
    });

// 审批必须复用同一个 session(待审批状态挂在 session 上),所以显式创建一个。
AgentSession session = await agent.CreateSessionAsync();

// 交互循环:
//   1) 跑一轮 agent
//   2) 打印模型回复
//   3) 如果有待审批的工具调用 → 停下来问用户 Y/N → 把结果喂回去 → 回到 1)
//   4) 没有待审批请求 → 结束
// 第一次的输入是用户需求;后续轮次的输入是审批响应列表。
string? prompt = "请帮我部署一个叫 payment 的服务。";
List<ChatMessage>? nextInput = null;

while (true)
{
    AgentResponse response = nextInput is null
        ? await agent.RunAsync(prompt!, session)
        : await agent.RunAsync(nextInput, session);

    nextInput = null;

    // 打印模型的文本回复
    string reply = string.Concat(response.Messages
        .SelectMany(m => m.Contents)
        .OfType<TextContent>()
        .Select(t => t.Text));
    if (!string.IsNullOrWhiteSpace(reply))
    {
        Console.WriteLine($"Agent: {reply}");
    }

    // 收集这一轮里所有“等待审批”的工具调用
    List<ToolApprovalRequestContent> approvalRequests =
        response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();

    if (approvalRequests.Count == 0)
    {
        Console.WriteLine("\n(没有待审批的操作,流程结束)");
        break;
    }

    // 逐个审批:控制台问 Y/N,构造 ToolApprovalResponseContent
    nextInput = [];
    foreach (ToolApprovalRequestContent req in approvalRequests)
    {
        if (req.ToolCall is not FunctionCallContent call)
        {
            continue;
        }
        call.Arguments.TryGetValue("serviceName", out var svcArg);
        Console.WriteLine($"\n[审批请求] 工具={call.Name},服务={svcArg}");
        Console.Write("是否批准执行?(Y/N): ");
        string? answer = Console.ReadLine();
        bool approved = string.Equals(answer?.Trim(), "Y", StringComparison.OrdinalIgnoreCase);
        Console.WriteLine(approved ? "-> 已批准,继续执行" : "-> 已拒绝");
        nextInput.Add(new ChatMessage(ChatRole.User, [req.CreateResponse(approved)]));
    }
    // 回到循环顶部:带着审批结果再跑一轮(批准→执行工具,拒绝→告知模型被拒)
}


feedback 如何变成下一轮的输入

这部分逻辑在 LoopAgent.BuildNextMessages 方法里,分两种模式。

复用 session 模式(FreshContextPerIteration = false,默认)

session 里已经有完整历史,每轮只需要发最新一条 feedback

string? latest = feedback[feedback.Count - 1];   // 只取最后一条
if (!string.IsNullOrWhiteSpace(latest))
    messages.Add(new ChatMessage(ChatRole.User, latest));   // 发给 agent

Agent 从 session 历史里能看到前面所有轮次,这条 feedback 就是 "再往前推一步" 的指令。


fresh context 模式(FreshContextPerIteration = true

每轮 session 重置后历史没了,必须把原始任务 + 累积的全部 feedback 重新发一遍:

messages.AddRange(context.InitialMessages);                 // 原始任务
ChatMessage? feedbackMessage = BuildAggregatedFeedbackMessage(feedback);
if (feedbackMessage is not null)
    messages.Add(feedbackMessage);                          // 所有 feedback 拼成一条


BuildAggregatedFeedbackMessage 把历史 feedback 拼成 ## Feedback\n- 条1\n- 条2... 的格式。这样 agent 每轮都能看到 "原始任务 + 截至目前所有的改进意见",独立但连贯地重新作答。

正在渲染 Mermaid 图表...

第三种:evaluator 自己指定消息

如果评判器用的是 ContinueWithMessages,它就完全接管了下一轮的输入,绕过上面的 feedback 构造逻辑。适合需要发非 user 角色、多条消息、或非文本内容的场景。注意 session 该不该重置还是照常(如果开了 fresh 就重置),只是消息内容由 evaluator 决定。