从零手写最小AI Agent:核心循环与工具调用全解析 AI Agent学习日记写到Day4状态比前两天踏实了不少。前三天我基本在赶进度第一天搞懂了大模型对话API的基本格式、token和Prompt工程第二天啃完了Agent的基础概念理解了“工具调用”“记忆”“规划”这些词的大致含义第三天跟着教程跑通了一个现成Agent框架的Demo还给Agent配了两个工具让它帮我查天气、算数据。问题也出在第三天——框架确实能跑但日志刷出来的时候我心里全是疑问到底是什么在决定“该查天气了”参数是怎么从一个函数传到另一个函数的为什么Agent算完数据之后还记得我最开始问的是什么所以Day4我做了一个决定把框架暂时放一边只用几十行Python从零手写一个最小可用的Agent。这篇文章就是Day4的记录目标是彻底讲透Agent最核心的“思考→调用工具→观察结果”循环代码会完整给出不藏私。1. 为什么第4天我宁可先放下框架1.1 前三天到底学了什么第一天是最基础的搞清楚什么是token什么是completion怎么样在本地用API把一个Prompt发给大模型再把返回的文本打印出来。那天我最大的收获是知道大模型本质上是一个“概率文本生成器”你给它一段文字它基于训练学到的规律继续生成后面的文字。第二天进入Agent的正题。我把“Agent”的定义捋了一遍与普通ChatBot的区别在于它能自己做决策、调用外部工具、感知结果、调整下一步动作。当时我在笔记本上写了一个自认为很精准的总结——“Agent LLM大脑 工具手脚 循环行动框架”但现在回头看这句话只是记住了并没有真正理解。第三天用的是某个现成的Agent框架。我按照教程配置了一个Agent挂上两个工具跑起来确实很快。但越是“开箱即用”我越觉得心虚。我看不到“谁调了谁”的细节遇到问题唯一能做的就是改Prompt重试。那天结束后我列了一个问题清单工具Schema是怎么被模型看到的模型返回的tool_call谁去执行执行完结果通过什么方式回到模型于是Day4干脆自己造一个简单的轮子。1.2 框架带来的“认知黑洞”用框架最大的问题不是它不好而是它把关键路径封装得太干净。你在框架里注册一个函数框架自动帮你生成Schema自动处理一轮又一轮的对话历史自动把工具结果塞回上下文。你只看到最后输出了一个答案但中间每一步是怎么衔接的完全是个暗箱。我可以类比一下框架像是给你一辆能自动驾驶的车。你上车、输入目的地、车自己开到了。你坐得很舒服但让你自己把车拆了再装回去你根本不知道发动机怎么点火变速箱怎么换挡。如果你想做Agent方向的开发这种状态是危险的。因为真实项目里你会遇到各种边缘情况模型返回了不规范的参数、工具执行报错、上下文撑爆、模型在一个死循环里反复调同一个工具。这些问题框架帮不了你你必须懂原理自己去处理。所以Day4我给自己定的规则很死不装任何Agent框架不用任何封装好的Agent类只用基础的HTTP调用和Python函数手写一个能让模型调用工具并继续思考的最小循环。代码量预计100行以内。1.3 手搓Agent到底是在搓什么先说结论手搓一个最小Agent本质上是写一个短小的while循环。这个循环大致长这样——把用户的原始问题拼上系统提示词发给大模型模型返回两种情况要么直接给出最终回答要么要求调用某个工具如果是要调工具我们在本地代码里真正执行这个函数把返回结果转成文本塞回消息列表带着新的工具结果再次调用模型重复上面步骤直到模型认为信息足够、直接给出最终回答或者达到我们设置的最大步数。这就是Agent最原始的内核。所有花里胡哨的框架围绕的其实就是这个循环只是在记忆管理、工具编排、规划策略、多轮对话维护上做了更多增强。所以想理解Agent别从增强开始先把这个最原始的内核剪出来看清楚它怎么运转。2. 核心循环拆解想清楚“思考、做事、看结果”这三步2.1 大模型只负责“想”不负责“做”这是我在Day4最开始给自己重复了十遍的一句话。很多人误以为Agent是大模型自己动手操作了什么其实不是。大模型的能力边界非常清楚它只负责“生成文本”。你给它一段Messages它返回一段内容。即便它“调用工具”本质也只是在返回的文本里夹带了一个结构化的请求比如“我想调用calculator这个工具参数是expression: 14 * 2”。这个请求并不会自己执行。真正执行计算、去查天气、把结果拿回来的是模型之外的一段普通代码。我把这种配合叫“决策和执行分离”。模型的角色像一个团队里的负责人它根据掌握的信息决定“下一步应该做什么”我们的代码像执行层负责真正把事办成并把结果汇报给负责人。负责人看完汇报再继续决策直到它觉得任务完成。明白这一点之后看框架的日志就不再玄学了。日志里每一行“tool call”其实都包含两个部分模型发了个调用申请你的程序执行了这个申请并把结果写回上下文。仅此而已。2.2 用一次查天气的过程理解循环举个例子假设用户问“北京今天适合穿羽绒服吗”Agent内部可能发生这样几轮对话。第一轮模型看到这个问题判断“我不知道实时天气”于是输出一个工具调用请求查询某城市天气。我们本地代码收到请求真正去天气接口拉数据把结果包装成一条消息回给模型。这条消息里写北京今天晴最高温零下2度西北风4级。第二轮模型看到天气数据结合常识判断零下2度且风大适合穿羽绒服。于是它不再调用工具直接生成最终回答建议穿厚款羽绒服最好再戴个帽子和围巾。整个过程模型没有主动访问网络真正查天气的是我们代码里那个函数但“根据天气回答是否穿羽绒服”是模型做的推理判断。这就是Agent和普通聊天最本质的区别它有“感知外部世界→行动→获得反馈→继续推理”的能力而聊天机器人只有嘴没有手。2.3 终止条件与死循环防护写循环最怕什么怕它停不下来。模型如果一直在“调用工具→看到结果→继续调用工具”里绕圈你的账单和Latency都会很难看。所以Agent循环一定需要两个终止条件。第一个终止条件是自然的模型返回的内容里不再包含工具调用请求只有最终回答文本说明它认为任务已经完成此时循环结束。第二个终止条件是人设的保险丝max_steps。我在代码里设成6意思是最多循环6轮超过就直接停止并报错。这能防止模型因为某种原因陷入死循环。顺便提醒一句max_steps设多大要看任务复杂度。只查一次天气2到3步就够需要多工具配合的任务可以放大到8到10。但不要无脑设成100工具结果越多上下文越长后面的每一次调用都在燃烧tokens成本也会失控。2.4 工具注册模型看到的和真实运行的可能是两套东西关于工具调用我第三天的疑惑点之一是“模型怎么知道有哪些工具可以用”。Day4我翻开了底层协议才彻底明白你在框架里注册函数时框架会把这个函数翻译成一份JSON Schema然后跟着请求一起发给模型。这份Schema描述的是“工具长什么样”函数名、功能说明、参数名、参数类型、哪些参数必填、分别是什么意思。模型不看函数源码只看这份说明书。所以工具描述写得好不好直接决定模型会不会在正确时机调用它。但这又带来一个容易混淆的点模型看到的“calculator”是一个符号化的名字和描述而真实执行时我们的代码拿这个名字去一个本地字典里找到真正的Python函数然后调用。也就是说Schema是模型侧的说明书真实函数是执行侧的实体中间靠名字关联起来。这个映射关系Agent框架帮你藏了起来手写的时候必须自己维护。3. 手写一个最小Agent代码全放送3.1 环境准备与LLM接入我的运行环境是Python 3.10额外用到了requests库发送HTTP请求。为了可复现我直接把LLM接入封装成一个函数通过环境变量指定接口地址和模型名。很多推理服务都提供通用的Chat Completions协议所以下面这层封装不绑定特定的服务商你在本地起一个兼容该协议的推理服务或者用任何支持该协议的云服务都能跑通。import json import os from datetime import datetime import requests LLM_BASE os.environ.get(LLM_BASE, http://127.0.0.1:8000/v1/chat/completions) MODEL_NAME os.environ.get(MODEL_NAME, local-demo-model) def call_llm(messages, toolsNone): payload { model: MODEL_NAME, messages: messages, temperature: 0.2, } if tools: payload[tools] tools payload[tool_choice] auto resp requests.post(LLM_BASE, jsonpayload, timeout120) resp.raise_for_status() return resp.json()[choices][0][message]两点说明temperature我刻意调成了0.2目的是让模型尽量稳定地按协议输出少一点随机发挥timeout设成120秒是因为一些工具任务可能需要多次内部推理短了容易误报超时。后面踩坑部分我会详细说temperature过高会发生什么。3.2 两个练手工具取本地时间和计算器我给Agent准备了两个不需要额外依赖的工具。第一个是获取当前本地时间纯本地函数任何人复现都能运行第二个是一个简单计算器能处理加减乘除和括号。这两个工具足够演示“多工具协作”的场景比如我问“现在几点了把小时数乘以2得到一个新数字”Agent需要先调时间工具再调计算器中间还有一次结果回传。def get_current_time(): 获取当前本地时间 return {now: datetime.now().strftime(%Y-%m-%d %H:%M:%S)} def calculator(expression): 计算数学表达式的结果支持 - * / 和括号 # 只允许数字、四则运算符、括号和空白 if not re.fullmatch(r[\d\s\-*/().], expression): return {error: 表达式包含不支持的字符} try: result eval(expression, {__builtins__: {}}, {}) except Exception as exc: return {error: str(exc)} return {result: result}注意这里的eval仅用于本地演示。生产环境绝对不能直接eval用户输入会带来非常严重的注入风险。如果要做一个可靠的计算器我建议用AST解析或者专门的计算库把允许的节点类型白名单化再执行。我在演示代码里已经用正则限制了字符集同时清空了内置函数但这两层只能挡住最简单的攻击不要把它当成安全方案。3.3 工具JSON Schema给模型看的“使用说明书”要让模型知道可以调用什么工具得按协议把工具描述成JSON Schema。这一步也是“框架在背后偷偷帮你做的事”。我手动写了一遍之后才明白为什么工具描述那么重要模型是根据这段文字去决定调用时机的。TOOLS [ { type: function, function: { name: get_current_time, description: 获取当前本地日期和时间不需要任何参数, parameters: {type: object, properties: {}}, }, }, { type: function, function: { name: calculator, description: 计算数学表达式的结果支持四则运算和括号。输入必须是合法的表达式例如 13 * 2, parameters: { type: object, properties: { expression: {type: string, description: 待计算的数学表达式} }, required: [expression], }, }, }, ]写这份Schema我踩的第一个感知上的坑是模型并不执行函数它只负责生成“符合这个Schema的参数”。它可能生成 expression 13 * 2但不会真的去计算。真正计算发生在我的Python函数里。所以Schema是双份约定模型用它来生成参数我的代码用它来校验和映射参数。两边必须对得上。3.4 核心循环run_agent完整实现下面是整个Day4最核心的代码。我把工具名到真实函数的映射放在一个字典里然后写一个run_agent函数里面维护一个messages列表循环发请求、判断是否要调用工具、执行工具、把结果回填到消息列表。TOOLS_MAP { get_current_time: get_current_time, calculator: calculator, } SYSTEM_PROMPT ( 你是一个AI助手。如果用户的问题需要实时信息或计算请先调用工具。 工具返回后基于工具结果继续思考和回答。 不要编造工具结果。如果不需要工具就直接回答用户。 ) def run_agent(user_question, max_steps6): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_question}, ] for step in range(max_steps): message call_llm(messages, toolsTOOLS) messages.append(message) if not message.get(tool_calls): print([Agent] 最终回答, message[content]) return message[content] for tool_call in message[tool_calls]: name tool_call[function][name] arguments json.loads(tool_call[function][arguments]) print(f[Step {step 1}] 调用工具{name}参数{arguments}) result TOOLS_MAP[name](**arguments) print(f[工具结果] {result}) messages.append( { role: tool, tool_call_id: tool_call[id], name: name, content: json.dumps(result, ensure_asciiFalse), } ) print([Agent] 达到最大步数停止。) return None if __name__ __main__: run_agent(现在几点了把这个小时数乘以 2得到一个新数字。)这个函数里有几个细节我后来复盘时觉得特别关键第一messages列表是贯穿整个循环的状态容器。用户的原始问题、模型每次的输出、工具返回的结果全都被追加进去再作为下一轮的输入发给模型。这保证了模型“记得住”自己刚才调过什么工具、得到过什么结果。第二每个工具结果的message必须带tool_call_id。这个ID来自模型刚才发出的tool_call请求它相当于一个关联凭证让模型知道这个结果是对应哪一次调用。少了它绝大多数的接口都会直接报错。第三执行工具用的是TOOLS_MAP[name]相当于一个动态分发。模型传过来的工具名是字符串我们用它去查字典、拿到真实函数、再用**arguments展开参数调用。这就把模型侧的抽象符号和真实执行对齐了。3.5 跑一次真实任务盯着中间过程看我拿“现在几点了把这个小时数乘以2得到一个新数字”这个问题去跑。注意这个问题故意设计成需要连续调用两个工具先取时间再从时间里拆出小时数然后计算乘2。运行日志大致是这样的[Step 1] 调用工具get_current_time参数{} [工具结果] {now: 2025-01-15 14:27:03} [Step 2] 调用工具calculator参数{expression: 14 * 2} [工具结果] {result: 28} [Agent] 最终回答 现在时间是14点27分小时数是14乘以2以后是28。看这个日志Agent的循环逻辑就很直观了。第一步模型发现“现在是几点”无法靠自身知识回答于是发起get_current_time调用我们代码执行完把结果塞回messages。第二步模型看到当前时间是14点27分它自己完成了“提取小时数14”这个简单推理然后认为“14 * 2”需要用计算器再次发起调用。最终拿到结果28它觉得足够了就开始组织自然语言回答。最让我有感觉的是第二步里“14”这个数字的提取。没有任何代码去解析这个时间字符串是模型自己从工具结果里读出来的。这说明工具结果只要以文本形式回到messages里模型就能理解并利用它。这也是为什么工具返回的content建议用JSON转成字符串模型看到的是文本让它从结构化的文本里取信息比直接塞一个Python对象更符合它的输入方式。3.6 如果模型不支持原生工具调用用文本协议也能顶住原生工具调用协议很香但不是所有模型都支持。Day4我还顺手测了一下手边的某个本地小参数模型它对tools参数几乎无感直接忽略。这时候还有第二种方案让模型在回答里输出结构化的文本块我们自己解析。思路是改造System Prompt要求模型在需要工具时严格输出如下格式{thought: 我需要先获取当前时间, action: get_current_time, action_input: {}}然后代码如下import re TEXT_PATTERN re.compile(r\{.*?\}, re.DOTALL) def parse_action(content): match TEXT_PATTERN.search(content) if not match: return None return json.loads(match.group(0))拿到解析结果后做和原生工具调用一样的事情执行函数、塞结果回messages、再次调用模型。区别只是少了几行协议层的自动解析多了一部分自己维护的字符串解析逻辑。这个方案是文本版ReAct的雏形也是很多早期Agent项目跑通的方式。理解了它你再看任何框架的“agent internals”日志都会觉得亲切许多。4. Day4踩坑实录每一个都值得写进笔记4.1 把arguments当成JSON对象直接用我第一个踩的坑就是这个。模型返回的tool_calls里function下层的arguments是一个字符串例如{expression: 14 * 2}。我第一次写代码时觉得“这看起来就是对象嘛”直接**arguments展开结果TypeError扑面而来。原因是协议里为了兼容性把arguments序列化成了字符串。你必须先json.loads一下再传给函数。后来我建议你在loads外面套一层try-except。因为模型偶尔会生成不合法的JSON比如多一个逗号、漏一个引号。如果解析失败可以把“你的参数不是合法JSON”作为一条错误信息回传给模型让它重新生成调用请求。这样比直接让程序崩溃聪明得多。4.2 忘记回传tool_call_id接口直接报错第二个坑在第一次手写循环时几乎必踩。我给messages追加工具结果时只写了role、name、content忘了写tool_call_id结果接口返回状态码400提示工具结果与调用无法匹配。这个ID的作用是关联模型在上一轮发起了一个调用请求每个请求都有自己的ID。工具结果带上这个ID模型才能知道“这个返回是对应我哪个调用的”。如果你不传它就不知道哪个工具执行完了。所以后面一旦遇到类似报错我第一反应就是检查工具结果消息里有没有tool_call_id字段名拼错没有ID有没有拿错。4.3 模型不调工具或者反复调同一个工具Day4的另一个头疼点是在某些测试里模型明明该调计算器却直接凭空口算了一个错误答案另一些测试里它又像魔怔了一样反复调同一个工具哪怕结果已经拿到了。排查方向我是这样整理的现象排查方向一句话解法明明需要工具却不调工具描述不够具体问题中隐含的“计算/查询”信号弱在description里写清楚“什么时候用”和“输入格式”反复调同一个工具temperature过高工具返回信息不足以触发下一步决策把temperature降到0.2左右查看工具结果是否完整回传编造工具返回值system prompt没强调禁止编造明确写“不要编造工具结果只能基于返回内容回答”传了明显不合理的参数参数描述不清楚required字段不完整在Schema里把每个参数的语义和格式写清楚还有一个细节工具描述里的“什么时候不要用”往往被人忽略。比如计算器只处理数学表达式你如果不写清“只处理表达式不处理单位的换算”模型可能在用户问“2米加3分米等于多少”时傻傻地把23传给计算器。描述写清楚边界工具被误用的概率会低很多。4.4 工具返回巨大上下文长度爆掉这是Agent工程里躲不开的问题。单个工具返回几十行甚至上百行的JSON几次调用下来messages里的文本量急剧膨胀。我第三次跑测试时就碰上了模型连续查了几个工具每个结果都塞回上下文第4轮还没跑完接口就报超出上下文长度限制。我Day4用的解法比较朴素限制历史长度。比如维护一个“最近N轮消息”的滑动窗口只保留最近的几轮工具结果和模型输出丢弃最早的。另外对于大型工具返回可以先在代码层做一个摘要比如只保留关键字段而不是把整个JSON原样塞回去。甚至有经验的开发者会把大结果先丢给模型做一个压缩再把压缩后的文本存进上下文。这些都属于记忆管理的范畴Day5我会继续深入。但你从Day4起就该知道无限往messages里追加数据是撑不久的。4.5 工具执行抛异常时别让Agent整个崩掉工具函数是普通代码当然可能抛异常。我第一次测试时计算器收到一个格式奇怪的表达式抛了异常程序直接traceback结束挺尴尬的。后来我改成在函数内部try-except把异常信息转换成{error: ...}返回。这样模型看到的是“工具执行失败”的文本它会自己判断是重新尝试还是向用户说明。这里隐藏着一个Agent设计的重要原则工具失败不应该让整个对话崩溃。把异常当成一种普通结果回传到模型层让模型来决策比鲁莽地中断循环更合理。这也是Agent鲁棒性的来源之一。框架里那些“优雅降级”“自我纠错”的能力追根溯源就是在这一层做了处理。5. 收尾今天这个小循环明天能长出什么5.1 最大的认知更新Day4跑通这个最小Agent之后我对之前那些概念的理解发生了质变。以前我以为Agent复杂是因为框架把编排、记忆、规划这些词堆在一起显得很高深。现在亲手写了一遍循环我发现最底层的骨架其实极其简单一个while循环、一个messages列表、一个工具映射字典没了。所谓框架的“智能体能力”本质上是在这个骨架外面做了更多轮的策略控制它可能帮你裁剪历史、自动生成子任务计划、在多步骤之间插入反思节点、或者同时维护多个对话上下文。但这些都不能改变核心循环的本质——模型决策一次我们执行一次结果再喂回模型。有了这个认知以后看任何框架的源码都有切入点。5.2 Day5之后想继续扩展什么今天这个小循环已经能处理“取时间→计算→回答”这种多工具协作的场景。但它仍然很“傻”它没有长期记忆换个会话就忘光没有任务规划复杂问题不会自动拆解没有反思机制答错了不会自我检查。这些正是我接下来想逐个攻克的。Day5我计划先做记忆层把历史消息存进一个存储并支持按关键词或向量检索后动态拼进上下文。然后会尝试给循环加一个“反思”节点在模型给最终答案之前多问一次它“这个答案有没有依据要不要先调用工具验证”。最后再把一个任务拆成子步骤逐步执行。我个人实际操作中很深的体会是学Agent一定要尽早把“模型调用层”和“执行逻辑层”分清楚。模型再怎么聪明也只负责出主意代码再怎么简单也要负责落地。这个最小Agent就是一个很好的分界线样板。如果你也在自学Agent建议不要只收藏这篇而是亲手把代码敲一遍换几个测试问题试试。等你能徒手写出这个循环再去看任何框架都只会觉得“原来是这么一回事”。