最近被一个同学问得说不出话:框架用了大半年,各种Agent项目跑得飞起,结果一换模型全部失灵。我问他,你自己手写过一次Agent循环吗?他说没有,不都是调框架吗。
问题就出在这。框架给你的是别人嚼过的抽象层,零基础用它确实能快速跑起演示项目,可一旦遇到线上问题,你会发现自己对底层在发生什么毫无概念。所以我一贯的主张是,零基础想学AI Agent,第一步不是选框架,而是手写一个最简单的Agent系统。把“模型调用、工具定义、循环控制”这三件事亲手实现一遍,框架在你眼里就不再是黑盒,后面你用什么工具都顺手。
这篇就把我总结出来的一套零基础手写AI Agent系统课完整拆开:从环境准备到核心循环代码,从调试技巧到生产化改造,全部用能直接跑的代码和踩坑实录来讲。你不用有任何Agent基础,只要会一点点Python,就能跟着写出一套自己能改、能扩、能上线的Agent骨架。
1. 先搞清楚:AI Agent到底是个什么东西
1.1 Agent的本质是一个反复逼近目标的循环
很多零基础同学以为Agent就是“能连续对话的ChatGPT”,这是个很大的误解。对话只是外壳,Agent的内核是:一个程序根据目标,反复调用外部工具、观察结果、修正方案,直到任务完成。
我上课时常用一个生活化类比。你让一个实习生去订会议室,他不会一次性搞定,而是先查空闲时间、预定、发会议邀请,发现有人冲突再换时间再通知,这就是一个完整的Agent行为链。大模型在这个链里扮演“大脑”,负责判断下一步该干什么;工具函数是“手”,负责真正把事办了;而你自己写的循环代码,就是那个“项目经理”,负责把大脑和手脚串起来,防止大脑跑偏。
所以你现在理解了,Agent不是某一个模型,而是一整套“感知、决策、行动、反馈”的闭环。手写Agent,本质上就是亲手搭这套闭环,让模型说一句话,你的代码就动一步。
1.2 零基础直接手写,到底图什么
我遇到过不少人质疑:现成框架一拖就能用,为什么非要手写?
第一个价值是理解力。框架把“模型该输出什么”“工具结果怎么回填”都藏起来了,你会配置,但你不知道每次请求到底长什么样。手写一遍,所有细节暴露在你面前,以后再学任何框架都是降维打击。
第二个价值是调试能力。Agent线上崩了,用框架你可能只能对着报错日志干瞪眼。自己写的系统,你清楚每一步该打日志,知道问题出在解析、调用还是循环控制。
第三个价值是可定制。真实业务里,框架的抽象往往不合身。你写一个只有五个函数的简易Agent,扩展起来只需要注册新工具,完全不用被框架的机制绑架。
零基础手写也有现实难点:不会设计类结构、不会调试、遇到报错容易慌。这篇后面的内容就是按零基础能跟上的坡度设计的,不需要你先读一堆设计模式,直接从函数写到循环。
1.3 我们要做的系统,长什么样
最终做出来的东西不大,但五脏俱全。你可以把它想成一个“小管家”:用户丢给它一个任务,它反复调用工具完成任务并汇报结果。
整个流程是这样的:用户给一句任务描述,Agent循环体把任务喂给大模型,模型输出一段结构化指令,你的代码解析这段指令、执行对应工具、把工具结果回填给模型,模型再根据结果决定下一步,直到它判断任务完成,最终输出答案。
这个系统大概只有两三百行代码,却能真实完成“计算数值、获取时间、查东西”这类多步骤任务。代码量很小,所以零基础完全能啃下来。而且重点是,把这套骨架搭完之后,你往里面加什么工具,你的Agent就能干什么活,这比任何花哨的架构Demo都实在。
2. 动手前的准备:环境与核心概念
2.1 工具清单,比你想象中少得多
我建议的准备清单如下:
- Python 3.10 及以上版本,不需要装任何Agent框架,你只需要能运行脚本。
- 一个可调用的模型API,常规做法是拿一个API Key,只要能用HTTP请求调用就行。如果没有线上API资源,本地跑一个小模型也可以,流程完全一致,就是效果上要迁就一下。
- 一个趁手的代码编辑器,不做特别推荐,能用就行。
- 安装一个HTTP请求库,后面所有请求都靠它,执行下面这条命令即可:
pip install requests不需要装那些厚重的自动化框架、向量数据库、消息队列,至少现在不需要。很多零基础同学一上来就被工具链劝退,实际上手写Agent的门槛非常低,低到你只需要会发一个HTTP请求。
2.2 三个绕不开的核心概念
要动手,必须先理解三个底层概念,它们是一切Agent实现的公理。
第一个是模型API的无状态性。大模型本身不记事情,你每次调用它,都必须把之前所有对话历史原样发给它,它才能“接得上话”。这也是为什么Agent系统里,你必须自己维护上下文,而不是指望模型记住。把这个概念刻在脑子里,后面不会走偏。
第二个是工具调用。模型不会直接执行你的函数,它只会输出一段文本。你要做的是在提示词里描述清楚有哪些工具、参数是什么、什么时候用,让模型输出一段你能解析的指令。这段指令本质上就是“模型告诉代码该去调哪个函数”。
第三个是循环控制。你的代码要在循环里不断完成“调用模型、解析输出、执行工具、回填结果”这四个动作,直到模型认为任务完成。没人帮你写这个循环,这正是手写Agent的核心工作量。
用一个类比总结:模型像个开会的领导,它只会说“小王你去订个会议室”,真正跑腿的小王是你写的函数,而你写的循环体则是执行领导指令的行政助理。领导再聪明,没有行政助理和跑腿的人,会议还是订不下来。
2.3 为什么这一步不急着用框架
我知道很多人会问:框架不是更快吗?
没错,框架确实更快,但它解决的是“已有Agent思路的应用落地”问题,不是“学会Agent思路”问题。零基础一上来就用框架,本质是拿着别人封装好的预设去填空,你看不到模型返回的原始JSON,也看不到工具结果回填的细节。
我用框架带过项目,发现一个明显现象:让学员改一个工具参数,他会迷茫,因为他没写过底层的消息组装逻辑。等他手写过一次,再回头看框架文档,基本不需要人教。所以我的结论是:框架留到后面,手写永远是第一课。
3. 零基础手写第一版:让模型学会“先想再做”
3.1 把工具定义成函数和说明书
我们不做大而全,先做两个工具:一个做加法,一个取当前时间。这两个工具简单到不能再简单,但足够把机制讲透。
先定义真正的Python函数:
import datetime def add(a: float, b: float) -> float: """两个数字相加""" return a + b def get_current_time() -> str: """获取当前时间""" return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")函数本身不神奇,神奇的是你要让模型知道这两个函数的存在和用法。方法是在系统提示词里附上一份“工具说明书”。
TOOL_DESCRIPTIONS = [ { "name": "add", "description": "计算两个数字的和", "parameters": { "a": {"type": "number", "description": "第一个加数"}, "b": {"type": "number", "description": "第二个加数"} } }, { "name": "get_current_time", "description": "获取当前时间", "parameters": {} } ]这份说明书的作用是让模型理解:你有哪些牌可以打。参数用结构化描述,模型才知道该填什么。这也是所谓函数调用的雏形,它靠的不是魔法,而是透明的约定。
3.2 设计一套“让模型听指挥”的提示词
有了工具说明书,接下来要把说明书组装进系统提示词,并约定输出格式。这里我建议零基础第一版不要直接用各家复杂的函数调用协议,而是先走“JSON输出模式”。原因是它能让你看得最清楚,模型输出什么,你程序就处理什么。
下面是我常用的提示词模板:
SYSTEM_PROMPT = """ 你是一个能调用外部工具的AI助手。请根据用户问题,选择调用合适的工具。 可用工具: - add: 计算两个数字之和,参数为a和b,都是数字。 - get_current_time: 获取当前时间,无参数。 调用方式:如果你需要调用工具,请严格输出一个JSON对象,格式如下: {"tool": "工具名", "args": {"参数名": 值}} 规则: 1. 需要调用工具时,只输出上述JSON,不要输出任何多余文字。 2. 不需要调用工具时,直接回答用户问题。 3. 一次只能调用一个工具。 """写这个提示词时有三个关键点。一个是明确“一次只能调用一个工具”,否则模型会一口气输出多个JSON,零基础阶段徒增解析复杂度。第二个是明确“只输出JSON”,否则模型容易带上解释性文字,然后你的解析器就崩了。第三个是把参数名写得和函数签名一致,减少后面写执行层代码的麻烦。
3.3 写核心循环:解析、执行、回填
现在写一个最基础的模型调用函数。你只需要把它里的占位地址替换成实际服务商的地址即可:
import json import requests API_URL = "YOUR_API_ENDPOINT" API_KEY = "YOUR_API_KEY" def call_llm(messages): headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" } payload = { "model": "your-model-name", "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + messages, "temperature": 0.2 } resp = requests.post(API_URL, headers=headers, json=payload, timeout=30) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"]然后是工具执行函数:
def execute_tool(name: str, args: dict): if name == "add": return add(**args) if name == "get_current_time": return get_current_time() return f"未知工具: {name}"接下来是灵魂环节,也就是Agent主循环:
def run_agent(task: str, max_steps: int = 5): messages = [{"role": "user", "content": task}] for step in range(max_steps): reply = call_llm(messages) print(f"[Step {step + 1}] 模型输出: {reply}") try: action = json.loads(reply) except json.JSONDecodeError: # 模型没有输出JSON,视为最终回答 return reply if "tool" not in action: return reply tool_name = action["tool"] tool_args = action.get("args", {}) result = execute_tool(tool_name, tool_args) print(f"[Step {step + 1}] 工具 {tool_name} 返回: {result}") # 把工具结果回填给模型,让它继续判断 messages.append({ "role": "user", "content": f"工具返回结果: {result}。请根据该结果继续:如果任务完成,直接给出最终回答;如果还需要其他工具,继续输出JSON调用。" }) return "达到最大步骤数,任务可能未完成。"这段代码有几个必须强调的设计。首先是max_steps上限,没有它,模型一旦反复调用工具就变成死循环,这是新手最容易炸的地方。其次是解析失败直接当作最终答案返回,因为模型偶尔不守规矩输出普通文本,没必要把它卡死。最后是把工具结果作为新的用户消息追加进messages,模型下一次调用时就能看到“刚才那步发生了什么”。
3.4 第一次运行:看循环怎么转起来
保存为agent.py,然后在终端里试一下:
python agent.py或者在交互环境里执行:
run_agent("现在几点了?顺便算一下 123 加 456")如果你用的模型足够配合,会看到类似这样的输出过程:
[Step 1] 模型输出: {"tool": "get_current_time", "args": {}} [Step 1] 工具 get_current_time 返回: 2025-01-05 14:30:22 [Step 2] 模型输出: {"tool": "add", "args": {"a": 123, "b": 456}} [Step 2] 工具 add 返回: 579.0 [Step 3] 模型输出: 当前时间是2025-01-05 14:30:22,123加456等于579。第一次跑通这个循环,你基本就理解了Agent的骨架。之后遇到再复杂的框架,你都能在脑子里翻译成这个循环的变体。
4. 加入多步推理和记忆:Agent才会“越用越聪明”
4.1 记录思考轨迹,让Agent别“失忆”
跑通第一版后,你大概率会发现一个问题:模型偶尔会在第二次调用时把前面做过的事忘了,或者重复执行同一个工具。原因在于,我们只把工具结果作为新用户消息追加,但没把模型自己上一步的决策显式记进上下文。
修改方式很简单:在追加工具结果之前,先把模型上一条回复以assistant角色加回messages。这样模型在下一步决策时,能看到自己刚才说要调用哪个工具,形成完整的思考轨迹。改造后的核心循环片段如下:
# 把模型的决策写入历史 messages.append({"role": "assistant", "content": reply}) # 再写入工具结果 messages.append({ "role": "user", "content": f"工具返回结果: {result}。请根据该结果继续..." })这一步虽然小,但对行为影响很大。模型有了完整的决策上下文,才不会像个失忆的人一样反复问同一个问题。这也是很多所谓“ReAct”思路的朴素起点,你不需要记名词,只需要理解:让模型看见自己每一步怎么想的,它才能像真正工作的人一样连贯执行。
4.2 多轮对话状态,怎么隔离最合理
当你和Agent连续聊好几轮时,会遇到一个新问题:上一轮的任务过程,要不要带入下一轮?
我见过不少人的做法是把所有中间步骤一股脑全堆在消息列表里,结果上下文很快爆掉。正确的思路是把“会话历史”和“当前任务上下文”分开。
你可以维护一个整体会话列表,但它只保留用户每次的问题和Agent的最终回答,不保留中间的几十次工具调用过程。每次用户发新问题,你先看会话历史,然后开启一轮全新的任务循环,任务循环内部的工具调用消息,等这轮结束后丢弃。
这样做的好处是:多轮对话的“记忆力”靠会话摘要实现,单轮任务执行的“过程记忆”靠循环内的消息实现,两者职责清晰。我给零基础学员的建议是先用最简单的方式:用一个全局列表存最终对话,任务循环内部的消息每次新建。等系统复杂了,再引入会话摘要机制。
4.3 控制Token长度,别让上下文变成定时炸弹
Agent最容易被忽视的坑就是Token消耗。工具返回一大段文本、日志记录没完没了、消息列表无限增长,几十轮之后可能超出模型的上下文窗口,导致调用直接报错。
我自己常用的几个手段如下:
- 每一轮循环检查
messages的估算长度,超过阈值就丢弃最旧的工具中间消息。 - 工具返回结果在回填前用截断函数限制长度。
- 旧对话做一层摘要,而不是永远保留原始文本。
截断函数写起来很无脑:
def safe_truncate(text: str, max_len: int = 500) -> str: if len(text) <= max_len: return text return text[:max_len] + "...(已截断)"注意截断时必须保留“(已截断)”标记,这是告诉模型:你看到的信息不完整,如果任务对缺失部分敏感,应该主动说明。不加这个标记,模型会把截断当成完整内容,产出的结果会非常离谱。零基础阶段最容易忽略,但实际线上影响很大。
5. 从一个玩具到一个可用系统:我踩过的坑
5.1 工具参数永远是“看似合法”的字符串
第一版跑通之后,我踩过的第一个坑就是参数类型。模型输出的JSON里,args的值经常是字符串。比如它输出{"a": "123", "b": "456"}而不是数字,直接传进add()就会因为字符串和数字不能相加而报错。
解决办法不是去求模型老实点,而是在执行层做一次参数整理。我会在execute_tool里增加一个转换函数:
def coerce_number(value): try: return float(value) except (TypeError, ValueError): return 0.0再把add的调用改成:
if name == "add": return add(coerce_number(args.get("a")), coerce_number(args.get("b")))这个经验反映出一个通用原则:永远不要把模型的输出当可信输入,要在代码层做防御。模型是概率生成,再强也会犯低级格式错误,Agent系统的健壮性就体现在这些防御里。
5.2 循环体里必须加逃生舱
一个零基础学员曾经在循环里让模型反复搜索同一个关键词,跑了两百多次,把一天的量额度全烧完了。原因就是他把max_steps设成了100,而模型死活不肯结束。
从那以后我加了一条硬性经验:max_steps是一道保险,但还不够,还要加重复检测。如果模型连续两次调用同一个工具、相同参数,基本说明它卡住了,应该立刻终止循环。
简单实现如下:
last_action = None for step in range(max_steps): ... action = json.loads(reply) if "tool" in action: current_action = (action["tool"], json.dumps(action.get("args", {}), sort_keys=True)) if current_action == last_action: return "检测到重复调用,任务提前终止。" last_action = current_action很多Agent框架里的各种“退出条件”,本质上就是为了防这种卡死。你自己写的时候千万不要心疼循环次数,该断就断,宁可给用户一个“没完成”的回复,也比白白烧Token强。
5.3 模型幻想工具名,怎么办
模型不是永远照章办事,它会幻想出一些你根本没定义的“工具名”,比如convert_currency、search_web。你的第一反应可能是:这些是我们未来要加的功能,模型怎么知道的?
这事不需要过度解读,就是模型在靠训练记忆做猜测。解决办法有两个层面。第一层是在系统提示词里反复强调“只能使用列出的工具,不存在其他工具”。第二层是执行层兜底:
if name not in ["add", "get_current_time"]: return f"工具不存在: {name},请从可用工具中选择。"注意这个兜底消息会被回填给模型,让它知道刚才调用失败了,然后重新决策。这种做法比直接抛异常靠谱得多,因为异常会中断整个循环,而回填错误消息能让模型自己纠正。实际测试下来,大部分模型在被明确告知工具不存在后,都能重新选择正确工具。
5.4 调试时先用快模型,别一上来上最强模型
这是我最想强调的调试习惯。如果你在调试阶段直接用效果最强但速度慢的模型,会发生两件事:第一,每次循环等待几十秒,你连改代码的耐心都会耗尽;第二,强模型太聪明,会帮你“脑补”当前代码的缺陷,导致你自以为系统没问题,换到普通模型立刻翻车。
合理顺序是:先用一个响应快、能力相对弱的模型打通流程,把JSON解析、循环终止、上下文管理这些机制都调试稳定;确认无误后,再切换到能力更强的大模型跑业务。弱模型能跑通,说明你的代码逻辑是完备的;强模型能跑得更好,那是锦上添花。
零基础同学最容易犯的错就是,代码一报错就怀疑模型不够强,换更强的模型来掩盖问题。实际上问题往往出在你自己的解析逻辑和循环控制上。先把机制做扎实,这是手写Agent最核心的收获。
6. 进阶方向:从手写走向生产
6.1 让调用更稳定:超时与重试
手写版本跑通业务后,要面对现实环境里的问题:网络闪断、服务端限流、响应超时。基础做法是在call_llm里加超时,再加指数退避重试。
我常用的简化逻辑是:第一次失败后等1秒重试,第二次等待2秒,第三次等待4秒,最多重试三次。如果还是失败,才真正抛异常。
import time def call_llm_with_retry(messages, max_retries=3): for attempt in range(max_retries): try: return call_llm(messages) except Exception as e: if attempt == max_retries - 1: raise wait_time = 2 ** attempt time.sleep(wait_time)这种重试机制是生产系统的基本功。你甚至可以在重试前检查一下是不是参数问题,但零基础阶段先掌握这个模板就够了,后面自然知道什么错可以重试、什么错重试也没用。
6.2 从硬编码工具到注册表模式
现在的execute_tool全靠if判断,工具一多就会变得又臭又长。用注册表模式解决,把工具名映射到函数,加新工具只需要往表里加一行。
TOOL_REGISTRY = { "add": add, "get_current_time": get_current_time, } def execute_tool(name: str, args: dict): if name not in TOOL_REGISTRY: return f"未知工具: {name}" fn = TOOL_REGISTRY[name] return fn(**args)你还可以在此基础上加一个register_tool函数,让新工具在别处注册,循环代码完全不用动。这样你的Agent就开始像一个插件系统了。以后想扩展就扩展,核心循环始终保持稳定。
6.3 评估Agent效果,别只看表面聪明
手写Agent最容易造成的错觉是“看着很聪明”。它偶尔成功一次,你会误以为系统已经可用。实际上手写系统后,你需要一套评估方法来衡量效果,否则就是盲人摸象。
我建议准备一份二十到三十条的任务评测集,包含不同难度:
- 单工具任务:直接问时间。
- 双工具任务:先取时间,再做计算。
- 上下文依赖任务:分多轮提问,检测记忆是否保留。
- 误导性任务:问一些工具范围之外的问题,检测系统会不会硬要调用工具。
然后统计几个关键指标。我常用的表格样式如下:
| 指标 | 含义 | 目标 |
|---|---|---|
| 成功率 | 任务被正确完成的占比 | 越高越好 |
| 平均步数 | 每任务平均循环次数 | 越低越好 |
| Token消耗 | 每任务平均消耗量 | 成本控制 |
| 失败原因分布 | 解析失败/工具不存在/步骤超限 | 定位短板 |
跑完评测集,你才能清楚知道自己手写的Agent距离可用还有多远。我见过很多项目,上线前自测感觉良好,一上真实流量就崩,就是因为没有一套客观评价。手写Agent不复杂,但评测习惯是决定你能不能做好的分水岭。
最后分享一点个人体会
带零基础同学手写Agent的这几年,我最大的体会是:框架能让你跑得快,但手写能让你不迷路。我一直建议,第一版代码再简陋也没关系,一定要亲手敲一遍,把模型输出打印出来看,把工具回填的过程看清,把循环里每一次决策都摆在眼前。
我建议你把上面这套代码原样跑通,然后做一次最实际的改进:把add换成你自己业务里的工具,比如查库存、发通知、算工资。换一个工具,你就能体会到什么叫工具调用;换三次工具,你手写Agent就算真正入门了。
对一个真实可用的Agent系统来说,复杂从来不是核心问题。循环写稳、工具定义清楚、上下文控制得住,这个系统就能立住。这套骨架扎实了,你后面再去学任何框架、读任何架构,心里都会有一张底图。