构建带步数上限的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:一轮工具调用的消息顺序
这张时序图说明,模型只是提出调用请求;真正执行本地代码的是 Python 宿主。上限也必须由宿主控制,不能只在系统提示词里要求模型“不要循环”。
五、运行并验证正常结果
确认 .env 配置有效后,在项目根目录执行:
python agent.py
输入:
分析 data/input/sales.csv,告诉我销售额最高的类别、各类别销售额和总销售额。
根据教学 CSV 逐行计算,预期数值如下:
表 4:教学数据的人工核算结果
| 类别 | 计算方式 | 销售额 |
|---|---|---|
| 键盘 | (12 + 3) × 199 | 2985 |
| 鼠标 | (20 + 8) × 79 | 2212 |
| 显示器 | 5 × 899 | 4495 |
| 合计 | 2985 + 2212 + 4495 | 9692 |
这些数值是根据示例 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:排错时从外到内的检查顺序
排错时先区分模型请求错误与工具逻辑错误。若工具结果已经正确返回,但运行仍然失败,重点检查循环是否耗尽预算;不要为了让它“看起来成功”就直接把失败状态改成 completed。
七、将演示循环用于真实任务前的边界
固定步数能限制模型决策轮数,但不是完整的生产级资源治理机制。
- 限制单轮工具数量。 一次模型响应可能包含多个
tool_calls;如工具数量或成本敏感,应为单轮调用数设上限,并在超限时返回明确错误。 - 限制工具运行时间。 模型请求设置了超时,不代表本地工具自动获得超时保护。耗时工具需要单独的超时、取消和资源隔离策略。
- 控制重试。 网络 SDK 的自动重试与 Agent 决策轮数是两回事。本文设置
max_retries=1,实际请求次数仍可能高于循环轮数;若要严格控制总请求量,应把重试预算也纳入统计。 - 保存可追踪信息。 当前结果保留工具调用次数、工具结果和警告。后续章节再将任务进度与步骤写入 SQLite,并引入日志、恢复和幂等处理。
- 谨慎处理外部写入。 本篇工具只读 CSV。发送邮件、写数据库或修改文件等副作用操作,需要独立的权限、确认和重复执行控制,不能仅靠步数上限保证安全。
最终验收的关键不是模型说“完成了”,而是程序能够在预算内结束、返回结构明确的任务结果,并在耗尽预算时诚实报告失败。下一节会继续把单次循环扩展为带步骤拆解与验证的执行过程;引入 LangGraph 时,图的递归/步骤限制仍需单独配置,不能认为它会自动替代业务层的预算设计。
参考资料
- 阿里云百炼:通过 OpenAI 兼容接口调用模型——核对 API 地址、SDK 调用方式与地域差异。
- 阿里云百炼:获取 API Key——配置访问密钥前的官方入口。
- 阿里云百炼:文本生成文档——了解模型接口及参数说明。
- LangGraph:GRAPH_RECURSION_LIMIT 故障说明——理解后续图编排中循环未终止与步数上限的关系。
- LangGraph:Graph API 与 recursion limit——后续引入图执行时参考,不是本篇代码的运行依赖。
本文介绍如何为AI智能体(Agent)设计带步数上限的执行循环,防止模型无限调用工具导致任务无法终止。通过销售CSV分析助手示例,实现三类终止场景:正常回答、工具报错和步数耗尽失败。重点包括循环预算控制、工具白名单分发、结构化工具结果回传,并强调宿主程序而非提示词承担上限责任。内容覆盖验收标准、边界验证及生产级扩展要点,如单轮工具数限制、超时控制与状态持久化准备。
25

被折叠的 条评论
为什么被折叠?



