【打造能干活的Agent】构建带步数上限的Agent执行循环

本文介绍如何为AI智能体(Agent)设计带步数上限的执行循环,防止模型无限调用工具导致任务无法终止。通过销售CSV分析助手示例,实现三类终止场景:正常回答、工具报错和步数耗尽失败。重点包括循环预算控制、工具白名单分发、结构化工具结果回传,并强调宿主程序而非提示词承担上限责任。内容覆盖验收标准、边界验证及生产级扩展要点,如单轮工具数限制、超时控制与状态持久化准备。

构建带步数上限的Agent执行循环

课程归属:打造能干活的Agent

当 Agent 可以调用 Python 工具后,一个新的问题随之出现:如果模型连续要求调用工具,却始终不给出最终回答,程序会不会一直运行?实用的 Agent 不能只会执行,还必须有明确的停止条件。本文用一个销售 CSV 分析助手实现带上限的执行循环,展示正常结束、工具报错和达到上限三种结果,并通过可检查的数据验证行为。

一、先定义执行循环的验收标准

本节只解决一个问题:如何限制 Agent 连续请求模型和调用工具的轮数,并在无法正常结束时明确报告失败。

案例读取教学模拟的销售数据,工具计算各类别销售额,模型再根据工具结果组织回答。工具仍然是普通 Python 函数,不引入 LangGraph,也不提前加入数据库、网页界面或文件写入能力。

表 1:本节输入、行为与交付结果

项目约定验收方式
用户输入例如“哪个类别销售额最高?”程序接收非空问题
工具analyze_csv(relative_path)返回统一的逻辑结果对象
步数上限MAX_STEPS = 4最多进行 4 轮模型决策
正常结束模型返回文本且无需继续调用工具status 为 completed
异常结束模型请求失败、或耗尽步数仍未得到最终文本status 为 failed,并有 warnings

这里的“一步”指一轮模型请求及其返回的工具调用处理,不是 Python 函数内部的一行代码,也不是工具调用总数。一次模型响应可能包含多个工具调用,因此生产系统还应额外限制单轮工具数量;本示例只暴露一个只读分析工具,重点演示轮数上限。

二、理解为什么需要上限

模型的工具调用不是 Python 函数自己触发的。模型先返回“调用哪个工具、传入什么参数”,宿主程序校验并执行,再把结果交回模型。模型可能继续请求工具,也可能给出最终回答。如果每次都无条件继续,就可能形成循环。

图 1:Agent 的基本执行闭环

否

是

是

否

接收用户问题

请求模型决策

是否请求工具?

返回最终回答

是否还有步数?

校验参数并执行工具

把工具结果交回模型

返回步数上限错误

观察图中的两个出口:模型返回普通文本时,任务可以正常结束;循环预算耗尽时,程序必须停止并报告失败,不能把“已经执行过工具”误当成“任务已经完成”。

表 2:三类停止情况的差别

情况判断依据对外结果
正常完成模型响应没有新的工具调用completed,包含 summary.answer
工具返回错误工具结果中 status 为 error把错误结果交回模型;是否能结束取决于后续响应
达到上限本轮仍有工具调用,但轮数预算已耗尽failed,警告说明上限值

工具的 status: ok/error 描述的是单次工具执行;任务的 status: completed/failed 描述的是整个 Agent 运行。两者不能混用。比如工具读取成功,但模型一直继续请求工具,任务仍然可能因步数耗尽而失败。

三、准备项目与教学数据

在 WSL2 Ubuntu 终端执行以下命令。示例使用 Python 3.11,并通过 OpenAI Python SDK 调用阿里云百炼的 OpenAI 兼容接口。接口地址、密钥和模型名必须使用自己账号所在地域与控制台提供的值,不能直接照抄其他地域的地址。

mkdir -p agent_lab/data/input
cd agent_lab
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install "openai>=1.0.0" "python-dotenv>=1.0.0"

表 3:项目文件用途

文件用途
agent.py工具实现、模型请求、循环上限和结果封装
data/input/sales.csv教学模拟的销售数据
.env.example环境变量配置模板
.env本地真实配置,不应提交到代码仓库

创建 data/input/sales.csv:

category,quantity,unit_price
键盘,12,199
鼠标,20,79
显示器,5,899
键盘,3,199
鼠标,8,79

创建 .env.example:

DASHSCOPE_API_KEY=replace-with-your-api-key
MODEL_BASE_URL=https://YOUR_WORKSPACE_ID.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
MODEL_NAME=replace-with-a-model-supported-by-your-region

