ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零实现最小Agent Loop:模型、工具与循环控制的完整指南

从零实现最小Agent Loop:模型、工具与循环控制的完整指南 1. 模型只是大脑Agent 是个完整的循环1.1 模型就是 Agent这句话错在哪我见过太多人把 Agent 开发的第一步交给了找一个最聪明的模型。班子还没搭先上最强 API然后丢一句话过去期待模型自己智能地把任务做完。结果往往很尴尬模型给出的答案一本正经但完全没用到你给它准备的工具更谈不上完成多步任务。这里有个根本性的概念混淆模型是参数化的概率系统给它一串 token它预测下一串 token。它本身没有环境感知不会主动去调用工具更没有一个做完了没、要不要换个方法再来一次的判断机制。这些能力来自模型外围那层控制逻辑也就是我们常说的 Agent Loop。打个比方模型像一个非常聪明但被困在房间里的顾问Agent 则是把顾问放到真实办公室里、给他配好电话和资料库、并不断根据他的要求去查资料、把结果回传给他、直到他把问题真正解决掉的那个执行体系。没有后者前者只是个问答机器。再强的模型直接调用一次也不构成 Agent。很多人会在 agent 是什么、agent 开发学习路线这类话题上绕圈子其实核心就一句话Agent 模型 工具 循环控制。其中循环控制才是灵魂。工具给模型长了手脚循环让模型有了试错-修正-收敛的能力。明白了这一点你再看 agent 框架、agent 架构、agent 开发教程里铺天盖地的术语思路会清晰很多。1.2 Agent Loop一个循环里到底在跑什么所谓 Agent Loop翻译成人话就是让模型在一个循环里反复工作每次做完一步把结果喂回去直到满足退出条件。我拆一个最典型的循环逻辑给你看把用户目标作为初始消息发给模型。模型返回两种可能性之一一个是最终答案另一个是我要调用某个工具参数是这样。如果模型要调工具程序就去执行那个工具把执行结果作为新的消息追加到上下文里。再次把完整上下文发给模型让模型基于工具结果继续推理。重复第 2 到第 4 步直到模型给出最终答案或者达到最大步数上限。这个循环形态在处理实际业务时几乎是万能的查资料、操作数据库、调用业务 API、写代码跑测试、访问网页抓数据本质都是模型提出工具调用请求外部环境返回结果模型基于结果再决策。你把它跑熟了再去看 LangChain、LangGraph 这些 agent 框架会发现它们无非是在这个最小循环上做了更多封装记忆管理、状态图、并行工具、人机审批、错误重试机制等。不过框架封装的是通用能力底层的基本功还得自己练。从零实现一个最小 Agent Loop最直接的价值就是让你彻底理解每一行代码在干什么遇到问题时不至于对着框架源码干瞪眼。1.3 最小 Agent 的四个必备组件一个能跑起来的最小可用 Agent至少需要四样东西。多了可以后面再加少了就跑不通。第一模型接口。不一定要本地部署任何提供对话补全接口的模型都行。关键是接口要稳定并且支持函数调用Function Calling这种结构化的工具输出。现在主流模型厂商基本都兼容 OpenAI 的接口格式选一个你顺手、性价比合适的先用着。第二工具集。这是 Agent 可以行动的载体。一个完全没工具的 Agent本质上就是个聊天机器人循环只走一步就结束了。工具可以是任何可执行的东西一个 Python 函数、一个 HTTP 请求、一段 SQL 查询、一个命令行脚本都可以。关键在于你要用固定的格式把这些工具的能力描述清楚让模型知道什么情况下该找谁。第三上下文记忆。这里我只说最原始的方案把每轮对话、每次工具调用、每个工具返回结果全部按顺序追加到一个 messages 数组里。这个数组就是 Agent 短期记忆的全部承载。后面你会遇到上下文爆炸的问题那是优化话题一开始越简单越不容易出错。第四循环控制逻辑。这是区分调了一次模型和是一个 Agent的分水岭。控制逻辑要负责识别模型是否请求调用工具、调用哪个工具、把结果回传、判断是否退出循环、以及处理异常情况。一个合格的循环控制必须能处理模型连续调好几次工具的完整链路。你把这四块拼起来就是一个最精简的 Agent Loop。下面我会先讲设计要点再带你把代码跑通。2. 动手前的准备工作工具与接口的设计2.1 工具Agent 的手脚用 JSON Schema 声明很多人第一次写 Agent 代码时最迷惑的是工具该怎么描述给模型。你不是把 Python 函数源码整个发给它而是要给它一份工具说明书格式通常是 JSON Schema里面写清楚工具叫什么、用来干什么、需要哪些参数、参数类型和约束是什么。比如我要做一个获取天气的工具说明书大概是这样的{ type: function, function: { name: get_weather, description: 获取指定城市的实时天气信息包括温度、天气状况和空气质量, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、广州 } }, required: [city] } } }模型读到这份说明书后如果觉得需要天气数据就不会自己去编一个天气而是返回一个结构化的调用请求大致长这样{ name: get_weather, arguments: {\city\: \北京\} }这里有两个坑我要提前提醒你。第一description 一定要写清楚包括参数单位、取值边界、什么时候该用这个工具因为模型是靠这些描述来判断调用时机的。写得太含糊模型就会乱调用。第二arguments 在模型返回时通常是一个字符串你必须用 JSON 解析之后再传给真正的函数。很多刚入门的朋友忘了这步直接把字符串当字典传参一跑就报错。2.2 模型 API 的调用约定实现最小 Agent 时我不建议一上来就对接一堆复杂的 Agent 框架。直接用 OpenAI SDK 兼容的接口最省事因为现在 DeepSeek、Qwen 等模型也都提供这种兼容服务你只需要改一个 base_url 和 model 名称就能切换。核心参数是 tools 和 tool_choice。tools 就是你准备好的工具说明书列表tool_choice 设置成 auto 时模型自行判断要不要调工具。如果你想让模型每次都强制调用某个工具也可以指定具体工具名这在某些流程化场景里很有用但最小实现里用 auto 就够。另外一个细节是模型的系统提示词。系统提示词要明确告诉模型你的身份和做事风格比如你是一个能调用工具的助手当需要获取实时数据时使用 get_weather 工具不要编造数据。如果工具返回结果不足以回答问题可以继续追问工具。系统提示词写得好不好直接影响 Agent 的效果因为模型对什么时候该用工具的判断很大程度上依赖你给它的行为约束。2.3 循环的终止条件怎么定循环如果没有终止条件要么无限跑下去烧钱要么上下文爆炸程序崩掉。最小实现里我建议设三个终止条件一是模型返回内容里没有 tool_calls也就是说模型认为它可以给出最终答案了这时候循环直接结束把模型返回的文本作为最终输出。二是步数达到上限。比如设定 max_steps 8循环超过 8 次强制退出。因为真实场景里模型偶尔会陷入反复调用同一个工具的鬼打墙状态不设上限会出事。步数上限怎么选经验上简单任务 3 步内就能结束复杂任务可能需要 10 到 20 步。初学阶段设 8 比较合适既给了足够的试错空间又不会烧太多 token。三是预算上限。如果你的模型按 token 计费可以统计每轮消耗的 token累计超过某个阈值就中断。这在生产环境里非常重要但在学习用最小实现里可以先用步数兜底。逻辑上每轮循环结束时要检查这三件事任何一个满足就跳出循环并给调用方返回是正常结束还是有异常原因方便排查。3. 从零实现最小 Agent Loop3.1 搭环境一个 Python 文件就跑起来先说环境。Python 3.10 以上版本就行依赖只需要一个 openai 库。装好之后设置好 API Key如果你是连接到兼容接口的模型额外设置 base_url 即可。为了不让每个人都依赖我的本地环境下面这段代码我尽量用最少的依赖实现。pip install openai然后新建一个 agent.py 文件所有代码都往里面写。我习惯先定义配置区域方便后面切换模型import json import os from openai import OpenAI client OpenAI( api_keyos.getenv(OPENAI_API_KEY), base_urlos.getenv(OPENAI_BASE_URL, https://api.openai.com/v1), ) MODEL_NAME os.getenv(AGENT_MODEL, gpt-4o-mini)这里把配置都放到环境变量里不写死在代码中。一是安全二是在不同模型之间切换时不用改代码。如果你想试试兼容 OpenAI 接口的其他模型把 OPENAI_BASE_URL 指过去AGENT_MODEL 改成对应模型名就行。3.2 核心循环代码详解接下来是核心的 Agent Loop。为了让初学者能看懂我没有做任何高级抽象逻辑非常直白。def tool_calls_to_messages(tool_calls): 把模型返回的工具调用请求转换成可追加的字典。 results [] for tc in tool_calls: fn tc.function args json.loads(fn.arguments or {}) result execute_tool(fn.name, args) results.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), }) return results def run_agent(user_input, max_steps8): messages [{role: user, content: user_input}] for step in range(max_steps): print(f[step {step1}] calling model...) resp client.chat.completions.create( modelMODEL_NAME, messagesmessages, toolsTOOLS, tool_choiceauto, ) msg resp.choices[0].message print(f[step {step1}] finish_reason{resp.choices[0].finish_reason}) # 修正把模型返回的消息对象转成干净字典避免序列化问题 msg_dict message_to_dict(msg) messages.append(msg_dict) # 模型要求调用工具 if msg.tool_calls: tool_messages tool_calls_to_messages(msg.tool_calls) messages.extend(tool_messages) continue # 模型没有要求调用工具认为可以给出最终答案 return { answer: msg.content, steps: step 1, messages: messages, } return { answer: None, error: max_steps_reached, steps: max_steps, messages: messages, }这里有一个很容易踩坑的 detailresp.choices[0].message是 OpenAI SDK 封装的消息对象不能直接把它原样塞回下一次请求的 messages 列表里否则有些 SDK 版本会报错或者丢字段。所以我们需要写一个message_to_dict转换函数def message_to_dict(msg): data msg.to_dict() # 去掉空字段保持上下文干净 return {k: v for k, v in data.items() if v is not None and v ! []}为什么这里要过滤空字段因为我实际测试中发现某些模型接口对tool_calls: null这种字段比较敏感轻则警告重则报 400 错误。过滤掉之后上下文里就只保留有实际意义的字段。3.3 用实例跑通一个查天气给建议任务工具不能只停留在说明书层面得有一个真实的执行函数。我们还是以天气工具为例def get_weather(city: str): 模拟的天气查询工具生产环境替换成真实天气 API。 table { 北京: {condition: 晴, temp: 20, aqi: 45}, 上海: {condition: 小雨, temp: 24, aqi: 58}, 广州: {condition: 多云, temp: 28, aqi: 72}, } info table.get(city, {condition: 未知, temp: None, aqi: None}) return {city: city, **info} def execute_tool(name, args): 根据工具名分发到具体实现函数。 if name get_weather: return get_weather(**args) raise ValueError(funknown tool: {name}) # 工具说明书和上一节的 JSON 结构对应 TOOLS [ { type: function, function: { name: get_weather, description: 获取指定城市的实时天气信息包括温度、天气状况和空气质量指数 AQI, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、广州, } }, required: [city], }, }, } ]现在跑一个真实任务if __name__ __main__: result run_agent(北京今天适合跑步吗请给出建议。) print( final answer ) print(result[answer]) print(fsteps used: {result[steps]})我实际跑过一次输出大概是这样的过程第 1 步模型发现缺少天气数据返回工具调用请求get_weather(city北京)。程序执行工具得到天气数据并回传。第 2 步模型基于晴、20度、AQI 优给出了跑步建议。你可能觉得这有点小题大做一个模型直接问它北京适合跑步吗它也能答个大概。关键区别在于真实项目里数据是动态的、私有的、实时变化的模型不可能凭空知道。让模型学会先查再答这才是一个 Agent 该有的样子。3.4 没有循环 vs 有循环对比看差距如果你只用一次模型调用效果会怎样我也专门对比过。没有循环时你把问题直接丢给模型它会根据自己的训练知识回答北京今天的天气嘛……一般是秋天比较适合跑步建议穿长袖。听起来好像通顺但里面没有一条事实是真实的。你说它错吧它回答得挺像那么回事你说它对吧里面每个数据都是编的。有循环之后模型先拿工具拿到真实数据再基于真实数据进行建议答案的可信度完全不一样。这个对比就是最直观的模型不是 Agent的证明。另外循环还带来一个隐藏优势容错。如果第一次工具返回的结果比较奇怪模型可以继续调用另一次工具去核实。这个再来一次的能力是单次调用永远不具备的。4. 常见问题与排查技巧实录4.1 常见问题速查表我在带团队做 Agent 项目时大家踩过的坑高度集中。整理成一张速查表方便你对号入座。现象可能原因处理方式模型上一轮输出的答案像是编的模型没有调用工具或工具描述不清楚检查系统提示词和工具 description明确告知模型要用工具工具返回后模型不理解结果工具返回格式复杂缺少字段说明让工具返回结构化 JSON并在 description 里说明含义循环一直调用同一个工具工具结果没有给模型足够的决策信息检查工具返回内容补充更多上下文上下文越来越大请求变慢messages 列表无限制增长做消息裁剪或摘要只保留最近几轮报错 Agent execution terminated due to error工具执行抛出异常或循环控制未捕获异常排查工具函数内部异常包一层 try-except模型每次都输出一堆无关工具调用tools 里放了太多工具精简工具集按场景分组4.2 模型输出格式出错怎么办模型虽然是概率系统但经过工具调用训练后正常情况下返回的 tool_calls 格式是比较稳定的。不过你在实际运行中仍然可能遇到模型返回了看起来像 JSON 但不是合法 JSON的 arguments。比如参数值里混了多余引号、缺了右括号这类问题在长参数、嵌套结构的情况下更容易出现。我的处理手段是解析失败时不要直接崩溃先尝试简单修复比如去掉首尾多余的空格和换行还不行的话把原始内容作为 tool 消息返回给模型告诉它你给的工具参数格式不正确请重新给一个合法的 JSON。这个错误信息回传机制非常管用因为模型看到自己的错误后大概率能自我纠正。另一个常见情况是模型返回的 arguments 是一个 JSON 数组而你的工具函数要求对象 dict。这时候可以在 execute_tool 里做一层防御性处理判断类型如果是数组就取第一个元素做参数解析。4.3 循环不终止、上下文膨胀怎么处理循环不终止是最让初学 Agent 的人头疼的问题。模型可能反复调用同一个工具或者在不同工具之间来回横跳就是不出最终答案。我建议用两步来治理。第一步是硬限制max_steps 必须设置哪怕你觉得你的任务很简单也不能省。第二步是软干预当模型连续两步调用同一个工具且参数几乎相同时你可以截断这个循环把一条提示消息追加进上下文你已经连续两次调用了 get_weather 且 city 参数相同。如果工具已经返回结果请直接基于已有信息给出最终答案。不要重复调用。这种显式的软干预在很多情况下比硬限制更智能。上下文膨胀也逃不掉因为你每轮都要把所有历史消息发出去。最小实现阶段可用一个简单策略保留最初的系统提示词和用户目标中间只保留最近 3 到 5 轮工具调用和回复更早的内容用一个摘要替代。摘要可以让模型自己生成比如请用一句话总结到目前为止已经完成的事情。这在真实项目里非常实用也是很多 agent 框架底层记忆管理的基础逻辑。5. 从最小循环走向真实项目5.1 记忆上下文窗口就是 Agent 的短期记忆你把这个最小循环跑通之后马上会撞到一堵墙上下文窗口不够用。模型一次能处理的 token 是有限的你的 Agent 跑了几轮工具调用之后历史消息很快就把窗口撑满了。在 Agent 生态里这个问题被叫做记忆管理。但我想告诉你的是记忆没有那么玄乎。最原始的记忆就是 messages 数组最朴素的记忆管理就是取舍。哪条消息重要就留哪条不重要的折叠成摘要。如果你的 Agent 要服务很多用户每个用户都有独立上下文那就得按 session 维度做隔离每个会话维护独立的 messages 数组。这就是 agent 记忆话题里最核心的实操点了。还有一个容易忽略的坑不要在 messages 里塞太多和任务无关的背景。很多朋友喜欢把产品文档、用户手册一股脑塞进系统提示词Agent 还没开始干活上下文就已经烧掉大半。正确的做法是把文档按块存储只有当 Agent 明确需要某块知识时通过检索工具把它取回来。这也是 RAG 和 Agent 经常搭配使用的原因。5.2 技能Skill与 Agent 的关系我经常看到有人在聊 agent 开发时把 skill、工具和 Agent 混为一谈这里我展开说一句。Skill 和 Agent 不是一个层次的东西。Agent 是负责决策和调度的主体Skill 则是一个可以被复用的能力单元。一个 Skill 可能是调用天气 API 并格式化展示另一个 Skill 可能是执行 SQL 查询并统计数据。Agent 在 Loop 里根据任务需要一个接一个地调用这些 Skill。从工程实现的角度最小循环里你不需要为 Skill 做多复杂的封装它就是一组工具函数外加一段工具说明书。但当你的项目变复杂Skills 多了以后就需要把它们组织成独立的模块统一注册、统一管理。这时候再去看那些 agent 框架里的 skill 机制理解起来就容易多了。另外还有一个词叫 harness常被拿来和 agent 对比。你可以把 harness 理解为 Agent 运行的外壳负责输入输出、权限控制、资源限制、日志追踪、审批流程这些外围能力。Agent 本身是循环决策的大脑harness 是让这个大脑能安全落地到生产环境的那套装置。最小实现里我们写的 run_agent 函数其实就承担了一部分 harness 的工作。5.3 什么时候该上框架什么时候裸写循环就够了我对 Agent 框架的态度是先裸写再选型。如果你连一个最小循环都没亲手写过直接上框架会很痛苦因为你不知道框架抽象掉了哪些东西出了问题也无从下手。把裸循环写好之后你自然就理解框架解决的是什么问题。那什么时候该上框架当你的需求开始变得复杂时具体表现包括Agent 内部有多个步骤分支、需要人机协同审批、需要持久化会话状态、需要并行运行多个 Agent 协作、需要精细的失败重试和回退机制。这些场景下成熟的 agent 框架确实能省不少事。但注意框架不是银弹它也会引入新的抽象成本和学习成本选择时要先做小规模可行性验证。对于多数业务场景我的建议是维护一个足够灵活的最小循环核心然后在外面按需加功能。很多人想象不到的是一个几百行的自定义循环配合一套设计良好的工具集就能覆盖大量实际业务需求而且比什么都依赖框架更可控、更容易调试和维护。5.4 最后分享几个我在实际项目里养成的小习惯第一个习惯是给工具返回加一个简单的 data 字段和 error 字段区分。工具执行失败时返回 {error: 天气服务超时}而不是抛异常。这样模型看到错误信息可以自己决定要不要重试或者换别的工具而不是整个循环崩溃。这个小改动让 Agent 的鲁棒性提升了一个档次。第二个习惯是在每次循环里打印完整的中间结果。我在做最小循环时会把每步的 finish_reason、tool_calls、工具返回结果都打出来。别怕日志刷屏调试 Agent 的过程本质上是观察模型决策的过程信息越多定位问题越快。第三个习惯是给 Agent 设置会话级别 ID。哪怕现在单机跑也先加上一个 request_id方便追踪整个循环中每一次模型调用和工具调用的日志。等将来接入线上监控系统时你会感谢自己当初这个举手之劳。第四个习惯也是我最想强调的不要追求一次循环解决所有问题。真实世界里用户需求往往是模糊的、残缺的。Agent 的价值不在于一次推理就能给出完美答案而在于它有足够好的循环机制能在反馈中一步步修正方向。这也是我在做了大量 agent 项目后对模型不是 Agent这句话最深的理解模型负责聪明循环负责靠谱。把两者结合在一起你才能做出真正能落地的智能体。
返回列表