☰
从零手写AI Agent:不依赖重型框架,半小时跑通核心循环
2026/10/12 3:59:30 网站建设 项目流程

这两年只要聊到AI应用,Agent这个词几乎躲不开。很多人跑来问我:Agent到底怎么开发?是不是不上LangChain这类重框架就做不了?我的回答通常是一句话:先剥开看,再决定要不要穿衣服。前段时间我把pi-agent这个开源项目从头到尾读了一遍,又照着它的思路从零手写了一个小Agent,整个过程比我想象中清爽得多。所谓Agent,剥到最后其实就是一个循环:给模型一个目标,让它决定调哪个工具,拿到结果,再继续决定下一步,直到目标完成。这篇文章我就带你完整走一遍我的实现过程,不依赖任何重型框架,只用Python和一个支持函数调用的对话模型,半小时跑通一个真正能“干活”的小Agent。适合刚接触Agent开发、想弄清底层原理的开发者,也适合被各种概念绕晕、想回头补基础的同学。

1. Agent开发的核心认知:别被概念绕晕

1.1 Agent的本质是一个决策循环

很多人以为Agent是什么玄乎的东西,其实剥开来看,它就是一个非常朴素的循环。我习惯叫它“决策循环”:你给Agent一个目标,它像人一样先思考——我现在该做什么?如果要获取信息,该调用哪个工具?调用完之后,它看到工具返回的结果,再思考下一步。这个思考、行动、观察、再思考的过程不断重复,直到它认为任务已经完成。

这个模式在业内有个经典叫法:ReAct,也就是Reasoning和Acting的结合。不用被这个词吓到,它的核心就三步。第一步,模型根据当前对话上下文进行推理,产出一个自然语言的想法;第二步,模型决定调用某个工具,并生成结构化的调用参数;第三步,系统执行工具,把真实结果塞回对话上下文,模型看到结果后继续推理。这样一个循环走完,Agent就完成了一次“思考并行动”的闭环。

我打个比方你就能秒懂:这就像你给实习生交代了一件事,告诉他工具柜里有什么工具,实习生不清楚细节时,会去翻工具说明书,拿出一个工具试一下,然后把结果汇报给你。你看了汇报再给他新的指示。Agent就是这么个实习生,模型是大脑,工具是他的双手,而那个循环就是你和他之间的工作流。理解了这个比喻,后面所有代码你都能看懂了。

1.2 pi-agent给我们的最大启示:最少可用闭环

我把pi-agent这个开源项目完整读完之后,最大的感慨不是它用了多少花哨技术,恰恰相反,它非常克制。整个项目的核心就是三样东西:一个循环、一个工具注册表、一份消息历史。没有复杂的图状态机,没有向量数据库,也没有多Agent编排。它仅仅是把上面说的那个决策循环写扎实了,就足以完成很多实用的任务。

这种“最少可用闭环”的设计思路,我认为是pi-agent最值得学的地方。现在Agent框架越做越重,动辄就是几十个概念堆在一起,新手光理解那些抽象名词就得花一周。但你要是照着pi-agent的路子走,把最核心的循环从零写一遍,你会发现原来那些框架里的高深概念,大部分都是在这个循环外面包了一层又一层的壳。壳固然有它的用处,但前提是你得先搞清楚里面的芯是什么。

还有一个重要启示是:Agent的能力瓶颈,往往不在模型,而在工具质量和循环的鲁棒性。pi-agent的代码里大量篇幅在处理“模型返回格式不对怎么办”“工具执行出错了怎么办”,而不是在搞花哨的规划算法。这个经验我实测下来非常正确,后面我踩坑的时候还会反复提到。

1.3 工具调用是Agent的双手

要让Agent真正“干活”,光会聊天是不够的,它必须能操作外部世界。这里的关键机制就是函数调用,通常叫Function Calling。简单说,我们在请求模型时,除了发给它对话消息,还附带一份工具清单。这份清单用JSON Schema描述每个工具的名称、用途、参数结构。模型看到之后,如果觉得需要调用工具,它不是直接帮我们执行代码,而是输出一个结构化的调用意图,里面包含函数名和参数。