将模板复制为 .env,再替换为控制台实际值。若使用北京地域的百炼服务,按该地域文档填写兼容接口地址;其他地域的工作空间 ID、API Key 和 endpoint 可能不同。模型名称也必须是账号当前有权限调用、并支持所需工具调用格式的模型。

cp .env.example .env

不要把真实密钥写入 Python 文件,也不要将 .env 提交到公开仓库。官方入口见文末“参考资料”。

四、实现带上限的 Agent 循环

将下面完整代码保存为 agent.py。它包含四部分:受限 CSV 工具、工具白名单分发、模型工具调用循环、统一任务结果。示例只允许读取 data/input/ 目录下的文件,并检查必需列、数值和路径。

import csv
import json
import os
import uuid
from pathlib import Path
from typing import Any

from dotenv import load_dotenv
from openai import OpenAI

ROOT = Path(__file__).resolve().parent
MAX_STEPS = 4


def analyze_csv(relative_path: str) -> dict[str, Any]:
    """分析 data/input/ 下的教学 CSV。"""
    rel = Path(relative_path)
    if rel.is_absolute() or ".." in rel.parts:
        return {
            "status": "error", "data": None, "source": [],
            "error": {"code": "PATH_NOT_ALLOWED",
                      "message": "只允许读取 data/input/ 下的相对路径"},
        }

    allowed = (ROOT / "data" / "input").resolve()
    path = (allowed / rel).resolve()
    if not path.is_relative_to(allowed):
        return {
            "status": "error", "data": None, "source": [],
            "error": {"code": "PATH_NOT_ALLOWED",
                      "message": "文件路径超出教学数据目录"},
        }

    source = [str(path.relative_to(ROOT))]
    if not path.is_file():
        return {
            "status": "error", "data": None, "source": source,
            "error": {"code": "FILE_NOT_FOUND", "message": "找不到指定 CSV 文件"},
        }

    try:
        with path.open("r", encoding="utf-8-sig", newline="") as f:
            reader = csv.DictReader(f)
            required = {"category", "quantity", "unit_price"}
            if not reader.fieldnames or not required.issubset(reader.fieldnames):
                raise ValueError("CSV 必须包含 category、quantity、unit_price 列")

            totals: dict[str, int] = {}
            row_count = 0
            for line_no, row in enumerate(reader, start=2):
                try:
                    category = (row["category"] or "").strip()
                    quantity = int(row["quantity"])
                    unit_price = int(row["unit_price"])
                    if not category or quantity < 0 or unit_price < 0:
                        raise ValueError
                except (TypeError, ValueError):
                    return {
                        "status": "error", "data": None, "source": source,
                        "error": {
                            "code": "INVALID_ROW",
                            "message": f"第 {line_no} 行字段无效:类别不能为空,数量和单价须为非负整数",
                        },
                    }
                totals[category] = totals.get(category, 0) + quantity * unit_price
                row_count += 1

        if row_count == 0:
            return {
                "status": "error", "data": None, "source": source,
                "error": {"code": "EMPTY_CSV", "message": "CSV 没有数据行"},
            }

        ranked = sorted(totals.items(), key=lambda item: (-item[1], item[0]))
        data = {
            "row_count": row_count,
            "revenue_by_category": [
                {"category": name, "revenue": value}
                for name, value in ranked
            ],
            "total_revenue": sum(totals.values()),
            "top_category": ranked[0][0],
            "top_revenue": ranked[0][1],
        }
        return {"status": "ok", "data": data, "source": source, "error": None}
    except (OSError, UnicodeError, ValueError) as exc:
        return {
            "status": "error", "data": None, "source": source,
            "error": {"code": "INVALID_CSV", "message": str(exc)},
        }


TOOLS = [{
    "type": "function",
    "function": {
        "name": "analyze_csv",
        "description": (
            "读取 data/input/ 下的教学销售 CSV,计算各类别销售额、"
            "总销售额和销售额最高的类别。"
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "relative_path": {
                    "type": "string",
                    "description": "相对于 data/input/ 的文件名,例如 sales.csv",
                }
            },
            "required": ["relative_path"],
            "additionalProperties": False,
        },
    },
}]


def execute_tool(name: str, arguments_json: str) -> dict[str, Any]:
    """只执行白名单中的工具,并校验模型给出的参数。"""
    if name != "analyze_csv":
        return {
            "status": "error", "data": None, "source": [],
            "error": {"code": "UNKNOWN_TOOL", "message": f"不允许调用工具:{name}"},
        }
    try:
        arguments = json.loads(arguments_json)
        if (
            not isinstance(arguments, dict)
            or set(arguments) != {"relative_path"}
            or not isinstance(arguments["relative_path"], str)
        ):
            raise ValueError("参数必须是仅含 relative_path 字符串的对象")
    except (json.JSONDecodeError, ValueError, TypeError) as exc:
        return {
            "status": "error", "data": None, "source": [],
            "error": {"code": "INVALID_ARGUMENTS", "message": str(exc)},
        }
    return analyze_csv(arguments["relative_path"])


