猪头少年 - 云南AI专家 - 云南独立开发者

Engineering note

先修复 Agent 循环:从 Demo 走向可控内核

这是「Agent 工程实战」的第 8 篇。专题从一个能聊天、能调工具的 .NET Agent 出发,逐步补齐可靠性、安全、测试与发布能力。

本篇要解决的问题: 用明确的终止条件、最大步骤、取消和重复检测,让工具循环不再失控。

返回专题路线


本章定位:本章是从 Demo 走向内核的转折点。你会把当前混在 ChatStreamAsync 里的循环拆成明确的 Agent Loop,并为结束、失败、取消和重复调用建立状态。

建议阅读方式:先通读原理,再对照当前项目源码,最后完成本章实践。如果只准备实现一个工程化改造,请优先完成本章,因为后面所有能力都依赖可靠的循环。

本章导读

本章是从 Demo 走向内核的转折点。你会把当前混在 ChatStreamAsync 里的循环拆成明确的 Agent Loop,并为结束、失败、取消和重复调用建立状态。

本章采用“源码观察 → 概念拆解 → 工程改造 → 实践验证”的顺序。示例中的接口和代码骨架用于说明设计方向,真正提交代码时应结合项目当前状态逐步落地。

这是后续开发最值得优先完成的一章,因为循环错误会影响所有功能。

8.1 正常结束条件

当前代码检查:

if (choices[0].TryGetProperty("stop", out var stopElement))
{
    break;
}

主流 chat completion 协议通常把结束原因放在 finish_reason 中,值可能是 stoptool_callslength 或其他 provider-specific 值。应统一读取该字段,并在收到最终回答后结束本轮。

建议把一轮对话写成明确的状态机:

for (var step = 0; step < options.MaxToolSteps; step++)
{
    var response = await modelClient.CreateResponseAsync(context, cancellationToken);

    if (response.ToolCalls.Count == 0)
        return response.Text;

    foreach (var call in response.ToolCalls)
        context.Add(await toolExecutor.ExecuteAsync(call, cancellationToken));
}

return "工具调用次数超过本轮上限。";

8.2 终止条件清单

一轮 Agent 调用至少应该在以下情况终止:

  • 模型给出最终文本;
  • 达到最大工具步骤数;
  • 用户取消;
  • 请求超时;
  • 认证失败或服务不可用;
  • 工具连续失败达到上限;
  • 检测到无效或重复调用。

8.3 防止重复调用

同一个工具、同一组参数在短时间重复出现时,可能表示模型陷入循环。可以对调用做指纹:

var fingerprint = $"{call.Name}:{call.Arguments.GetRawText()}";
if (!seen.Add(fingerprint))
    return ToolResult.Failure("DUPLICATE_CALL", "检测到重复工具调用");

8.4 本章交付物

  • AgentLoopOptions:最大步骤数、超时时间、最大响应长度;
  • AgentTurnResult:最终文本、状态、工具调用次数、错误信息;
  • 一个单元测试:模型返回 stop 后只发生一次模型请求;
  • 一个单元测试:模型持续要求工具时能在上限处停止。

8.5 为什么要把循环从模型客户端拆出来

模型客户端的职责是“发请求并解析响应”,Agent Loop 的职责是“根据响应决定下一步”。当前两者都放在 ChatStreamAsync() 里,导致一个方法同时理解 HTTP、SSE、消息历史、工具反射和终止条件。

这会制造一种很难排查的问题:如果最终回答重复、工具调用不停止,到底是网络响应解析错了,还是循环策略错了?拆开之后,每一层可以独立回答自己的问题。

推荐的调用关系是:

AgentLoop
  -> IModelClient.CreateResponseAsync
  -> IToolExecutor.ExecuteAsync
  -> Session.Append

模型客户端不应该知道工具实例,工具执行器也不应该知道 SSE 字节流。

8.6 一个更完整的循环骨架

public async Task<AgentTurnResult> RunAsync(
    AgentSession session,
    string userMessage,
    CancellationToken cancellationToken)
{
    session.AddUserMessage(userMessage);

    for (var step = 1; step <= _options.MaxToolSteps; step++)
    {
        var response = await _model.CreateAsync(
            session.Messages, _tools.Definitions, cancellationToken);

        session.AddAssistant(response);

        if (response.ToolCalls.Count == 0)
            return AgentTurnResult.Completed(response.Text, step);

        foreach (var call in response.ToolCalls)
        {
            var result = await _executor.ExecuteAsync(call, cancellationToken);
            session.AddToolResult(call.Id, result);
        }
    }

    return AgentTurnResult.LimitReached(_options.MaxToolSteps);
}

这里的关键不在代码长度,而在每一步都留下了可观察状态:模型请求、assistant 消息、工具结果和终止原因。

8.7 处理 finish_reason = length

模型可能因为输出达到长度上限而结束。不能把这种结束当成完整回答,也不能无条件重新请求。可以返回一个状态,让上层决定是否允许“继续生成”:

public enum CompletionStatus
{
    Completed,
    ToolCallsRequested,
    LengthLimitReached,
    Failed,
    Cancelled
}

继续生成前,要考虑上下文是否已经接近窗口上限,以及用户是否愿意承担额外成本。

8.8 本章练习:制造三个循环故障

使用 fake model 模拟:

  1. 先返回工具调用,再返回最终文本;
  2. 永远返回同一工具调用;
  3. 返回 finish_reason = length

分别断言 Agent 的状态、请求次数和最终提示。能稳定复现故障,才算真正掌握了循环。

8.9 本章产出

完成本章后,Agent Loop 应成为项目中最容易测试的核心组件,并且任何“继续、结束、失败、取消”的行为都有明确状态。

单篇实战作业

实践:用 fake model 驱动“最终回答、一次工具、无限工具、长度结束”四条路径,记录每条路径的终止状态。

建议把作业拆成一个独立提交,并在提交说明中写清楚:改动前的行为、改动后的行为、验证命令、尚未解决的风险。如果只准备实现一个工程化改造,请优先完成本章,因为后面所有能力都依赖可靠的循环。

章节复盘

复盘问题:你的循环是否能在每一种终止原因下留下完整的会话状态和用户可读解释?

本章的完成标准不是把所有设计一次性做完,而是能把它变成项目中的一个明确边界,并为下一章留下可验证的接口。


下一篇:第 9 篇《配置管理与模型提供商:把密钥和模型选择移出代码》

如果你正在把 Agent 放进真实工作流,建议完成本篇的实战作业后再继续:每一步都应留下可验证的代码、测试或运行记录。

Next step

不要停在单篇文章。

沿主专题继续阅读,查看同一问题从概念到实战的完整路径。

返回「Agent 工程实战」路线