☰
Python AI智能体开发实战:从零搭建最小Agent核心循环
2026/10/14 10:57:53 网站建设 项目流程

这两年,Python 圈子里最热的话题之一就是 AI 智能体。很多人第一次听到这个词,觉得就是"给大模型套个壳、多轮聊天",其实完全不是这么回事。所谓 AI 智能体,核心是把大模型从"你问我答的顾问"变成"能自己动手干活的实习生":你给它一个目标,它自己拆解任务、选择工具、执行动作、观察结果、调整计划,直到把事情办完。

这篇是 Python AI 智能体编程实用指南的第一篇,我会把智能体的最小骨架讲透:它为什么这么设计、核心机制是什么、以及怎么用几十行 Python 搭一个真正"能干活"的 Demo。适合已经调过模型 API、写过 Python、想搞懂 Agent 底层逻辑的开发者看。不需要深度学习背景,也不绑定任何特定平台,我会把原理、代码、踩坑经验一起拆开讲。

1. 智能体到底是什么

1.1 普通问答和智能体的本质差别

先看一个最简单的对比。你直接调用大模型 API,发一句"帮我查一下明天北京的天气",模型大概率会回复一句"抱歉,我无法实时获取天气信息"。这不怪模型,它的训练数据里没有今天和明天的实时数据,它也不会去打开浏览器。

智能体要解决的就是这件事。在这个结构里,模型不再直接输出最终答案,而是先输出一个"行动计划":我需要调用某个天气查询工具。系统收到这个意图后,真的去请求天气服务,把结果塞回给模型。模型看到"明天北京晴,28 度",再组织成一句人话回复你。

注意这个闭环:模型负责推理和决策,工具负责执行,执行结果又回到模型手里。这个"推理 -> 行动 -> 观察 -> 再推理"的循环,就是智能体区别于普通问答的关键。说得夸张一点,普通调用是"纸上谈兵",智能体是"带装备上场"。

1.2 智能体的四个核心部件

一个能稳定工作的智能体,拆开看就四样东西:

  • 模型:负责理解任务、拆解步骤、决定下一步调用谁。这是大脑。
  • 工具:暴露给模型的能力,比如计算器、查天气、读写文件、发请求。这是手脚。
  • 记忆:对话历史、工具执行结果、中间推理过程。模型靠这些信息判断"我现在进行到哪一步了"。
  • 编排:也就是那个循环逻辑,负责把模型输出、工具调度、结果回传串起来,还要处理终止条件。

很多人搭 Agent 上来就考虑"多智能体协作""记忆数据库""自动规划",我觉得顺序搞反了。先把这四件套跑通,再谈花活。尤其是编排循环,这是地基中的地基,不理解它,后面每个问题都会变成玄学。

2. 先想清楚再动手:最小架构怎么设计

2.1 从零手写还是直接用框架

现在开源社区有很多成熟的 Agent 框架,封装了对话循环、工具注册、记忆管理等能力,用起来确实省事。但我强烈建议你至少手写一遍最小实现。

原因很简单:框架把太多细节藏起来了。你遇到"模型反复调用同一个工具""工具结果模型没看到""上下文越滚越大"这类问题的时候,如果不知道底层消息是怎么组织的,排查起来会非常痛苦。我见过不少直接用框架的同学,出了问题只能重启重试,完全没法定位。

反过来,花一两个小时手写一个几十行的循环,你对消息结构、工具返回格式、终止条件这些关键点会有特别直观的体感。之后再上框架,你看到的是"它帮我做了什么优化",而不是一团黑盒。

2.2 一个最小闭环循环

我平时搭 Agent 用的最小循环大概长这样:

  1. 把系统提示词、历史对话、用户输入拼成消息列表,发给模型。
  2. 模型返回两种结果之一:要么是最终回复;要么是一组"工具调用请求"。
  3. 如果是工具调用请求,逐条执行对应工具,把执行结果以"工具消息"的形式追加进消息列表。
  4. 带着追加了工具结果的消息列表,再次调用模型。
  5. 循环第 2 到第 4 步,直到模型给出最终回复,或达到最大步数。

这里面有两个终止条件很关键:一是模型不再请求调用工具,说明它觉得信息够了、可以回答了;二是步数上限,防止模型陷入死循环。我习惯把最大步数设为 8 到 10,Demo 阶段甚至设 5 就够。

这里借用一下圈内常说的 ReAct 模式来理解:模型内部其实在经历 Think(想)-> Act(做)-> Observe(看)三个状态。不过好消息是,现在主流的模型服务已经通过"函数调用"能力把 Act 这一步做成了结构化输出,我们不用去解析自由文本里的"我想调用某工具",省了一大堆恶心事。后面第三节细讲。

2.3 工具注册机制与设计规范

工具不是随便塞给模型的,需要一套"注册"机制,让模型知道:你有什么工具、每个工具是干嘛的、参数怎么填。

我常用的工具描述结构包含四部分:

  • name:工具名,模型调用时填的名字,比如calculate。
  • description:一句话说明工具用途,以及什么时候该用它。这部分特别重要,模型靠它做选择。
  • parameters:参数的 JSON Schema,描述每个参数叫什么、什么类型、是否必填。
  • handler:真正执行工具的那个 Python 函数,从参数里取值干活。

在代码里我会用一个字典把这些信息存起来,工具名作为 key。模型返回"我要调用 calculate",系统拿到名字,去字典里找到对应函数,把参数传进去执行。这个模式简单可靠,后面想加几十个工具也只是往字典里多塞几项而已。

写工具描述有个经验:description 里一定要写清楚"什么时候用",甚至给个例子。比如"计算四则运算表达式,如 (12+34)*5/6",模型很容易理解。如果只写一句"计算器",模型经常会在不需要计算的时候也去调一下,白白浪费轮次。

3. 关键细节与实操要点

3.1 function calling 到底发生了什么

先跑一下题,说说函数调用这个能力。传统做法是让模型输出"我想调用 calculate,参数是 XX",然后我们写正则去解析这段自然语言,脆弱得一塌糊涂——模型换一个说法就废了。

现在的主流方案是,请求模型时额外传一个tools参数,里面是工具的结构化定义。模型看到这些定义后,如果要调用工具,会返回一个结构化的tool_calls字段,里面包含工具名和 JSON 格式的参数。也就是说,模型没有真的去执行你的代码,它只是"用文字发出了一道指令",真正的执行者是你。

这个过程对稳定性至关重要。因为参数是结构化 JSON,你的json.loads直接就能解析,不再需要正则和自然语言理解。我在生产项目里几乎只依赖这一种交互方式,自由文本式的工具调用只作为兜底。

实操里有个小坑:不同模型服务对tools的字段命名不同。有的叫functions,有的用tool_choice控制是否强制调用某个工具。我建议动手前先把你用的服务的请求示例读一遍,手写一个最小请求打印出完整响应,确认清楚字段格式再写循环,能省后面一大半排查时间。

3.2 消息顺序就是 Agent 的记忆

这是我觉得 Agent 编程里最容易踩坑、也最反直觉的地方。整个对话循环里,模型能看到的所有信息,全部存在于一个消息列表里。没有额外的"状态机",没有"变量区",消息顺序就是记忆。

具体来说,消息类型至少有三种角色:

  • system:系统提示词,一般放开头,告诉模型"你是一个能调用工具的助手"。
  • user:用户输入。
  • assistant:模型的回复。注意,这里有个特殊情况——模型决定调用工具时,返回的那条带tool_calls的消息,也要原样追加进列表。
  • tool:工具执行的结果,必须带上tool_call_id,告诉模型"这条结果是为了响应哪一次工具调用"。

有个无数人踩过的坑:模型返回了工具调用请求,工具也执行了、结果也算出来了,但你没有把结果以tool角色消息追加回去,而是直接把结果文字拼到user消息里再发给模型。这种情况下模型大概率会"失忆",因为它不知道自己刚才请求过工具,只会觉得用户突然塞了一串奇怪的数字过来,于是开始胡说八道。

所以请记住一条铁律:消息列表里,一条带 tool_calls 的 assistant 消息,后面必须紧跟至少一条对应的 tool 消息。顺序错了、ID 对不上、角色写错,都会导致模型给出的回答逻辑混乱。