def run_task(task_input: str, task_id: str | None = None) -> dict[str, Any]:
    """最多进行 MAX_STEPS 轮模型决策;耗尽预算时明确失败。"""
    task_id = task_id or str(uuid.uuid4())
    load_dotenv(ROOT / ".env")
    api_key = os.getenv("DASHSCOPE_API_KEY")
    base_url = os.getenv("MODEL_BASE_URL")
    model = os.getenv("MODEL_NAME")

    if not task_input.strip():
        return {
            "task_id": task_id, "status": "failed", "report_path": None,
            "summary": {}, "warnings": ["输入问题不能为空"],
        }
    if not api_key or not base_url or not model:
        return {
            "task_id": task_id, "status": "failed", "report_path": None,
            "summary": {}, "warnings": [
                "请在 .env 中配置 DASHSCOPE_API_KEY、MODEL_BASE_URL、MODEL_NAME"
            ],
        }

    client = OpenAI(
        api_key=api_key, base_url=base_url, timeout=45.0, max_retries=1
    )
    messages = [
        {
            "role": "system",
            "content": (
                "你是资料分析助手。回答 CSV 数值问题时必须调用 analyze_csv,"
                "并且只能依据工具返回的数据作答。工具失败时要说明失败,"
                "不得猜测数值。结果足够时直接用中文给出最终回答。"
            ),
        },
        {"role": "user", "content": task_input},
    ]
    tool_calls_used = 0
    tool_results: list[dict[str, Any]] = []

    for step in range(1, MAX_STEPS + 1):
        try:
            response = client.chat.completions.create(
                model=model,
                messages=messages,
                tools=TOOLS,
                tool_choice="auto",
            )
        except Exception as exc:
            return {
                "task_id": task_id, "status": "failed", "report_path": None,
                "summary": {
                    "tool_calls_used": tool_calls_used,
                    "tool_results": tool_results,
                },
                "warnings": [f"模型请求失败:{type(exc).__name__}: {exc}"],
            }

        message = response.choices[0].message
        messages.append(message.model_dump(exclude_none=True))

        if not message.tool_calls:
            return {
                "task_id": task_id, "status": "completed", "report_path": None,
                "summary": {
                    "answer": message.content or "模型未返回文本回答。",
                    "tool_calls_used": tool_calls_used,
                    "tool_results": tool_results,
                },
                "warnings": [],
            }

        for call in message.tool_calls:
            tool_calls_used += 1
            result = execute_tool(call.function.name, call.function.arguments)
            tool_results.append({"tool": call.function.name, "result": result})
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": json.dumps(result, ensure_ascii=False),
            })

    return {
        "task_id": task_id, "status": "failed", "report_path": None,
        "summary": {
            "tool_calls_used": tool_calls_used,
            "tool_results": tool_results,
        },
        "warnings": [
            f"达到步数上限 MAX_STEPS={MAX_STEPS},仍未收到最终文本回答"
        ],
    }


if __name__ == "__main__":
    question = input("请输入资料分析问题:").strip()
    result = run_task(question)
    print(json.dumps(result, ensure_ascii=False, indent=2))

4.1 循环中最重要的三处控制

第一,for step in range(1, MAX_STEPS + 1) 是硬性预算。每次进入循环最多发起一轮模型请求,达到循环末尾后函数就会返回,不会无条件继续。

第二,收到工具调用后,程序不直接把模型提供的函数名当作任意 Python 入口,而是交给 execute_tool() 白名单分发;参数必须是符合约定的 JSON 对象。文件路径还会在工具内部再次检查,避免只依赖模型遵守提示词。

第三,工具结果必须带着对应的 tool_call_id 以 role: "tool" 消息返回。模型需要看到结果,才能继续推理或组织答案。只在 Python 中执行工具、却不把结果放回消息历史,闭环就没有完成。

图 2:一轮工具调用的消息顺序

CSV工具 模型 Python宿主 用户 CSV工具 模型 Python宿主 用户 提交分析问题 问题与工具定义 工具名称与参数 校验后调用函数 结构化工具结果 tool_call_id对应的结果 最终文本或下一次工具调用 任务结果

这张时序图说明,模型只是提出调用请求;真正执行本地代码的是 Python 宿主。上限也必须由宿主控制,不能只在系统提示词里要求模型“不要循环”。

五、运行并验证正常结果

确认 .env 配置有效后,在项目根目录执行:

python agent.py

输入:

分析 data/input/sales.csv,告诉我销售额最高的类别、各类别销售额和总销售额。

根据教学 CSV 逐行计算,预期数值如下:

表 4:教学数据的人工核算结果

类别计算方式销售额
键盘(12 + 3) × 1992985
鼠标(20 + 8) × 792212
显示器5 × 8994495
合计2985 + 2212 + 44959692

这些数值是根据示例 CSV 人工核算的预期结果,不是模型 API 实测输出。真实运行时,返回对象应包含 task_id、status、report_path、summary 和 warnings。正常结束时,status 应为 completed,summary.answer 应基于工具返回的数据说明显示器销售额最高、为 4495,总销售额为 9692;措辞可以不同,但关键数值必须一致。

当前章节没有生成报告文件,因此 report_path 为 null 是合理的。completed 只表示本次运行拿到了最终文本,不表示后续章节才会实现的 report.md 已经生成。

六、验证上限与失败边界

只看一次正常回答,无法证明步数限制有效。还要检查工具报错和循环预算耗尽时的行为。

表 5:边界用例与判断依据

用例操作预期判断
正常 CSV使用 sales.csv工具 status 为 ok,总额为 9692
文件不存在询问工具分析 missing.csv工具返回 FILE_NOT_FOUND,模型应说明无法读取
非法数值把某行 quantity 改成 abc工具返回 INVALID_ROW,不应生成伪造统计值
越界路径传入 ../.env工具返回 PATH_NOT_ALLOWED
循环未结束模型连续返回工具调用直到用完预算任务 status 为 failed,警告包含上限值

如果要在不消耗模型额度的情况下验证上限逻辑,可以把模型客户端替换成测试桩:让每次 chat.completions.create() 都返回一个格式正确的工具调用,并统计调用次数。断言模型请求次数不会超过 MAX_STEPS,最终状态为 failed。这是对循环控制的单元测试,不等于真实模型服务联调。

图 3:排错时从外到内的检查顺序

否

是

否

是

error

ok

是

否

运行结果不符合预期

模型请求成功?

检查密钥、地域地址、模型权限与网络

工具是否被调用?

检查问题描述与工具定义

工具结果 status

检查路径、CSV列和数据值

是否得到最终文本?

核对回答与工具数据

检查步数预算和循环终止分支

排错时先区分模型请求错误与工具逻辑错误。若工具结果已经正确返回,但运行仍然失败,重点检查循环是否耗尽预算;不要为了让它“看起来成功”就直接把失败状态改成 completed。

七、将演示循环用于真实任务前的边界

固定步数能限制模型决策轮数,但不是完整的生产级资源治理机制。

  • 限制单轮工具数量。 一次模型响应可能包含多个 tool_calls;如工具数量或成本敏感,应为单轮调用数设上限,并在超限时返回明确错误。
  • 限制工具运行时间。 模型请求设置了超时,不代表本地工具自动获得超时保护。耗时工具需要单独的超时、取消和资源隔离策略。
  • 控制重试。 网络 SDK 的自动重试与 Agent 决策轮数是两回事。本文设置 max_retries=1,实际请求次数仍可能高于循环轮数;若要严格控制总请求量,应把重试预算也纳入统计。
  • 保存可追踪信息。 当前结果保留工具调用次数、工具结果和警告。后续章节再将任务进度与步骤写入 SQLite,并引入日志、恢复和幂等处理。
  • 谨慎处理外部写入。 本篇工具只读 CSV。发送邮件、写数据库或修改文件等副作用操作,需要独立的权限、确认和重复执行控制,不能仅靠步数上限保证安全。

最终验收的关键不是模型说“完成了”,而是程序能够在预算内结束、返回结构明确的任务结果,并在耗尽预算时诚实报告失败。下一节会继续把单次循环扩展为带步骤拆解与验证的执行过程;引入 LangGraph 时,图的递归/步骤限制仍需单独配置,不能认为它会自动替代业务层的预算设计。

参考资料

  1. 阿里云百炼:通过 OpenAI 兼容接口调用模型——核对 API 地址、SDK 调用方式与地域差异。
  2. 阿里云百炼:获取 API Key——配置访问密钥前的官方入口。
  3. 阿里云百炼:文本生成文档——了解模型接口及参数说明。
  4. LangGraph:GRAPH_RECURSION_LIMIT 故障说明——理解后续图编排中循环未终止与步数上限的关系。
  5. LangGraph:Graph API 与 recursion limit——后续引入图执行时参考,不是本篇代码的运行依赖。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值