说到手写AI Agent,很多人第一反应是:市面上现成的Agent框架一大堆,何必自己造轮子?但如果你真的从零开始用代码把Agent的核心链路写一遍,你会发现那些框架里被隐藏的细节——模型怎么选工具、怎么维护上下文、怎么处理一轮接一轮的循环——全都变得无比清晰。这篇文章就是我整理的一份零基础手写AI Agent系统课的内容骨架,跟着走完一遍,你能亲手做出一个具备工具调用、记忆和多步规划能力的Agent,而不是只会调别人封装好的接口。
这个内容适合谁?我觉得主要适合三类人:刚接触大模型开发、想知道Agent背后原理的初学者;已经在用LangChain之类的框架,但遇到问题只能靠猜的人;以及想做一个真正贴合自己业务场景、不依赖重型依赖的轻量Agent的开发者。不需要你有深厚的Python功底,会用函数、会写类、能处理字典列表就足够了。
1. 先搞懂:AI Agent到底是什么
1.1 从“会聊天”到“会动手做事”
在动手写代码之前,我建议先把概念底子打好。一个纯大模型应用,比如你直接问模型“帮我查一下杭州今天的天气”,它只能凭训练时学到的知识回答,要么告诉你“我无法实时获取信息”,要么给你编一个。这不是模型笨,而是它天生就是“离线选手”,只能根据输入预测输出,不连接外部世界。
Agent就不一样了。它在模型外面包了一层循环逻辑:让模型分析当前情况,决定要不要调用外部工具,工具返回结果以后再交给模型继续判断,直到最终给出结论。用我的话说,模型负责“思考”,工具负责“动手”,Agent负责把思考和动手串成一个闭环。
我在课程里经常用一个类比:把Agent想象成一个刚入职的实习生。你给他一个任务,他会先想“这需要什么资源?”,然后去查资料、问同事、跑数据,每拿到一个新结果,他会重新评估“现在够不够?还缺什么?”,直到任务完成。这个“想-做-观察-再想”的过程,就是Agent的核心循环。
1.2 一个最小Agent的四个零件
把一个Agent拆开来看,万变不离其宗,核心就四样东西:
| 部件 | 作用 | 我习惯叫它 |
|---|---|---|
| 大模型 | 理解指令、生成决策、输出行动计划 | 大脑 |
| 工具 | 执行具体操作,比如查天气、算数学、读写文件 | 手脚 |
| 记忆 | 保存对话历史和关键信息,供模型参考 | 记事本 |
| 循环控制 | 不断让模型根据最新状态做决策,直到任务结束 | 调度员 |
很多人以为Agent很神秘,其实你把这四个零件拼在一起,就是一个能用的Agent。框架做的事情无非是把它们封装得更漂亮,但本质就是这几样。
1.3 为什么必须从“手写”开始
直接上手LangChain、AutoGPT这类框架确实快,几行代码就能声明一个带工具调用的Agent。但问题在于,一旦运行结果不符合预期,你完全不知道是哪里出了问题。是模型没有识别该用工具?还是工具参数格式不对?还是上下文被截断了?框架内部一层套一层抽象,排查起来非常痛苦。
我自己带过不少学员,凡是先手写过一个最小实现的人,再回去用框架,理解深度完全不一样。因为你知道框架每一步在做什么,你能猜到问题大概率出在哪个环节。这个“知其所以然”的收益,是初期多花几个小时完全值得的。
2. 零基础准备:环境搭建与开发规划
2.1 我建议的准备清单
这套课的代码我用的是Python 3.9以上版本,不需要装任何重量级依赖,核心只用到一个HTTP请求库。如果你本地没有Python环境,我建议直接装一个Anaconda或者去Python官网下载安装包,把Python加到系统PATH里就行。
需要准备的还有两样东西:
- 一个支持函数调用或这prompt可控的大模型API接口。我用的是OpenAI兼容格式的接口,因为市面上绝大多数服务商都兼容这个格式,你只需要替换base_url和api_key就能跑通。
- 一个趁手的代码编辑器。我用VS Code,配上Python插件就够了,不需要什么高端配置。
这整套项目不需要GPU、不需要本地部署模型、不需要复杂的分布式环境,一台普通电脑就能跟上。我在课程里一直强调:先把逻辑跑通,再考虑优化。
2.2 项目目录规划
项目开始之前,我建议把代码结构提前定好。这不是形式主义,而是当你从三五个文件发展到十几个文件时,清晰的目录能省下大量心智负担。我的项目结构是这样规划的:
ai_agent_lite/ ├── main.py # 入口,启动交互循环 ├── llm.py # 统一的大模型调用封装 ├── tools.py # 内置工具集与注册器 ├── agent.py # Agent核心循环 ├── memory.py # 记忆管理 └── config.py # 配置项:API密钥、模型名、温度等这个结构的核心思想是“分层”:llm.py只负责跟API打交道,tools.py只负责定义工具,agent.py把两者串起来,memory.py处理上下文和记忆,main.py负责跟用户交互。各层之间通过简单的函数调用通信,互相不越界。等你以后想替换模型供应商,只需要改llm.py一个文件,其他代码一行都不用动。
2.3 第一次调通大模型接口
我们先把最底层的“大脑连接”打通。这一步的目标很朴素:能用代码向模型发送一句话,并拿到回复。不需要任何Agent逻辑,就一个裸调用。
这里我直接用requests库写一个极简示例,兼容OpenAI格式的接口:
import requests def chat_once(messages, api_key, base_url="https://api.your-provider.com/v1", model="gpt-4o-mini"): resp = requests.post( f"{base_url}/chat/completions", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" }, json={ "model": model, "messages": messages, "temperature": 0.7 }, timeout=120 ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] if __name__ == "__main__": reply = chat_once( [{"role": "user", "content": "你好,请一句话介绍你自己"}], api_key="sk-你的密钥" ) print(reply)跑通这段代码,你的“大脑”就能工作了。这里有几个细节我要特别叮嘱:
- 所有接口参数,包括api_key、base_url、model,我强烈建议单独放到config.py里,不要硬编码在主程序里。我在这个项目里就吃过亏,调试的时候改模型名还要翻源代码,非常痛苦。
- timeout参数一定要设置,否则网络波动时程序会一直挂着,看起来像死机。我亲眼见过学员等了三分钟没反应,以为代码写错了,其实只是请求还在路上。
- 几乎所有兼容OpenAI格式的服务都支持这个调用方式,只是模型名不一样。你换成any对应模型都能跑。
这一步跑通以后,恭喜你,Agent的地基已经打好了。
3. 给Agent装上“手脚”:函数调用机制
3.1 工具调用是怎么回事
现在模型虽然能“说话”了,但它还是干不了任何实事。比如我让模型“计算一下12345乘以6789”,它可以硬算,但一旦数字变大、精度变高,它就会开始犯错。更不用说查天气、读文件这类完全超出它能力范围的事。
所以我们要给它装上工具。思路其实很简单:
第一步,我们把一堆“工具说明书”塞给模型,告诉它:你有这些工具可用,每个工具叫什么、有什么参数、是干什么的。
第二步,模型看完用户的问题,自己决定“这个问题需要用工具X”,然后输出一个结构化的调用指令。
第三步,我们作为Agent的调度者,解析这个指令,去执行对应的Python函数,拿到结果。
第四步,把结果返回给模型,让模型结合结果继续生成回答。
这个过程就是函数调用的核心。在代码实现上,我选择用装饰器来自动生成工具说明书,这样每新增一个工具,只需要写一个函数并加上装饰器,注册表会自动更新。
3.2 实现一个工具注册器
下面这段代码实现了一个极简工具注册器。核心思想是:用一个全局字典保存所有已知工具,装饰器负责把函数的名称、描述、参数定义和实现本体都注册进去。
import inspect import json TOOL_REGISTRY = {} def tool(name=None, description="", parameters=None): def decorator(func): nonlocal_name = name or func.__name__ TOOL_REGISTRY[nonlocal_name] = { "type": "function", "function": { "name": nonlocal_name, "description": description, "parameters": parameters or { "type": "object", "properties": {}, "required": [] } }, "impl": func } return func return decorator你可以看到,我把函数的实现也塞进了注册表里,键名是“impl”。这样工具定义和执行逻辑就绑在了一起,注册新工具变得非常轻松。
下面注册两个简单工具,一个算加法,一个获取当前时间:
import datetime @tool( description="计算两个整数相加的结果", parameters={ "type": "object", "properties": { "a": {"type": "integer", "description": "第一个整数"}, "b": {"type": "integer", "description": "第二个整数"} }, "required": ["a", "b"] } ) def add_integers(a: int, b: int) -> int: return a + b @tool( description="获取当前的日期和时间", parameters={ "type": "object", "properties": {} } ) def get_current_time(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")3.3 让模型输出结构化调用指令
工具定义好了,接下来要做的就是告诉模型这些工具的存在。在OpenAI兼容API里,有一种更原生、更稳的方式叫tools参数,直接把工具定义传进去,模型输出时会多出一个tool_calls字段。
如果接口不支持原生的工具调用,也可以退而求其次,在system prompt里把工具说明书以JSON形式写出来,要求模型以固定JSON格式回复。但那样容易出现格式漂移,能用原生tools参数尽量用原生。
下面是带工具的大模型调用函数:
def chat_with_tools(messages, tools=None): payload = { "model": config.MODEL_NAME, "messages": messages, "temperature": 0.2 } if tools: payload["tools"] = tools resp = requests.post( f"{config.BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {config.API_KEY}"}, json=payload, timeout=120 ) resp.raise_for_status() return resp.json()["choices"][0]["message"]注意我在这里把temperature调低到了0.2。原因是工具调用属于逻辑决策任务,不需要太多创造性,温度高容易让模型输出不稳定的JSON或凭空想象不存在的工具名。这是我在实践中学到的一个小技巧。
3.4 执行工具并返回结果
拿到model返回的message之后,我们要判断它有没有要求调用工具。如果有,就提取工具名和参数,去TOOL_REGISTRY里找到实现,执行,然后把结果拼成一条role为tool的消息喂回给模型。
def run_agent_loop(messages): message = chat_with_tools(messages, tools=list_tool_definitions()) messages.append(message) while message.get("tool_calls"): for call in message["tool_calls"]: func_name = call["function"]["name"] func_args = json.loads(call["function"]["arguments"]) func_impl = TOOL_REGISTRY[func_name]["impl"] result = func_impl(**func_args) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": str(result) }) message = chat_with_tools(messages, tools=list_tool_definitions()) messages.append(message) return messages这个while循环是Agent的灵魂所在。它打破了大模型“一次问答”的限制,让模型可以根据工具结果连续思考:如果结果还不够,它可以再调下一个工具,直到它认为已经可以回答用户为止。
我在课程里让学员做一个实验:让Agent查询当前时间,再把当前时间转换成“今天是星期几”。如果你只做一轮工具调用,你会发现模型根本算不出星期几,因为它没法从时间戳里直接推导出日期名称(实际上它能算,但容易错)。而有了循环,Agent可以先调用get_current_time拿到时间,再调用另一个星期计算工具或者自己的常识推理来回答。这就体现了Agent“多步执行”的价值。
4. 让Agent有记忆:短期记忆与长期记忆
4.1 没有记忆的Agent有多尴尬
一个没有记忆的Agent,每次对话都是“失忆”状态。你跟它说“我叫小王,帮我记住”,它答应用户,但你下一轮问“我姓什么”,它完全不记得。因为大模型本身不保存状态,它只能看到你当前传给它的这一串消息。
记忆是Agent从“能干活”到“好用”的关键一步。我在课程里把记忆分成两层来教:
- 短期记忆:当前这轮对话里,我们需要传给模型的全部消息,可以简单理解成“上下文窗口”。
- 长期记忆:跨会话保存的关键事实,比如用户偏好、历史数据、项目背景。
4.2 短期记忆:消息列表与滑动窗口
短期记忆最基本的形式就是消息列表。在上一节的代码里,我一直在维护一个messages列表,每次把用户、助手、工具的消息追加进去。这就是最朴素的短期记忆。
但短期记忆有一个现实问题:上下文长度有限。即使是最新的模型,也不可能无限制地塞消息。比如模型上下文窗口是128K token,你把一本小说塞进去,它照样会报错。而且越长的上下文,推理越慢、费用越高。
所以实际操作中,我习惯给短期记忆加一个滑动窗口。只保留最近N条消息,超出范围的旧消息就丢弃或压缩。下面是简化版的实现:
def trim_messages(messages, max_tokens=8000): # 估算每条消息的token数:粗略按字符数除以3~4 total_tokens = sum(len(m["content"]) / 3 for m in messages) while total_tokens > max_tokens and len(messages) > 2: # 永远保留首条system消息 if messages[0]["role"] == "system": removed = messages.pop(1) else: removed = messages.pop(0) total_tokens -= len(removed["content"]) / 3 return messages这个实现思路很直白:从头部删旧消息,直到总长度降到阈值以下。它有明显的缺点,比如可能会把关键信息也删掉,但对于学习项目来说,先跑通逻辑比优化策略更重要。
4.3 长期记忆:极简向量检索
跨会话记忆我用的方案是“向量化+相似度检索”。意思是你把用户说的关键信息,比如“我喜欢喝美式咖啡”,转成一串向量数字,然后存起来。下次用户再来,系统把新问题也转成向量,和之前存的所有向量算相似度,找到相关度最高的几条记忆,把它们塞进prompt里。
这里有一个不依赖外部向量数据库的极简实现思路:
def embed_text(text): # 调用embedding接口,拿到向量 resp = requests.post( f"{config.BASE_URL}/embeddings", headers={"Authorization": f"Bearer {config.API_KEY}"}, json={"model": config.EMBED_MODEL, "input": text} ) return resp.json()["data"][0]["embedding"] def recall_memory(query, memory_store, top_k=3): if not memory_store: return [] q_vec = embed_text(query) scored = [] for item in memory_store: vec = item["embedding"] score = cosine_similarity(q_vec, vec) scored.append((score, item["text"])) scored.sort(reverse=True) return [text for _, text in scored[:top_k]]你可以把memory_store简单地做成一个Python列表,程序退出前序列化到JSON文件里。这虽然谈不上“高可用”,但对学习而言,完全够用,而且能让学员彻底理解“向量检索”到底在做什么。
我在课程里会安排一个练习:让Agent记住用户的生日,隔几轮对话后问“我的生日是哪天”。如果没做长期记忆,Agent一脸茫然;做了长期记忆后,它会把生日从记忆库中召回,并写进prompt,轻松答对。这一瞬间的体验反馈非常强烈。
5. 让Agent学会规划:ReAct思考模式
5.1 为什么需要“先想后做”
到现在为止,Agent已经能调用工具了,但它的能力上限还是很低,原因在于它只是“碰到问题就工具”,缺少“我先想想该怎么做”这个环节。
打个比方,现在有个人问Agent:帮我计算A城市到B城市的距离,顺便告诉我两地时区差多少。正确的做法是:先调用距离计算工具,再调用时区工具,但Agent可能会一上来乱调工具,或者只调了一个就停手。为了让Agent具备合理安排步骤的能力,我们需要引入ReAct模式。
ReAct是Reasoning and Acting的缩写,核心思想是让模型在每一步都输出三种信息:
- Thought:我现在的思考,当前情况是什么,下一步该做什么。
- Action:我决定执行哪个工具,参数是什么。
- Observation:工具的返回结果。
模型每轮先“想”,再“做”,看到“观察”结果之后再“想”,形成一个完整的循环,直到最终输出Final Answer。这个模式在业内已经被验证了非常长时间,效果稳定。
5.2 在Prompt里注入ReAct格式
如果接口支持原生tools参数,其实我们不需要完全套用ReAct的文本格式,因为工具的调用过程本身就隐含了“行动-观察”循环。但为了让初学者彻底理解推理过程,我在课程里更倾向于用纯文本prompt的方式演示一轮,再切换到原生tools方式。
下面是纯文本prompt的模板简化版:
你是一个会使用工具的智能助手。你需要根据用户的问题,按以下格式输出: 思考:仔细分析问题,决定下一步该做什么。 行动:调用工具:工具名(参数) 观察:等待工具返回结果。 ...(重复思考-行动-观察) 当你不再需要调用工具时,输出: 最终答案:...我在课程中的演示会把这套模板和上一节的tools原生参数结合起来:既有原生的工具调用能力,又在system prompt里提示模型“先在工具不足时调用工具,最终给出简明答案”。这样一来,模型输出的结构本身就体现了“思考-行动”的过程。
5.3 多轮循环的完备实现
下面是我在课程里给学员的一个完整的Agent循环骨架,它整合了“思考-行动-观察”和终止条件:
MAX_STEPS = 8 def run_agent(user_input): messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages.append({"role": "user", "content": user_input}) for step in range(MAX_STEPS): print(f"--- 第{step + 1}步 ---") message = chat_with_tools(messages, tools=list_tool_definitions()) messages.append(message) # 如果模型没有要求调用工具,视为结束 if not message.get("tool_calls"): return message["content"] # 如果有工具调用,逐个执行 for call in message["tool_calls"]: func_name = call["function"]["name"] func_args = json.loads(call["function"]["arguments"] or "{}") print(f"[工具调用] {func_name}({func_args})") func_impl = TOOL_REGISTRY[func_name]["impl"] result = func_impl(**func_args) print(f"[工具结果] {result}") messages.append({ "role": "tool", "tool_call_id": call["id"], "content": str(result) }) return "达到最大步数,Agent已停止。"MAX_STEPS是个非常关键的保护参数。没有它,一旦模型发了疯,陷入工具调用死循环,不仅浪费token,还可能卡死程序。我见过有些学员因为忘了加这个限制,跑了几百次无意义的API调用,账单直接爆了。
从一个真实体验来说,步数上限设成8足够覆盖绝大多数教学案例。真实生产环境可以根据任务复杂度调整,但一定要记得加这个上限。
5.4 一个完整的实战演示
为了让学员看到这些代码合在一起能做什么,我让他们运行如下场景:
用户输入:帮我看一下现在几点了,然后告诉我再过3小时是什么时间。
Agent执行流程:
- 模型看到用户需要一个时间戳,调用get_current_time工具。
- 工具返回:2024年12月20日 14:30:25。
- 模型看到结果,发现自己还需要“加上3小时”的能力,但没有对应工具,它就自己用时间计算逻辑算出了17:30:25,并直接给出最终答案。
这里有一个关键认知:工具调用不是目的,目的是让Agent有依据地完成任务。有些任务它可以直接在推理中完成,不一定要调工具。能在工具边界内完成任务,那也是合格的表现。
6. 常见问题与排查技巧实录
手写Agent的过程中,每个人都会踩进同一个坑。我把课程里学员高频遇到的问题和对应的排查思路整理成下面这张表,方便你对照着排查:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 模型输出非法JSON,工具参数解析报错 | 温度过高或prompt不够明确 | 把temperature降到0.2以下,检查工具参数定义是否规范 |
| 上下文越来越长,响应变慢 | 没有截断工具结果,也没有清理历史消息 | 加滑动窗口,对工具输出截断摘要 |
| 模型一直重复调用同一个工具 | 缺少最大步数限制或工具结果无法帮助它决策 | 加MAX_STEPS保护,检查工具返回值是否真的有用 |
| 接口请求偶尔超时 | 网络不稳定或API负载高 | 加重试机制和指数退避,提高timeout |
| 工具名对不上 | 注册表和prompt里的工具定义不一致 | 始终从TOOL_REGISTRY动态生成工具定义,不要手写 |
下面我挑几个高频率问题展开说一下。
工具结果太长是我在课程里强调最多的坑之一。比如写一个“读取整个文件并返回内容”的工具,文件有几万字,塞进messages之后直接把上下文撑爆,后续所有对话都会变得又慢又贵。解决办法很直接,在工具内部就对返回内容做截断,只保留前几百个字,再附上一句“如需查看更多请调用读取更多”。千万别把完整内容一股脑丢给模型。
另一个高频问题是“模型把参数类型搞错”。比如工具定义要求integer,模型给了字符串“123”,代码执行时直接TypeError。我在工具执行外层加一个校验层,把arguments里的值强制转换成定义里声明的类型。虽然有点暴力,但能预防绝大多数类型错误。
最后,调试时我强烈建议你给每一步都打印日志,包括发送给模型的message数量、工具调用参数、工具结果、当前步数。很多学员嫌打印太啰嗦,结果出了问题根本不知道模型内部做了什么选择。我自己调试时不仅打印,还会把每一轮messages完整保存成JSON文件,方便回放分析。这套“日志优先”的习惯,能让你排查问题的效率翻倍。
结尾:手写一遍,胜过读十遍框架文档
这套跟完以后,你应该已经能熟练搭建一个包含工具调用、记忆管理、多步循环的Agent了。我个人体会是,手写Agent的过程最大的价值不在于那份代码有多优雅,而在于你亲手经历过“模型输出乱JSON——你修prompt——又乱——你改温度——终于稳定”这类真实的折腾。这些折腾才是你真正理解Agent的学费,也是你未来用任何框架时能快速定位问题的底气。
如果你学有余力,我建议你往这几个方向继续扩展:把当前极简的JSON文件记忆库替换成SQLite或真正的向量库;给Agent加上并行工具调用能力,一次模型输出同时执行多个工具;再往下可以尝试让Agent自己学会拆分任务,把一个复杂任务拆成多个子任务并跟踪执行状态。这些东西看起来高大上,但底层思路都逃不出这篇文章里最基础的循环。
最后再分享一个小技巧:写Agent代码时,别急着追求“聪明”,先追求“看得见过程”。一个每步都清清楚楚、出错能给出明确日志的Agent,比一个偶尔惊艳但经常莫名失灵的Agent可靠得多。这个习惯,能让你在这条路上走得很稳。