3.3 上下文管理与预算控制

消息列表越长,模型看到的上下文越完整,这是好事。但代价也很直接:第一,API 按 token 计费,上下文越长越烧钱;第二,很多模型对输入长度有上限,超过了一律报错或截断。

我常用的策略是"固定窗口 + 摘要压缩"。具体做法:

  1. 系统提示词和工具定义永远保留,这部分是 Agent 的"操作手册",不能丢;
  2. 设定一个历史轮次窗口,比如只保留最近 10 轮对话;
  3. 超过窗口的早期内容,定期让模型生成一段摘要,把摘要作为一条更早的 user 消息放回去。

如果是在一个长期任务里,工具结果可能非常长,我还会对每个工具结果做长度截断,比如超过 500 字符就只保留首尾,并附上一句"中间部分已被截断"。模型需要更多细节时可以再调工具获取。这样既控制预算,又保持信息密度。

3.4 工具异常要怎么处理

工具不是每次都能成功执行。参数可能解析失败,计算可能除零,外部服务可能超时,文件可能不存在。这里有一个统一原则:不要在循环里抛异常,把异常变成一条工具返回文本。

也就是execute_tool函数内部捕获所有异常,返回"计算失败:除数为零"或"网络请求超时"这样的文本。这看起来平淡无奇,效果却极其明显:模型收到这条"结果"后,会自动调整策略,比如改用另一个工具、换一种参数、或者直接告诉用户当前无法完成。

如果循环里抛异常,Agent 直接崩溃,那就谈不上什么自主调整了。我们做的是让模型在"有反馈"的情况下决策,错误也是一种反馈,而且是很重要的反馈。

4. 实操:跑通一个"能干活"的最小 Agent

说了这么多,来点能直接跑的。下面这个 Demo 我尽量保持精简,但完整覆盖了工具注册、模型调用、循环编排、异常处理全套逻辑。用到的是常见的/chat/completions风格接口,你换成自己手头可用的服务地址即可。

4.1 定义工具:从"会算数"开始

先定义两个工具:一个是安全的四则运算计算器,一个是获取当前时间。为什么用这两个?因为它们足够简单、确定性高,方便你验证整个循环是否正确。第一次跑通之后再替换成真实业务工具。

import ast import datetime import json import operator import re import requests # 模型服务配置(示例,请替换为实际可用配置) MODEL_URL = "http://your-model-endpoint/v1/chat/completions" MODEL_NAME = "your-model-name" API_KEY = "your-api-key" # 工具的可执行实现 _BIN_OPS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, } def _eval_expr(node): """基于抽象语法树的安全表达式求值,避免使用 Python 的 eval。""" if isinstance(node, ast.Expression): return _eval_expr(node.body) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS: left = _eval_expr(node.left) right = _eval_expr(node.right) if isinstance(left, (int, float)) and isinstance(right, (int, float)): return _BIN_OPS[type(node.op)](left, right) raise ValueError("不支持的表达式") def calculate(expression: str) -> str: expr_clean = re.sub(r"\s+", "", expression) if not re.fullmatch(r"[0-9+\-*/().]+", expr_clean): return "表达式包含非法字符" try: value = _eval_expr(ast.parse(expr_clean, mode="eval")) return str(value) except Exception as exc: return f"计算失败:{exc}" def get_current_time() -> str: return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

一个容易被忽略的设计细节:calculate的返回值永远是字符串,哪怕失败也返回带说明的字符串。这保证了工具输出可以被直接塞进消息列表,不会因为类型问题报错。

然后定义工具注册表。我把它拆成两部分:给模型看的 Schema,和给程序用的处理函数。

TOOLS = [ { "type": "function", "function": { "name": "calculate", "description": "计算四则运算表达式,例如 (12+34)*5/6", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "合法的数学表达式,例如 12+34*5" } }, "required": ["expression"] } } }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前日期和时间", "parameters": {"type": "object", "properties": {}} } } ] TOOL_HANDLERS = { "calculate": lambda args: calculate(args["expression"]), "get_current_time": lambda args: get_current_time(), }

