资讯详情

资讯详情

手写AI Agent:从零实现工具调用、记忆与多步规划

说到手写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_urlhttps://api.your-provider.com/v1, modelgpt-4o-mini): resp requests.post( f{base_url}/chat/completions, headers{ Authorization: fBearer {api_key}, Content-Type: application/json }, json{ model: model, messages: messages, temperature: 0.7 }, timeout120 ) resp.raise_for_status() return resp.json()[choices][0][message][content] if __name__ __main__: reply chat_once( [{role: user, content: 你好请一句话介绍你自己}], api_keysk-你的密钥 ) 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(nameNone, description, parametersNone): 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, toolsNone): 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: fBearer {config.API_KEY}}, jsonpayload, timeout120 ) 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, toolslist_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, toolslist_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_tokens8000): # 估算每条消息的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: fBearer {config.API_KEY}}, json{model: config.EMBED_MODEL, input: text} ) return resp.json()[data][0][embedding] def recall_memory(query, memory_store, top_k3): 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(reverseTrue) 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, toolslist_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可靠得多。这个习惯能让你在这条路上走得很稳。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →