零基础手写Agent:用Python和大模型API跑通智能体闭环
最近被问得最多的一个词就是 Agent也就是智能体。很多刚接触 AI 的朋友一看到“Agent 实战”“多智能体协作”这类标题第一反应是觉得门槛很高得先会用各种框架得懂一大堆底层原理。其实我自己的经验正好相反从一个最简单的 Agent 开始只靠 Python 和一个大模型 API半天时间就能跑通完整的“让 AI 自己干活”的流程。这篇内容我按照自己从概念到实战构建的路径来写尽量不堆术语每一步都告诉你为什么要这么写、踩过哪些坑适合完全零基础的小白也适合想快速把手头的大模型 API 用起来的开发者。建议先收藏因为后面代码部分可以直接当模板抄。1. Agent 到底是什么别被新概念唬住1.1 用一句话先给 Agent 下个定义我理解的 Agent可以拆成一个公式Agent 大模型 记忆 工具 行动循环。如果拿普通聊天机器人和它对比聊天机器人像是一个只能坐在办公室里回答问题的笔友你问一句它答一句它不会主动去查资料、不会调系统、也不会事后帮你跟进。而 Agent 更像一个带着手机、记事本和公司内部系统的外勤助理你交代它“把明天的会议安排整理成邮件发给我”它能自己拆步骤、查日历、调用邮件接口、最后把成品交到你手上。这里的关键点是Agent 本身并没有“自主意识”它的聪明来自大模型的推理能力而它的“动手能力”来自程序给它外挂的工具。你搭建 Agent本质上就是在写一段流程代码让大模型在一个循环里不断做决定下一步该调用哪个工具、还是直接给出答案。把这个概念理解透了后面的实战就不会觉得玄乎。1.2 Agent 的核心环节感知、决策、行动、反馈我在自己做得比较顺手的演示项目 X 里把 Agent 的工作方式归纳成四个环节感知接收用户输入以及从外部读取必要信息。比如用户说“整理一下我今天的日报”Agent 要先把这句话变成结构化任务。决策大模型根据任务和目标决定下一步动作。是直接写回答还是先查询某个工具的数据。行动执行代码里的工具函数比如读取待办列表、请求天气接口、操作文件。反馈把工具执行后的结果返回给大模型让模型基于真实数据生成最终回复。这四个环节形成一个闭环。没有这个闭环大模型就只能“想”不能“做”加上这个闭环它才像在干活而不是在聊天。1.3 它和大模型的直接调用有什么不同过去我们调大模型接口通常是一段 prompt 进去一段文本出来。输出好不好只取决于提示词写得怎么样。而 Agent 的差异在于模型可以输出一种“工具调用指令”而不是直接输出最终答案。代码收到这个指令后去执行真实的函数再把结果塞回对话里让模型继续推理。我经常打一个比方普通 API 调用是“只让一个很聪明的人闭卷答题”Agent 是“让这个人可以翻书、查资料、拿计算器甚至打电话问同事”最后交出来的答案准确性自然高很多。所以零基础入门时先把注意力放在“工具调用 循环”这两个机制上比你背一百个框架 API 更有用。2. 零基础入门的准备工具、成本与思维模型2.1 你需要准备的技术栈其实很少市面上讲 Agent 的教程经常会推荐各种编排框架比如某些“链”后缀的库。我不是说它们不好而是对于零基础先把框架放一边手写一个最小闭环才是最快的路径。你真正需要准备的只有这些Python 3.10 以上环境能跑脚本就行。requests库用来发 HTTP 请求。安装命令很简单pip install requests。一个大模型 API 的访问密钥。现在这类服务已经很普遍选一家你能注册的、支持“工具调用”或“函数调用”能力的服务即可。一个文本编辑器我习惯用 VS Code但你用什么都行。不需要 GPU不需要 Linux 服务器不需要 Docker。你的电脑只要能运行 Python就能跑通下面这个例子。我第一次跑通的时候甚至是在一台只有 8GB 内存的老笔记本上完成的。2.2 调用大模型 API 的成本到底贵不贵很多小白会担心费用。我的经验是做学习和演示用的 Agent成本几乎可以忽略但要注意控制循环次数。当前大部分大模型 API 按 token 计费输入和输出价格不同。我以一个虚拟价格为例输入每百万 token 10 元输出每百万 token 30 元。一次普通的日报整理请求可能消耗几百个 token算下来单次成本不到一分钱。真正烧钱的是两件事没有限制的 while 循环让 Agent 在一个错误分支上反复调用几十次甚至上百次。把超长文档反复塞进上下文每次都全量发送token 费用线性增长。所以我会在代码里给循环加一个最大步数比如max_steps5既能防止失控也方便排查问题。这个习惯不管做学习项目还是生产项目都建议保留。2.3 核心思维ReAct 循环ReAct 是 Reason Act 的组合翻译过来就是“推理 行动”。这个思路在 Agent 领域非常重要但理解起来不难。设想用户问“明天出门要带伞吗”一个普通模型可能直接回答“建议带伞”但它是凭训练时的记忆猜的。而一个带 ReAct 的 Agent 会这样做思考这个问题需要知道明天的天气所以我应该调用天气查询工具。行动调用get_weather工具传入城市名和日期。观察工具返回“明天多云午后有阵雨”。思考既然有阵雨我应该建议用户带伞。行动给出最终答案。在代码层面这五个步骤就是在循环里反复调用大模型接口判断返回内容里是否有“工具调用”字段。有就执行工具没有就把当前内容作为最终输出返回。这个机制就是 Agent 的发动机。3. 实战构建一个日报整理 Agent 从 0 到 13.1 先把需求拆解成工作流我选的例子是“日报整理 Agent”。为什么选它因为日报每个人都熟悉需求边界清晰而且它能自然地用到两个常用工具读取待办列表、获取当日天气。用户输入一段话“我上午写了方案文档下午开了两个会明天可能要外出拜访客户”Agent 需要输出一份这样的日报今日完成事项明日计划明日天气提醒总结/建议如果只靠大模型直接生成它很容易给你编一个“明天晴”之类的假数据。所以我们要让它先调用天气工具拿到真实天气后再生成日报。工作流可以文字表述为接收用户输入 → 把工具列表和对话历史一起发给模型 → 模型判断需要调用天气/待办工具 → 代码执行对应函数 → 把结果返回给模型 → 模型生成最终日报。这样一遍流程走下来就已经是一个完整的单 Agent 应用了。3.2 第一次调用大模型 API先写最基础的调用函数。假设你已经在环境变量里配置了LLM_API_KEY。下面这段代码我用的端点是一个通用占位地址实际使用时要替换成你所用服务的真实地址。import os import requests import json API_KEY os.getenv(LLM_API_KEY) API_URL https://api.example.com/v1/chat/completions def call_llm(messages, toolsNone): headers { Authorization: fBearer {API_KEY}, Content-Type: application/json, } payload { model: your-model-name, messages: messages, tools: tools or [], tool_choice: auto, } resp requests.post(API_URL, headersheaders, jsonpayload, timeout30) resp.raise_for_status() data resp.json() return data[choices][0][message]这段代码的要点messages是完整的对话历史不只是当前用户消息。因为 API 本身无状态我们必须自己携带上下文。tools是工具描述列表告诉模型有哪些函数可以调用。tool_choice设为auto让模型自己判断什么时候调用工具、什么时候直接回答。第一次跑通了别急着往下写先打印一下返回的 message 内容你会看到两种典型结果一种是正常的content字段直接包含回答另一种是模型返回了一个tool_calls字段里面是它想调用的函数名和参数。看到后者说明模型已经具备 Agent 的“决策”能力了。3.3 给 Agent 配上两个简单工具工具本质就是普通的 Python 函数但我们需要把它们的“说明书”告诉模型。先写函数本身def get_weather(city: str, date: str) - str: # 演示用假数据实际应请求天气服务 if 雨 in 明天有阵雨: return json.dumps({city: city, date: date, weather: 多云转阵雨, temperature: 22-28°C}, ensure_asciiFalse) return json.dumps({city: city, date: date, weather: 晴, temperature: 20-30°C}, ensure_asciiFalse) def read_todos(date: str) - str: todos { 2025-01-06: [上午写方案文档, 下午参加产品评审会, 晚上准备客户拜访材料], 2025-01-07: [上午十点拜访客户, 下午整理合同], } items todos.get(date, []) return json.dumps({date: date, todos: items}, ensure_asciiFalse)两个函数都返回 JSON 字符串这么做有个好处结构清晰大模型能稳定解析。接下来是工具描述要告诉模型“函数叫什么、有什么用、参数是什么格式”。这部分大多数 API 都遵循一套通用的 JSON Schema 描述方式。tools [ { type: function, function: { name: get_weather, description: 查询指定城市在指定日期的天气情况返回天气和温度, parameters: { type: object, properties: { city: {type: string, description: 城市名}, date: {type: string, description: 日期格式 YYYY-MM-DD} }, required: [city, date] } } }, { type: function, function: { name: read_todos, description: 读取指定日期的待办事项列表, parameters: { type: object, properties: { date: {type: string, description: 日期格式 YYYY-MM-DD} }, required: [date] } } } ]这里有一个非常重要的点description一定要写清楚“什么时候用”。模型没有读函数内部代码的能力它只能靠这段描述来判断该调用谁。你描述得越具体模型的选型就越准。我见过很多初学者在这里随便写一两句话导致模型经常选错工具或者干脆不调用。3.4 让 Agent 在循环里自己决定下一步现在把上面几块拼起来实现一个最核心的循环。模型第一次返回时如果没有tool_calls说明任务完成如果有我们就执行对应的工具把工具结果以新的消息追加到messages里然后带着更新后的历史再次调用模型。如此反复。def run_agent(user_input, max_steps5): messages [ {role: system, content: 你是一个日报整理助手。你会根据用户描述和工具返回的真实数据生成日报不要编造工具结果。}, {role: user, content: user_input}, ] for step in range(max_steps): message call_llm(messages, toolstools) # 如果没有工具调用说明这是最终回答 if not message.get(tool_calls): return message.get(content, ) messages.append(message) for tool_call in message[tool_calls]: func_name tool_call[function][name] arguments json.loads(tool_call[function][arguments]) if func_name get_weather: result get_weather(cityarguments[city], datearguments[date]) elif func_name read_todos: result read_todos(datearguments[date]) else: result json.dumps({error: unknown tool}) messages.append({ role: tool, tool_call_id: tool_call[id], content: result, }) return 达到最大步骤数任务未完成。测试时可以用这样的输入if __name__ __main__: user_input 今天是2025-01-06我写了方案文档下午开了两个会明天上午要拜访客户。请帮我生成今日日报并提醒我明天北京的天气。 print(run_agent(user_input))这里有一个代码细节值得留意在messages.append(message)时我直接把模型返回的那一整条消息放回去了。那条消息里包含content和tool_calls。如果不把tool_calls放进去模型就不知道“我刚才提出了调用请求”可能陷入前后矛盾的状态。运行后会看到什么模型会先返回一个read_todos调用代码执行后拿到待办列表接着可能再返回一个get_weather调用拿到天气最后生成一段结构化日报。每一步打印出来你就能直观地感受到 Agent 的工作方式了。4. 细节决定成败提示词、记忆与上下文管理4.1 提示词不玄学但要讲结构很多人在 Agent 上失败第一原因不是代码错了而是提示词太模糊。系统提示词是模型的全局约束我一般会包含四部分角色定位、任务目标、可用工具、输出约束。拿日报 Agent 来说你是一个日报整理助手。 根据用户提供的当日工作描述和工具返回的真实数据生成一份中文日报。 你有以下工具 - read_todos(date)读取指定日期的待办事项结果作为今日完成事项的参考。 - get_weather(city, date)查询城市天气结果用于明日计划和出行提醒。 你必须基于工具返回值来写日报不得编造天气和待办数据。 日报格式如下 ## 今日完成 ## 明日计划 ## 天气提醒 ## 建议这里最关键的一句是“必须基于工具返回值来写日报不得编造”。不加这句模型会倾向于图省事直接在第一次回答时伪造一个天气结果。原因很简单模型的任务是补全对话它并不知道自己真的可以调用工具约束越明确行为越稳定。4.2 在无状态 API 下实现记忆大模型 API 本身不保存会话记录所以“记忆”完全靠我们维护 messages 列表。短记忆就是把每一步的 user、assistant、tool 消息都追加到列表里这个我们在 3.4 已经做了。长记忆则要更主动地保存关键信息比如用户常用城市、项目背景、历史偏好。入门阶段不需要上向量数据库用 JSON 文件就够import json memory_file agent_memory.json def save_memory(key, value): with open(memory_file, r, encodingutf-8) as f: memory json.load(f) memory[key] value with open(memory_file, w, encodingutf-8) as f: json.dump(memory, f, ensure_asciiFalse, indent2)然后在系统提示词里把记忆内容拼接进去例如“用户常驻城市北京”。这样 Agent 在查询天气时如果用户没给城市它也能从记忆中推断。这个设计虽然简单但能让你体会到“有状态 Agent”和“无状态模型”之间的桥梁是怎么搭起来的。4.3 上下文窗口不够用怎么办上下文窗口是每个 Agent 开发者都会撞上的墙。尤其是对话轮数增多、工具返回结果很大时messages 列表会越来越长费用和延迟都会上来。我常用的办法有三个截断只保留最近 N 条消息最早的消息丢进一个摘要变量里。摘要每隔几轮让模型把前面的对话压缩成一段摘要作为 system 消息重新放回去。工具结果瘦身如果工具返回几十条数据可以模型先筛选只把关键字段传回 agent。比如天气接口返回十几项指标日报只需要天气和温度那工具函数里只保留这两项。一个简单的摘要函数可以这样写def summarize_messages(messages): summary_prompt 请用三句话概括以上对话的关键信息包括用户偏好和未完成事项 summary_messages messages [{role: user, content: summary_prompt}] summary call_llm(summary_messages)[content] return [{role: system, content: f历史摘要{summary}}]注意这个函数本身又是一次 API 调用所以在小 demo 里不要频繁触发。我的经验是当 messages 的总字符数超过约 8000 字或者连续 6 轮以上还没结束再做摘要比较划算。5. 踩坑实录Agent 实战中的常见问题与排查技巧5.1 模型“编造”工具结果你没有及时发现这是最典型的坑。表现为模型返回了一段“天气晴气温 25°C”但你根本没有在 messages 里看到tool_calls或者工具函数根本没有被执行。原因通常是系统提示词不够强、模型不支持工具调用、或者你用了不支持 function calling 的模型。排查方法很简单在run_agent的循环里加一段打印把每次调用的message原样输出到控制台。如果发现“直接给出天气”但没有任何工具调用记录就是模型在编造。解决方式是在提示词中加入“你无法直接获取天气必须调用 get_weather 工具。”确认所用模型支持工具调用不要用只适合聊天的基础版本。在最终输出校验时检查日报里的天气是否来自工具返回字段如果发现日期对不上重新发起一次修复请求。5.2 循环卡死与预算失控Agent 循环如果写得不够稳很容易出现“模型反复调用同一个工具拿到的结果每次都一样但它还在继续调”的情况。这会直接烧光预算。我在排错时遇到过某次循环连续调用了 20 次天气接口原因就是提示词让它必须确认三次才能输出但模型每次返回的参数有细微差异导致逻辑反复触发。解决办法有三层设置max_steps我习惯默认 3-5 步。给每次请求加timeout30防止网络阻塞挂死。对同样的工具调用计次如果同一个tool_call_id或函数名参数连续出现超过 2 次直接强制跳出并让模型基于已有结果生成答复。第三层属于兜底逻辑虽然会打断模型的节奏但至少不会让预算失控。生产环境里我还会额外加上一个日志计数器超过阈值就发告警。5.3 输出格式不稳定解析经常报错模型偶尔会把 JSON 输出成带json的代码块或者在里面写注释导致json.loads直接失败。遇到这种情况不要急着骂模型。写一个健壮的解析函数它先把字符串里的 markdown 代码块剥离再尝试解析 JSON。import re def parse_tool_arguments(text): text text.strip() # 去掉 json ... 包裹 match re.search(r(?:json)?\s*([\s\S]*?), text) if match: text match.group(1).strip() return json.loads(text)如果解析失败不要直接给用户报错。把解析的错误信息作为 user 消息传回模型比如“你上一步返回的 JSON 无法解析请重新输出有效的 JSON”。模型通常会自动修正。这个“错误回灌”技巧比人工介入快得多。5.4 我的排查顺序与避坑清单做 Agent 调试时我有一套固定的排查顺序能省下大量时间先看 HTTP 请求是否成功Authorization 有没有拼对。打印模型第一次返回的原始 message确定到底有没有tool_calls。确认 tool_calls 里的函数名和参数是否准确参数类型是否都能转成 Python 对象。确认工具函数有没有真的执行结果有没有以role: tool追加回去tool_call_id是否和请求的一致。最后才怀疑提示词不要一上来就改提示词那样容易越改越乱。关于避坑再分享几条日常实战总结任何工具函数都要给默认值比如城市默认“北京”日期默认“今天”减少模型遗漏参数的概率工具返回结果统一用 JSON 字符串字段名保持稳定不要把真实 API 密钥写在代码里用环境变量每次改动提示词后先跑同一个用例做回归对比不然很难判断是代码问题还是模型波动。我个人折腾下来最大的体会是Agent 的门槛没有想象中高但坑一定比教程里写得多。你不需要第一天就把所有框架都学会只需要先手动把“模型发出工具调用 → 代码执行 → 结果回传 → 再次调用模型”这条闭环跑通就已经很了不起了。后面遇到多 Agent 协作、复杂编排、知识库接入本质上都是在“让我手写的这个最小循环”更可控、更强大。另外还有一个小技巧把你这套 Agent 的输入输出和每次工具调用日志保存下来哪怕只是存成本地文件调试时都是一种巨大的优势。希望这篇内容能成为你入门 Agent 的第一块垫脚石照着上面的例子动手做一遍比看二十篇教程都管用。