这里把参数 Schema 写得越细,模型就越不容易传错参数。尤其required字段,我建议所有后续要用的参数都标上必填,否则模型会默认省略。

4.2 组装循环:让模型自主决定下一步

接下来是两个核心函数。第一个负责调用模型,第二个负责执行工具调用并返回结果。

def call_llm(messages, tools): payload = { "model": MODEL_NAME, "messages": messages, "tools": tools, "temperature": 0.2, } headers = {"Authorization": f"Bearer {API_KEY}"} resp = requests.post(MODEL_URL, json=payload, headers=headers, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"] def execute_tool(name, args_str): try: args = json.loads(args_str) if args_str else {} except json.JSONDecodeError: return f"参数解析失败:{args_str}" handler = TOOL_HANDLERS.get(name) if handler is None: return f"未找到工具:{name}" try: return str(handler(args)) except Exception as exc: return f"工具执行异常:{exc}"

注意execute_tool对每一个环节都做了兜底。参数解析失败返回提示,找不到工具返回提示,执行异常也返回提示。这些"提示"会被模型看到,成为它下一步决策的依据。

然后是主循环:

def run_agent(user_input, max_steps=8): messages = [ { "role": "system", "content": "你是一个能调用工具的智能助手。" "需要计算时使用 calculate,需要获取时间时使用 get_current_time。" }, {"role": "user", "content": user_input}, ] for step in range(1, max_steps + 1): print(f"[step {step}] 调用模型...") msg = call_llm(messages, TOOLS) if not msg.get("tool_calls"): print("[agent 完成]") return msg["content"] # 模型决定调用工具:把带 tool_calls 的消息原样追加 messages.append(msg) for tool_call in msg["tool_calls"]: fn_name = tool_call["function"]["name"] fn_args = tool_call["function"]["arguments"] print(f" [调用工具] {fn_name}({fn_args})") result = execute_tool(fn_name, fn_args) print(f" [工具返回] {result}") # 工具结果必须以 role=tool 的消息追加,并带上 tool_call_id messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": result, }) return f"[已达最大步数 {max_steps},未获得最终答案]"

这段代码的重点全在消息列表的维护上。模型返回的工具调用请求和工具执行结果,都被原样、按顺序追加到messages里。下一轮调用模型时,它看到的就是"我调了工具、工具返回了结果"这个完整过程。这个设计虽然朴素,但就是 Agent 能"记得自己做过什么"的根本原因。

4.3 运行效果与关键逻辑解读

在主入口里跑一次多步任务:

if __name__ == "__main__": answer = run_agent( "帮我算一下 (2350*17.5+860)/12 等于多少,顺便告诉我现在几点了。" ) print("\n最终回答:", answer)

运行后,终端会打印类似这样的过程:

[step 1] 调用模型... [调用工具] calculate({"expression": "(2350*17.5+860)/12"}) [工具返回] 3501.25 [调用工具] get_current_time({}) [工具返回] 2025-05-18 14:32:08 [step 2] 调用模型... [agent 完成] 最终回答: (2350*17.5+860)/12 的结果是 3501.25。当前时间是 2025-05-18 14:32:08。

注意一个细节:模型在第一步一次性发起了两个工具调用,这两个tool_calls 在同一个 assistant 消息里。我们的循环不需要串行等前一个完成再发下一个,而是直接遍历执行全部调用,再把所有结果依次作为 tool 消息追加回去。并行执行多个独立工具,是节省轮次和时间的重要手段。

如果模型返回工具调用但你的循环写错了、把工具结果塞进了 user 消息,你在日志里会看到模型后续回答完全无视计算结果。所以运行 Demo 时一定要盯着打印出来的消息结构看,这是建立直觉的最快方式。

5. 常见问题与排查实录

跑这个 Demo 和后续扩展时,有几个问题出现频率极高。我把它们整理成一个速查表:

现象常见原因排查思路
模型完全不调用工具请求里没传tools字段;temperature太高;工具描述不清打印请求体确认 tools 存在;描述里写明"何时该用"
工具执行了,模型却无视结果tool 消息的tool_call_id与 assistant 消息不匹配打印消息列表,逐条核对 id 和角色
模型反复调用同一工具工具返回信息不足,模型无法决策让工具返回更完整的结论文本;同时调低max_steps
对话越长回答越差上下文堆积,早期信息干扰决策固定窗口截断历史,必要时做摘要压缩
循环中途直接崩溃工具内部异常抛到了主循环在 execute_tool 里统一捕获异常,返回文本
API 报 400 错误消息格式不对,常见是 assistant 的 tool_calls 后少了 tool 消息检查消息角色、tool_call_id 是否成对出现
模型开始乱传参数参数 Schema 没有标明必填项在 parameters 里把必填参数写进required
一直停在"思考"不返回模型被要求强制使用工具,但当前任务不需要检查是否设置了工具选择策略,正常情况应允许模型自行决定

5.1 工具调用迟迟不发起的排查

如果你发现模型老是不用工具,先别怀疑模型智商。最常见的两种情况是:请求体里压根没传 tools,或者工具 description 写得太抽象。

我见过一个同学给工具写"提供天气",模型完全没概念什么时候该调。改成"查询指定城市的当前天气,当用户问'天气''冷不冷''下雨吗'时使用"之后,调用准确率立刻上来了。模型的工具选择本质是在做匹配,你的描述就是匹配依据。

另外,调试阶段可以把temperature调到 0 或 0.2。温度越高模型越"发散",越容易不按套路出牌;工具调用这种结构化动作,低温更稳定。

5.2 死循环和预算失控

模型陷入"调工具 -> 看结果 -> 再调工具"的循环停不下来,是 Agent 编程最常见的成本黑洞。我处理它的手段有三层:

第一层是硬性max_steps上限,这是兜底,必须要有。第二层是让工具返回"终结性信息"。例如某个查询工具返回"未找到相关记录,无需重试",模型看到这类明确信息后通常会停止追问。第三层是给任务设置总 token 预算,每次循环结算一次已消耗 token,超了就强制返回当前进展并记录。

这三层都加上之后,我几乎没有再遇到过真正失控的 Agent,顶多是多花几轮调用,但不会无上限烧钱。

5.3 上下文爆炸的处理

上下文爆炸的典型表现是:API 开始频繁报错,或者模型越来越"健忘",连最开始的任务目标都忘了。

我当时的处理方案很朴素:写一个简单的trim_messages函数,保留开头的 system 消息和最后 N 轮对话,中间的早期内容直接去掉。对 Demo 和中等复杂度任务,这个"粗暴截断"完全够用。只有当任务需要跨多个会话长期记忆时,才引入"每一轮对话结束后生成摘要并保存"的方案。

另外一个省钱技巧:工具定义本身在每一轮请求里都会发送,非常占 token。如果发现工具很多,可以把工具定义按照当前任务动态筛选,只传最可能用到的几个。这个优化在工具数量超过 10 个时很明显。

6. 从 Demo 到落地的几个方向

跑通上面这个最小 Agent 之后,你就拥有了一个可以不断迭代的地基。我个人建议的下一步不是急着堆功能,而是先做三件事。

第一,把工具换成真实业务。找一个你手头具体的工作场景,比如"查数据库里的订单""调用内部接口同步数据""读写本地文件生成报表"。工具一旦跟真实数据挂钩,你才会遇到权限、超时、参数校验这些在 Demo 里碰不到的麻烦,这些麻烦才是 Agent 工程的日常。

第二,加上并发和重试。真实服务不会像本地函数那样稳定返回,网络抖动、限流、超时是常态。给模型请求加上指数退避重试,给工具调用加上超时控制,这两点做完,稳定性能上一个台阶。

第三,做好可观测性。至少把每一轮的消息列表、工具调用、token 消耗都打印或记录到日志里。我排查 Agent 问题时的第一步永远是看日志里的消息流转,没有日志就等于盲人摸象。

这一篇先讲到这里。在我看来,Agent 编程的门槛不在模型、不在框架,而在你是否真的理解"模型负责决策、代码负责执行、消息列表负责记忆"这条主线。把这二十几行代码吃透,后面不管是上多智能体协作、接知识库,还是做自主规划,你都会有稳定可靠的根基。希望这篇能帮你少走点弯路,尤其是那些我在消息顺序和工具调用上踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询