举个例子,我提供了一个get_weather工具,模型收到用户问“北京天气怎么样”,它就会返回一段类似{"name": "get_weather", "arguments": {"city": "北京"}}的结构。我的代码拿到这个结构后,去本地映射表里找到真正的get_weather函数,替模型执行,然后把执行结果作为一条工具消息返回给模型。模型看到结果后,再组织语言回答用户。

这里有个特别重要的设计理念:模型永远不直接执行代码,它只负责“决策”,真正执行的是我们白名单里的函数。这是安全性的底线。如果让模型直接输出一段Python代码然后去eval,那等于把一个不受控的远程执行接口交了出去,风险极大。而函数调用的方式,让Agent的能力边界完全由开发者控制——你没注册的函数,它永远调不了。这个理念我在后面设计工具注册表的时候会严格贯彻。

2. 动手前的地基:环境准备与项目骨架

2.1 技术选型:为什么是Python加一个对话模型

动手之前先把技术栈定下来。我选Python,没什么悬念,Agent开发这块Python生态最成熟,JSON处理、HTTP请求、各种SDK都顺手。模型方面,选了一个支持函数调用的对话模型服务,现在市面上主流的大模型API基本都支持这个能力,选你顺手、能稳定访问的就行。

这里我建议一个新手最容易犯的错误避雷:不要一上来就引入LangChain这类重量级框架。我见过太多人,项目还没跑通,先被框架的Chain、Agent、Tool、Memory这些概念砸晕了。我的建议是,第一版完全自己写核心循环,最多用一下官方SDK。等你把循环逻辑彻底吃透了,再回去看那些框架,你会发现它们不过是把你写的这些代码封装成了通用组件。自己写过一遍之后,用框架才能用得明白,出了问题也知道去哪查。

2.2 最小项目结构设计

照着pi-agent的简洁风格,我搭了一个很小的项目结构,一共四个文件:

my_agent/ ├── agent.py # Agent核心循环 ├── tools.py # 工具定义与注册 ├── config.py # 配置与密钥加载 └── main.py # 命令行入口

每个文件的职责很清晰。tools.py里放所有Agent能调用的函数,每个函数写清楚注释和参数说明,这里是Agent能力的边界。agent.py是整个项目的灵魂,实现那个决策循环。config.py统一管理模型名称、API地址、密钥、最大循环次数这些配置。main.py只负责启动,读取用户输入,交给Agent跑,再打印结果。

这样拆的好处是,之后每加一个新工具,你只需要动tools.py,注册一下就行,核心循环完全不用改。这其实就是pi-agent里“工具注册表”思想的一种体现:Agent的能力是插件化的,大脑不变,双手可以随便换。

2.3 配置与密钥管理

先处理一个虽然基础但特别重要的环节:API密钥管理。我见过不少人把密钥直接写在代码里,然后一不留神把代码传到公开仓库,密钥就泄露了。这里必须强调一个铁律:密钥永远不要硬编码,用环境变量加载。

config.py里我是这样写的:

import os MODEL_NAME = os.getenv("AGENT_MODEL", "default-chat-model") API_BASE = os.getenv("AGENT_API_BASE", "https://api.example.com/v1") API_KEY = os.getenv("AGENT_API_KEY", "") MAX_STEPS = int(os.getenv("AGENT_MAX_STEPS", "10")) SYSTEM_PROMPT = os.getenv("AGENT_SYSTEM_PROMPT", "你是一个有帮助的助手,可以调用工具来获取信息。")

本地开发的时候,我在项目根目录放一个.env文件,里面填真实的密钥,然后把这个文件加进.gitignore。运行前用工具去加载它,或者手动export也行。这样代码仓库永远是干净的,密钥只在本地环境里存在。这个习惯救过我不止一次,希望你一开始就养成。

3. 实现一个能跑的小Agent:核心代码逐段拆解

3.1 消息管理与LLM调用封装

Agent循环里最重要的数据结构是消息列表。我们先统一消息格式,OpenAI兼容的接口基本都遵循这种标准:每条消息有一个role字段,取值是system、user、assistant或tool,还有一个content字段存文本内容。

system消息用来定义Agent的人设和行为准则,user是用户的输入,assistant是模型的历史回复,tool是工具执行后返回的结果。整个循环其实就是不断往这个列表里追加消息,然后发给模型,再把模型的回复追加进去。

我在agent.py里封装了一个最基础的调用函数:

import json import httpx def chat_completion(messages, tools=None): payload = { "model": config.MODEL_NAME, "messages": messages, "tools": tools or [], } headers = { "Authorization": f"Bearer {config.API_KEY}", "Content-Type": "application/json", } resp = httpx.post( f"{config.API_BASE}/chat/completions", headers=headers, json=payload, timeout=60, ) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]

这个函数不负责决策,只负责把消息发给模型,把回复原样拿回来。它就好比是Agent的通信模块,核心循环和外部模型之间的通道。我特意把tools参数留出来,因为循环里第一轮和后续轮次传的工具清单是同一个,但也有需要动态调整场景,比如Agent每次运行时工具列表不变的,就一路传同一个列表。

3.2 工具注册机制

工具是Agent的双手,那这双手怎么组织?我的做法是在tools.py里定义真实的Python函数,再写一份对应的JSON Schema清单,最后用一个字典把函数名映射到真实函数上。

先看一个最基础的工具——获取指定城市的天气。为了演示方便,我这里先用模拟数据代替真实API调用,保证整个流程可复现:

def get_weather(city: str) -> str: """获取指定城市当前天气,返回JSON字符串。""" fake_db = { "北京": {"天气": "晴", "温度": 26, "湿度": 30}, "上海": {"天气": "多云", "温度": 29, "湿度": 65}, "广州": {"天气": "阵雨", "温度": 31, "湿度": 80}, } data = fake_db.get(city, {"天气": "未知", "温度": 0}) return json.dumps({"city": city, **data}, ensure_ascii=False)

然后定义这个工具的JSON Schema。这个结构要跟模型API约定的格式一致,模型是靠它来理解“这个工具能干什么、参数怎么传”的:

WEATHER_TOOL = { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气情况,输入城市名称,返回天气、温度和湿度。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京", } }, "required": ["city"], }, }, }

描述这个字段非常重要,模型靠它来判断什么时候该调这个工具,以及参数怎么填。我实测下来,描述写得越具体,模型的选择就越准。比如光写“获取天气”和写“查询指定城市的实时天气情况,输入城市名称,返回天气、温度和湿度”,效果差别很明显。

最后做一个注册分发字典:

TOOL_SCHEMAS = [WEATHER_TOOL] TOOL_DISPATCH = { "get_weather": get_weather, }

后面每加一个工具,就是在这个文件里加一个函数、一份Schema、一个字典映射。这个模式非常简单直白,也正是pi-agent这类项目的核心思路:Agent的扩展性不靠复杂机制,靠一个清晰的注册表。

3.3 ReAct循环落地

核心重头戏来了,就是那个决策循环。我把完整实现贴出来,然后逐段讲解:

def run_agent(user_input): messages = [ {"role": "system", "content": config.SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for step in range(config.MAX_STEPS): print(f"\n[Step {step + 1}] 发送消息给模型...") message = chat_completion(messages, TOOL_SCHEMAS) if message.get("tool_calls"): messages.append(message) for call in message["tool_calls"]: fn_name = call["function"]["name"] fn_args = json.loads(call["function"]["arguments"]) print(f" -> 模型决定调用: {fn_name}({fn_args})") if fn_name not in TOOL_DISPATCH: result = json.dumps({"error": f"未知工具: {fn_name}"}, ensure_ascii=False) else: try: result = TOOL_DISPATCH[fn_name](**fn_args) except Exception as e: result = json.dumps({"error": str(e)}, ensure_ascii=False) print(f" -> 工具返回: {result}") messages.append({ "role": "tool", "tool_call_id": call["id"], "content": result, }) continue if message.get("content"): print(f"\n[Final] 模型最终回复:{message['content']}") return message["content"] print(f"\n达到最大步数限制,终止循环。") return "Agent未能完成任务,已达最大执行步数。"

这段代码看着不长,但它就是整个Agent的心脏。我来拆开讲几个关键点。

第一,为什么循环里要continue?因为模型在一轮里可能同时调用多个工具,每处理完一个工具调用,都要把结果作为tool消息追加进历史。追加完之后,必须再回循环开头,把包含工具结果的最新消息列表重新发给模型,让模型基于真实结果做下一轮推理。不能直接结束,否则Agent就只“决定”了动作却看不到结果。

第二,为什么要把模型返回的整条消息message追加进messages?因为这个消息里除了content,还带着tool_calls结构。后续对话历史里,模型需要看到“自己之前调用过什么工具”,这属于上下文的一部分。如果你只把工具结果追加进去,而漏了模型的工具调用记录,模型会丢失一部分上下文,行为可能变得混乱。

第三,最大步数限制MAX_STEPS是保命用的。模型有时候会在一个错误思路上反复打转,如果没有这个上限,循环可能永远不结束,白烧Token。我默认设10步,足够完成大多数简单任务,又不会无限制地跑飞。

第四,也是我踩坑最多的点:工具执行必须套try/except。工具函数执行出错太常见了,参数类型不匹配、外部服务超时、数据格式变化,这些都可能导致异常。正确的做法不是让异常直接崩溃,而是把错误信息转成JSON字符串,作为工具结果返回给模型。模型看到“出错了”之后,会自己想办法换一种处理方式,这比让程序崩溃强得多。

3.4 记忆与上下文管理

整个循环跑起来之后,你会很快遇到一个新问题:消息列表越来越长。每一次工具调用,包括模型决策和工具结果,都会往列表里加消息。跑个五六轮之后,光是多轮工具调用的历史就可能吃掉几千Token。所以记忆和上下文管理,哪怕是最小实现,也不能完全忽略。

最简单的方案就是“全量历史 + 截断”。我在main.py里给Agent加了一个简单的滑动窗口逻辑:当消息条数超过某个阈值,比如20条,就把最老的user/assistant轮次裁掉,只保留system消息和最近几轮。这个实现很粗糙,但对小Agent来说够用了。

def trim_messages(messages, keep_system=True, max_len=20): if keep_system: system_msgs = [m for m in messages if m["role"] == "system"] rest = [m for m in messages if m["role"] != "system"] if len(system_msgs) > 1: system_msgs = system_msgs[-1:] rest = rest[-(max_len - len(system_msgs)):] return system_msgs + rest return messages[-max_len:]

为什么暂不考虑向量数据库?因为对于一个小Agent来说,它要处理的上下文就是当前任务相关的几轮对话,根本不需要几千几万条历史。一上来就上向量检索,属于典型的过度设计。先把滑动窗口跑通,等真的遇到长对话总结需求,再去研究摘要方案也不迟。这个建议同样是从pi-agent的源码里学来的——在一个有限的场景里,克制比堆技术重要。

4. 实操过程:从“能回答”到“能干活”

4.1 一个小场景:让Agent查天气并给出穿衣建议

理论讲完,直接上实战。我在main.py里写一个简单的命令行入口,然后真跑一遍。

def main(): print("Mini Agent 已启动。输入你的问题,输入 exit 退出。") while True: user_input = input("\n你: ") if user_input.lower() in ("exit", "quit"): break response = run_agent(user_input) print(f"\nAgent: {response}") if __name__ == "__main__": main()

跑起来之后,我输入了这样一句话:“北京今天天气怎么样?我该穿短袖还是外套?”下面是完整执行过程实录:

你: 北京今天天气怎么样?我该穿短袖还是外套? [Step 1] 发送消息给模型... -> 模型决定调用: get_weather({'city': '北京'}) -> 工具返回: {"city": "北京", "天气": "晴", "温度": 26, "湿度": 30} [Step 2] 发送消息给模型... [Final] 模型最终回复:北京今天晴天,气温26度,湿度30%,整体比较干爽。26度穿短袖是没问题的,但如果早晚出门,建议带一件薄外套,防止温差。

这个输出看起来平淡,但注意看它背后发生了什么。第一轮,模型并没有直接回答“该穿什么”,而是先决定调用天气工具,因为它的常识库里有“需要实时数据”的判断。工具返回真实数据后,模型才基于数据组织回答。这就是Agent和普通聊天机器人的本质区别:它不是凭记忆编造,而是先获取事实,再基于事实回答。

我把这个完整日志贴给同事看的时候,他第一反应是:这不就是套了个API的if-else吗?还真不是。关键在于,模型的工具选择是它自己根据上下文动态做出的,我们没有写任何一条“如果用户问天气就调天气接口”的硬编码规则。这带来的灵活性是完全不同的——你往工具注册表里加一个新工具,模型不需要改一行代码,就能在遇到相关问题时自动学会使用它。

4.2 调试技巧:如何看清Agent每一步在想什么

开发Agent和开发普通程序有个很大的不同:普通程序逻辑是你写死的,出错了你知道去哪查;Agent的逻辑有一部分在模型脑子里,你得想办法把它“显示”出来。所以我强烈建议,从一开始就给Agent加上调试日志。

我上面的代码里已经穿插了一些print,但你还可以加得更细。比如把模型返回的原始JSON完整打出来,尤其是tool_calls里的arguments,因为模型偶尔会生成格式奇怪的参数,肉眼看一下能省很多排查时间。再比如,转发给模型的消息列表也可以选择性打印,确认上下文是不是按照预期拼接的。

这里我分享一个具体的排查案例。有一次Agent运行时,工具调用总是传错参数,我把日志打开后才发现,问题是模型把参数包装成了嵌套JSON,例如{"city": {"城市": "北京"}}。这个错误特别隐蔽,因为光看模型表面上的“思考”是完全正常的,只有把原始参数打印出来才看得到。所以说,调试Agent的第一原则就是:日志永远大于直觉。

4.3 失败重试与容错设计

实际运行中,Agent不可能每次都顺利。我设计了多层容错机制,每一层都很简单,但合起来能让整个系统稳定很多。

第一层,JSON解析容错。模型返回的arguments不一定每次都合法。用json.loads解析时,我会包一层try/except。解析失败时,把“参数解析失败,请重新生成合法的JSON参数”作为错误消息返回给模型,让它自己修正。绝大多数情况下,模型再生成一次就对了。

第二层,工具异常兜底。前面代码里的try/except,把工具抛出的任何异常变成JSON字符串返回给模型。比如我让Agent调用一个网络请求工具,结果接口超时,模型看到“request timeout”之后,会自己决定是重试还是改用其他工具。这一层让Agent具备了一定的“主观能动性”,它不需要开发者预判所有失败场景。

第三层,重复失败熔断。有些工具失败后,模型会执拗地反复调用同一个工具。我在循环里加了一个简单的计数器,同一个工具连续失败超过3次,就主动停止调用,并告诉模型“这个工具目前不可用,请换一种方式或直接回答”。这层逻辑看起来简单,但能救回不少即将烧完Token的对话。

5. 常见问题与排查实录

5.1 Agent陷入死循环怎么办

这是我被问得最多的问题,也是我自己最早踩的坑。表现就是Agent在一个工具调用和结果之间来回打转,反复执行同一个函数,或者不停说“让我再想想”却不做实质动作。根本原因主要有两个:一是模型对当前状态没有形成正确判断,二是我没设置最大步数限制。

解决办法分三层。最基础的一层就是MAX_STEPS,死循环最多烧10步就会被强行截断。第二层是检测连续重复调用,我加了个逻辑:如果某个工具连续出现三次且参数完全一样,就判断为“卡死”,主动把错误信息塞回去打断循环。第三层是在system提示词里写一句话:不要重复执行已经完成过的操作,如果上一步已经得到结果,请直接给出最终回答。这个提示词在很多模型上效果立竿见影。

5.2 工具参数传不进去、JSON解析失败

这个问题出现频率极高。你明明定义了city参数,模型却生成了city之外还带一堆额外字段的JSON,或者把字符串参数传成了数组。我这里总结出了两个经验。

第一,JSON Schema的required字段一定要写全,别偷懒。模型生成参数时,它会优先参考required里的字段,少了它,模型就不一定每次都生成完整参数。第二,解析失败时,一定把错误反馈给模型,而不是就地返回一个“调用失败”的静态文案。我试过这两种方案,反馈具体错误信息的成功率远高于静态文案,因为模型知道你哪里解析错了,它能针对性地修正。

5.3 上下文太长把Token爆了怎么办

跑得越久,Token消耗越大。一次性发很长的历史给模型,既慢又贵,还可能超出模型上下文窗口。我前面写的trim_messages是一种办法,但更进阶的做法是“摘要压缩”:当对话历史超过阈值,调用模型把旧对话总结成几句话,把摘要作为system的一部分,旧消息直接丢掉。

这个摘要方案我实测有一个副作用:模型总结时会丢掉一些细节,后面Agent可能“忘记”某个工具当时的返回数据。所以我的建议是:重要工具结果,尽量在压缩前把它落盘或者单独保存,不要只依赖对话历史。

5.4 模型不按预期格式返回

有时候你明明只让它调工具,它却非要在content里写一堆废话,完全不生成tool_calls。这常见于模型对这些API功能不太擅长的场景。我的经验是,在system提示词里主动说明工具调用规则,效果最直接:

当用户的问题需要实时数据时,请优先调用工具获取数据。 如果工具返回了结果,你必须基于结果来回答,不要编造信息。 一次只调用一个工具,除非多个工具之间没有依赖关系。

除提示词之外,还可以在代码里加上格式校验。如果模型在必须调用工具的场景下没生成tool_calls,就返回一条消息要求它“重新思考并调用合适工具”。记住一个原则:一回生成失败不要紧,让模型看到提示后再来一次,成功率会显著提升。

5.5 常见问题速查表

问题现象可能原因解决方式
Agent反复调用同一工具模型没有看到有效结果,或陷入思路闭环检查工具返回是否被正确拼进消息历史;增加重复调用检测
参数JSON解析失败模型生成非法JSON结构解析包try/except,把具体错误反馈给模型重新生成
上下文Token超限历史消息积累过多增加滑动窗口截断,或做旧对话摘要压缩
模型不生成tool_calls提示词约束不足或模型能力限制system提示词中明确工具调用规则,校验后要求重试
工具执行异常导致崩溃未捕获工具函数异常统一try/except,异常转成JSON字符串返回给模型
Agent运行到最大步数未完成任务复杂或模型效率低适当提高MAX_STEPS,检查提示词和工具描述是否清晰

写在最后的经验之谈

把pi-agent的源码读完,再自己从零实现一遍之后,我最大的体会是:Agent开发的核心竞争力不在于会用某个框架,而在于你能否把这个决策循环理解透彻,并且有能力在它出错时精准定位问题。框架天天在变,今天这个库明天那个库,但底层的循环逻辑是稳定的。

我还想分享一个让我少走很多弯路的小习惯:每加一个新工具,先单独在命令行里测通这个函数本身,确认输入输出符合预期,再把它注册进Agent。直接跑Agent调试新工具,等于同时调试两个变量,出了问题很难分清是工具写错了还是模型调用错了。先隔离测试,再集成,这个来自传统软件工程的习惯,在Agent开发里同样适用。

如果你正打算上手Agent开发,我的建议很简单:别贪多,先照着这个最小循环写一个能跑通的小东西。它可能只能干一两个活,但当你亲手看到模型决定去调用工具、拿到结果又组织出回答的完整过程时,你对Agent的理解会有一次真正的质变。往后那些复杂框架和先进方案,都是在帮你更好地处理这个循环以外的事情,而核心的芯,你早就已经握在手里了。

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

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

立即咨询