从零搭建最小可运行AI Agent:工具调用与主循环原理
前段时间在梳理开源 Agent 项目时看到 PrimeIntellect-ai / prime-agent 这个名字第一反应是这类仓库到底在解决什么问题。很多人以为 Agent 就是“接个大模型 API问一句答一句”但实际动手做一个最小可运行的 Agent 项目就会发现真正复杂的不是模型本身而是“怎么让模型学会调用工具、怎么把外部结果回传给模型、怎么控制一轮又一轮的循环直到任务完成”。这篇文章就以 PrimeIntellect-ai / prime-agent 这类开源智能体项目为灵感带你从零搭建一个最小可运行的 AI Agent 实战 Demo。不需要大型分布式集群也不需要复杂的 Agent 框架只需要一个 Python 环境、一个 OpenAI 兼容的模型 API就能把 Agent 的核心闭环跑起来。我相信读完并照着敲完你会比只看概念文章更能理解 Agent 的工作机制。1. 背景与核心概念1.1 什么是 AI AgentAI Agent智能体可以简单理解成“一个会自主调用工具来完成任务的 AI 程序”。它和我们平时使用的 ChatBot 最大的区别在于ChatBot 只负责生成文本无法操作外部系统。Agent 则多了一个“感知环境 → 决策 → 调用工具 → 获取结果 → 继续决策”的循环能力。比如用户问“帮我看看现在几点”普通聊天模型只能基于训练数据回答可能不准。而 Agent 可以驱动模型生成一次“工具调用指令”由程序读取本地时间后再把结果交给模型最终输出真实时间。1.2 PrimeIntellect-ai / prime-agent 给开发者的启发PrimeIntellect-ai / prime-agent 这类名字看起来很“硬核”大概率是 GitHub 上某个和去中心化 AI、开源算力或智能体工程相关的仓库。它对我的启发是当前 AI 领域已经不满足于“单次问答”而是追求“任务自动执行”。Agent 项目普遍采用“模型 工具 循环控制”三层结构。开源项目之间互相借鉴很快但只要吃透最小闭环再复杂的新框架也无非是在这个闭环上做抽象和扩展。换句话说理解 Agent 的通用运行机制比死记某个框架的 API 更重要。1.3 Agent、RAG、工作流三者的区别这几个概念经常混淆概念核心能力适用场景ChatBot对话生成客服、闲聊RAG检索增强生成知识库问答、文档问答Agent自主规划与工具调用自动化任务、操作外部系统Workflow固定流程编排流程明确、步骤固定的业务逻辑Agent 和其他三者的最大差异在于它让大模型参与了“决策”而不是只做信息处理。2. 环境准备与版本说明2.1 运行环境本文示例以以下环境为例操作系统Windows 10 / macOS / Ubuntu 均可Python3.10 或更高版本包管理工具pip模型 API任意支持 OpenAI 协议的大模型服务开发 IDEVS Code 或 PyCharm请根据你自己的项目实际情况调整版本这里重点演示完整落地思路。2.2 创建虚拟环境为了避免污染系统全局 Python 环境建议先创建虚拟环境mkdir prime-agent-demo cd prime-agent-demo python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate激活虚拟环境后终端提示符前面会出现(venv)标记。2.3 安装依赖新建requirements.txt文件写入以下内容openai1.0.0 python-dotenv1.0.0执行安装pip install -r requirements.txt其中openai官方 Python SDK 用于调用兼容 OpenAI 协议的接口python-dotenv用于加载.env文件中的环境变量。2.4 项目目录结构设计一个尽量简化的项目结构prime-agent-demo/ ├── venv/ ├── .env.example ├── requirements.txt ├── tools.py ├── llm_client.py └── agent.py分工如下tools.py定义 Agent 可以调用的工具。llm_client.py封装大模型 API 调用。agent.pyAgent 主循环以及入口。.env.example环境变量示例。3. Agent 的核心机制设计3.1 工具注册机制Agent 要调用工具首先得把工具“注册”到一个映射表里。这样程序才能根据模型返回的工具名称找到对应的函数并执行。注册机制有很多种写法最简单的是用一个字典TOOL_REGISTRY {} def register_tool(): def wrapper(func): TOOL_REGISTRY[func.__name__] func return func return wrapper以后新增工具时只需要在函数上加上register_tool()装饰器即可。这种方式很容易扩展也方便统一做参数校验。3.2 LLM 对话协议Agent 循环中我们会在一条消息列表里持续追加消息system系统指令告诉模型它的身份和任务。user用户输入。assistant模型回答可能包含tool_calls。tool工具执行结果必须和tool_call_id一一对应。大模型在每一轮都会看到之前所有的消息这样它才能知道自己刚才调用了什么工具、工具返回了什么结果。3.3 终止条件循环不能无限跑下去否则会产生大量 API 调用费用甚至可能陷入死循环。因此要设置两个终止条件模型返回了content且没有工具调用说明已经可以给出最终答案。循环次数达到max_steps上限无论有没有结果都强制结束。这种设计是 Agent 工程中最基础也最重要的兜底策略。4. 完整实战实现一个最小 Agent4.1 配置环境变量创建.env.example文件OPENAI_API_KEYsk-你的密钥 OPENAI_BASE_URLhttps://api.openai.com/v1 OPENAI_MODELgpt-4o-mini如果你使用的是国内兼容 OpenAI 协议的模型服务请改成对应的base_url和model。实际使用时把.env.example复制为.envcp .env.example .env4.2 编写工具层文件路径tools.pyimport datetime import json import re TOOL_REGISTRY {} def register_tool(): def wrapper(func): TOOL_REGISTRY[func.__name__] func return func return wrapper register_tool() def current_time(tz: str local): 返回当前时间。示例参数{tz: local} if tz utc: return datetime.datetime.now(datetime.timezone.utc).isoformat() return datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S) register_tool() def calculator(expr: str): 计算简单四则运算例如 12 或 (34)*5。 注意这是 Demo 级别的实现生产环境请使用 AST 解析或更安全的表达式计算库。 expr expr.replace( , ) if not re.fullmatch(r[\d\-*/().], expr): return 表达式包含非法字符 try: return str(eval(expr, {__builtins__: {}}, {})) except Exception as e: return f计算失败: {e} def dispatch(tool_name: str, args: dict) - str: if tool_name not in TOOL_REGISTRY: return f未知工具: {tool_name} try: return TOOL_REGISTRY[tool_name](**args) except TypeError as e: return f工具参数不匹配: {e}这里解释几个关键点register_tool装饰器把函数名作为 key 注册到全局字典。dispatch负责动态分发参数用**args解包。eval在演示中很方便但存在注入风险生产环境一定要换成安全的表达式解析方案。4.3 封装大模型客户端文件路径llm_client.pyimport os from openai import OpenAI class LLMClient: def __init__(self): self.client OpenAI( api_keyos.getenv(OPENAI_API_KEY, EMPTY), base_urlos.getenv(OPENAI_BASE_URL, https://api.openai.com/v1), ) self.model os.getenv(OPENAI_MODEL, gpt-4o-mini) def chat(self, messages, tools): resp self.client.chat.completions.create( modelself.model, messagesmessages, toolstools, tool_choiceauto, ) return resp.choices[0].messagetools参数需要传入工具描述列表也就是 JSON Schema 格式。tool_choiceauto表示让模型自己决定是否调用工具、调用哪个工具。4.4 编写 Agent 主循环文件路径agent.pyimport json import os from dotenv import load_dotenv from llm_client import LLMClient from tools import dispatch SYSTEM_PROMPT 你是一个自动化 Agent。 你需要根据用户请求决定是否调用工具。 工具调用结果会以 observation 的形式返回。 当结果可以回答用户时直接输出最终答案不要再调用工具。 TOOL_SCHEMAS [ { type: function, function: { name: current_time, description: 获取当前本地时间, parameters: { type: object, properties: { tz: { type: string, enum: [local, utc], description: 时区 } }, required: [tz] } } }, { type: function, function: { name: calculator, description: 计算简单四则运算例如 12 或 (34)*5, parameters: { type: object, properties: { expr: { type: string, description: 数学表达式 } }, required: [expr] } } } ] def run_agent(user_input: str, max_steps: int 5): client LLMClient() messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] for step in range(max_steps): print(f\n第 {step 1} 轮模型请求...) message client.chat(messages, TOOL_SCHEMAS) if message.tool_calls: # 将包含工具调用请求的 assistant 消息加入上下文 messages.append(message.model_dump()) for tc in message.tool_calls: tool_name tc.function.name try: args json.loads(tc.function.arguments or {}) except json.JSONDecodeError: args {} result dispatch(tool_name, args) print(f调用工具 {tool_name}参数 {args}结果 {result}) # 将工具执行结果加入上下文 messages.append({ role: tool, tool_call_id: tc.id, content: result }) continue if message.content: print(最终回答:, message.content) return message.content print(达到最大迭代次数未生成最终回答。) return None if __name__ __main__: load_dotenv() user_input input(请输入你的问题: ) run_agent(user_input)主循环可以做如下理解把system和user消息发给模型。模型返回的message如果带tool_calls说明模型想调用工具。我们把这条 assistant 消息原样放入上下文。逐个执行工具调用并把roletool的结果消息放回上下文。进入下一轮循环。如果模型返回的内容里没有工具调用直接作为最终结果输出。4.5 为什么需要把 assistant 消息也追加回去这个细节最容易踩坑。如果不把message.model_dump()对应的助理消息追加回messagesAPI 会报错。因为 OpenAI 协议要求tool消息必须紧跟在对应的assistant消息之后并且tool_call_id必须真实存在。把 assistant 消息加回去模型才能知道“我刚才决定调用计算器”结合工具返回的结果进行下一步判断。5. 运行与验证5.1 启动 Agent确保.env文件配置正确后执行python agent.py输入请输入你的问题: 计算 (34)*5预期输出类似第 1 轮模型请求... 调用工具 calculator参数 {expr: (34)*5}结果 35 第 2 轮模型请求... 最终回答: 计算结果是 35再测试时间查询请输入你的问题: 现在几点预期输出类似第 1 轮模型请求... 调用工具 current_time参数 {tz: local}结果 2024-06-01 15:30:22 第 2 轮模型请求... 最终回答: 当前时间是 2024-06-01 15:30:22。如果模型判断不需要调用工具的问题比如“你好”第一轮可能直接返回最终回答不会进入工具调用分支。5.2 验证循环终止为了验证max_steps兜底是否有效可以故意把max_steps改成1然后问一个必须多次调用工具才能完成的问题。此时 Agent 会在第一轮循环结束后直接退出不会无限请求。这种边界测试在真实开发中非常重要尤其是接入复杂工具链时。6. 常见问题与排查思路问题现象常见原因解决思路调用 API 报 401 认证失败密钥错误或没有配置.env检查OPENAI_API_KEY和base_url模型返回 content 为 None模型只生成了工具调用未生成文本不要急着判空先检查tool_calls工具参数经常解析失败模型生成的 JSON 参数格式异常用json.loads捕获异常返回给模型重试Agent 陷入无限循环缺少终止条件设置max_steps建议 5 或 8上下文越来越长费用变高每次循环都携带全部历史消息对历史消息做裁剪或摘要tool消息报错缺少对应的 assistant 消息确保tool_calls对应的 assistant 消息先追加计算器工具执行危险代码使用 eval 处理表达式生产环境改用 AST 安全解析如果遇到“工具明明存在但模型一直不调用”的问题优先检查工具描述是否写清楚。例如current_time的参数中required写了[tz]模型就必须生成tz参数否则工具调用会不完整。7. 最佳实践与工程建议7.1 工具设计原则工具不宜太多建议一次只给模型暴露 510 个。工具描述要明确这个工具是干什么的。参数的类型和取值范围。什么情况下应该调用这个工具。什么情况下不应该调用。如果模型经常选错工具多数是描述写得不够清晰而不是模型本身的问题。7.2 参数校验与安全边界Agent 的工具最终一定会执行真实操作因此对参数必须做严格校验。尤其是涉及文件删除、数据库更新、网络请求的工具必须遵循最小权限原则不允许传入任意 shell 命令。数据库操作前必须备份。生产系统变更前必须经过测试环境验证。7.3 日志与可观测性Agent 循环的每一步都要留下日志用户原始输入。模型生成的工具调用。工具返回结果。当前消息列表长度。API 调用耗时。这些信息能帮助你在线上快速定位问题。7.4 成本控制模型每轮循环都会调用一次 API且上下文会不断膨胀。建议设置单次任务的最大轮数。设置单次任务的 token 上限。合并历史工具结果只保留关键信息。对简单任务使用更便宜、更小尺寸的模型。7.5 从 Demo 到生产Demo 里所有代码都在本地进程内同步执行适合验证思路。生产环境通常还要考虑任务队列与异步执行。可恢复的任务状态存储。多用户并发隔离。工具调用的超时限制。模型输出的结构化验证。建议先把工具层做成独立服务再通过 HTTP 或消息队列和 Agent 主进程通信这样后续扩展会容易很多。8. 总结与下一步动手方向这篇文章从一个开源 Agent 项目名字出发拆解了 Agent 最核心的调用循环原理并带你完成了一个可运行的最小 Demo。你至少已经掌握工具注册与动态分发。OpenAI 协议中assistant与tool消息的搭配方式。Agent 主循环的终止条件。常见报错的排查思路。下一步可以做两个方向的尝试一是给 Agent 增加更多真实工具比如天气查询、数据库查询、文件读写二是引入更强的结构化输出方式要求模型严格按 JSON 返回决策结果而不是依赖自然语言描述。如果你只是想在项目里调研 Agent 是否可行建议先用这个几十行的最小闭环跑通业务场景再决定是否引入重框架。先把循环控制打扎实后续无论换成什么 Agent 项目都能很快上手。