☰
从零跑通AI Agent最小闭环:主循环、工具调用与工程约束
2026/10/23 5:50:33 网站建设 项目流程

很多人做第一个 AI Agent 小项目,路径都惊人地相似:先花两天看各种架构图,规划出规划器、反思模块、长期记忆、多智能体协作,甚至画好了三层调度示意图;第三天打开编辑器,建了十几个文件,写了一堆抽象基类;第五天跑起来,发现它连"查一下今天的天气再决定带不带伞"这种任务都做不利索。我见过太多这样的仓库,最后停在feature/agent-core分支上再也没合并回主干。

我自己的第一个 Agent 小项目走的是完全相反的路子。整个项目只有一个入口脚本、一个工具文件、一份不到两百行的主循环,跑通之后才逐步往里加东西。这个系列我打算把自己从零搭一个能实际干活的小 Agent 的全过程摊开讲,第一篇只解决一件事:让一个 Agent 的最小闭环真正跑起来,并且跑得可控。如果你正准备动手写自己的第一个 Agent,或者已经写了一版但总是卡在"模型不按套路出牌"的阶段,这篇里的结构、代码和踩坑记录应该能直接拿去用。

1. 第一个 Agent 项目为什么必须刻意做小

1.1 大而全的第一版通常死在哪个环节

先说结论:绝大多数第一版 Agent 不是死在"能力不够",而是死在"看不见它在干什么"。

Agent 和普通的函数调用有本质区别——中间多了好几轮模型决策。传统程序你打断点就能看清每一步的输入输出,但 Agent 的每一轮决策是模型根据当前上下文重新"想"出来的,你把同一份上下文喂两次,它可能给出两个不同的工具调用顺序。这意味着一旦出错,你面对的不是一个确定的调用栈,而是一段需要回溯的对话历史。

我在早期做过一个功能相当齐全的版本:六个工具、两层规划、带记忆压缩。结果调试的时候,一个简单的任务失败了,我花了整整一个下午才定位到问题——不是模型不行,也不是工具写错,而是第二层规划器在压缩历史时把第一轮的工具返回值截断了一半,导致主循环拿着残缺的 JSON 去解析。这个 bug 在只有三个文件、单层循环的版本里根本不可能出现。

所以第一版的核心目标不是"功能多",而是任何一个失败都能在三分钟内定位到具体是哪一轮、哪个工具、哪段上下文出的问题。这个标准听起来很低,但它是后面所有扩展的地基。

1.2 一个能跑的最小 Agent 到底需要几样东西

我把 Agent 的必要组件压缩到了三件:

组件作用第一版要做到什么程度
主循环反复问模型"下一步做什么",直到它说结束支持工具调用分支和终止分支,别再多了
工具表告诉模型有哪些能力、参数长什么样三到五个工具,参数用 JSON Schema 描述
状态桶保存对话历史、中间结果、步数计数一个列表加几个标量,先用内存

规划器、反思、长期记忆这些都可以先不要。原因很直接:规划器本质上是"让模型先输出一个计划再执行",但如果你的主循环本身就跑不稳,加上规划器只会让失败点从一处变成两处。反思机制同理,它需要额外的模型调用,会放大 token 消耗和延迟,而第一版你最需要的是快速迭代而不是省钱。

工具数量我建议压在三到五个。这不是拍脑袋:工具越多,模型的工具选择准确率下降得越快,而且工具之间的语义重叠会让你很难判断失败是"选错了工具"还是"工具本身有 bug"。三到五个工具足够覆盖"读数据、算数据、写结果"这条最小链路。

1.3 我给第一版定的三条止损线

动手之前我给自己立了三条规矩,后来发现这三条比任何架构设计都管用:

  • 单次任务最多八步。超过八步还没结束,强制终止并打印完整历史。这条逼着我去检查是不是任务描述太模糊,或者工具返回值让模型陷入了循环。
  • 单个任务最多六十秒。超时直接掐断。这条防的是某个工具卡住导致整个进程挂死,也顺手帮我发现了几个同步阻塞的网络调用。
  • 任何工具失败必须把原始错误信息原样回传给模型,不允许我自己包装成"工具调用失败,请重试"这种模糊文案。这条最关键——模型拿到原始错误往往能自己纠正参数,拿到模糊文案只会一遍遍重试同样的错误调用。

这三条止损线的共同点是:它们都在限制 Agent 的自由度,而不是增加它的能力。第一版要的就是这个,先把它关进笼子里,看清它怎么动,再逐步放大活动范围。

2. 把 Agent 内核拆开:主循环、工具表、状态桶

2.1 主循环:一个 while 和三种出口

主循环的骨架短得出奇,核心就是一个 while 加三个退出条件。我把它简化成下面这个样子,用的是一个抽象的模型调用接口,具体接哪家模型不影响结构:

import json import time def run_agent(task, tools, call_model, max_steps=8, budget_seconds=60): started = time.time() messages = [{"role": "user", "content": task}] tool_specs = [t["spec"] for t in tools] for step in range(max_steps): if time.time() - started > budget_seconds: return {"status": "timeout", "messages": messages} resp = call_model(messages=messages, tools=tool_specs) # 出口一:模型不再调用工具,直接给出最终答案 if not resp.get("tool_calls"): messages.append({"role": "assistant", "content": resp["content"]}) return {"status": "done", "answer": resp["content"], "messages": messages} # 出口二:模型要调工具,执行后把结果塞回上下文 messages.append({"role": "assistant", "tool_calls": resp["tool_calls"]}) for call in resp["tool_calls"]: result = dispatch(call, tools) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False)[:4000], }) # 出口三:步数用完 return {"status": "exhausted", "messages": messages}

dispatch函数负责把工具名映射到真实函数并捕获异常。这里有个细节值得单独说:异常一定要捕获后转成结构化结果回传给模型,而不是直接抛出去终止循环。早期我图省事让异常直接冒泡,结果模型完全不知道发生了什么,用户看到的只有一段 Python traceback。改成把{"ok": false, "error": str(e)}塞回上下文之后,模型在下一轮经常能自己换一种参数重试,成功率肉眼可见地上升。

还有一点:content我截断到了 4000 字符。这个数字不是随便定的,它取决于你上下文窗口的余量,我通常按"单条工具返回不超过窗口的十分之一"来估。截断的时候最好保留头部和尾部各一部分,因为工具的报错信息往往在末尾,而数据主体在开头。

2.2 工具表:写给模型看的产品说明书

工具表看着是代码,本质是文档。模型只能通过你写的描述来理解这个工具能干什么、什么时候该用、参数怎么填。写得含糊,模型就会瞎猜,然后你就得在调试器里猜它是怎么猜的。

我一般按这个格式写:

def make_tool_registry(): return [ { "name": "read_local_file", "spec": { "type": "function", "function": { "name": "read_local_file", "description": "读取指定路径的文本文件。仅用于读取,不要用它列目录。", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "相对项目根目录的文件路径"}, "max_lines": {"type": "integer", "description": "最多读取行数,默认 200"} }, "required": ["path"] } } }, "fn": _read_local_file, }, ]

注意description里那句"仅用于读取,不要用它列目录"。这种反向约束比正向描述有用得多。模型很容易把工具想得比实际能力强,你越是明确说清"它不能干什么",它越不容易乱用。

还有一个我踩过坑的细节:参数描述里写清楚单位、默认值、取值范围。比如max_lines不写默认值,模型有时候会传 10000,直接把上下文撑爆。写完默认值之后这种行为少了很多。工具描述这件事没有止境,但第一版做到"每个参数都有 description"就够了。

2.3 状态桶:历史不是越长越好

上下文管理是 Agent 里最容易出问题、也最容易被人忽略的地方。第一版我不做任何压缩,全量保留,但加了两个观察点:

第一个是每轮结束后打印 token 估算值。不用精确,按字符数除以二点五估就行。这个数字会告诉你历史增长的斜率,斜率太陡你就知道该考虑压缩了。

第二个是把工具返回值和模型文本分开统计。我后来发现,撑爆上下文的往往不是模型的话,而是工具的返回值——尤其是那些返回完整 JSON 数组的工具。一个返回两百条记录的接口,轻轻松松吃掉几千 token。

第一版的策略是粗放截断:工具返回超过阈值就砍。第二版我会考虑做摘要压缩,但那需要额外的模型调用,也会引入信息丢失的风险,第一篇不碰。

状态桶里我还放了一个scratch字典,用来存中间结果,比如已经读到的文件名列表、已经算出来的中间数值。它的作用是不依赖模型记住前面做了什么。工具可以从scratch里读上一步的产物,这样即使上下文被截断,关键中间状态也不会丢。这个设计后来成了我所有 Agent 项目的标配。

3. 工具调用的三次翻车与完整排查链路

3.1 参数对不上:从报错倒推真实的 schema 分歧

第一次翻车发生在项目跑起来的第十几分钟。任务的第二步本该调用一个计算工具,模型传过来的参数是{"expression": "12 * 34"},而我的函数签名是def calc(a: float, b: float, op: str)。

我当时的第一反应是模型太笨,但翻了历史才意识到问题出在我自己身上:工具描述里写的是"执行算术运算",参数却要求拆成三个字段。模型看到"执行算术运算"这六个字,很自然会以为传个表达式字符串就够了。

排查这个问题的链路我后来固化了下来,一共四步:

  1. 在dispatch里打印call["arguments"]的原始字符串,不要打印解析后的对象。因为模型有时候会输出带 markdown 代码块包裹的 JSON,解析器可能悄悄修正了格式,掩盖了真实输入。
  2. 把工具的真实函数签名和 schema 并排打印。我做了个小的校验函数,启动时跑一遍,检查 schema 的必填字段是否和函数签名一致。
  3. 单独测一次"只给工具、不给任何上下文"的调用,看模型在没有干扰的情况下能不能正确填参数。如果能,说明是上下文污染;如果不能,说明是描述问题。
  4. 修改描述后,用同一批任务回归,看参数错误率的下降。

第二步那个启动自检后来救了我很多次。它的实现很简单,用inspect.signature拿到函数参数,和 schema 里的required做差集,不一致就在启动时打印警告。这个检查不阻断运行,但能让你在写代码的当下就发现对不齐。

3.2 原地打转:模型反复调同一个工具的四种成因

第二次翻车更典型:任务跑了七轮,每轮都在调同一个文件读取工具,参数完全一样。因为设了八步上限,最后返回exhausted,但打印历史才发现,模型在第 3 轮就已经拿到了想要的内容,只是它没意识到任务已经完成。

反复调同一个工具,我后来总结出四种不同成因,处理方式完全不一样:

成因表现处理方式
工具返回值不明确返回{"data": ...}但没有成功标志加显式的ok: true字段
任务描述本身有歧义模型不确定"够不够"在任务里写明完成标准
上下文里旧结果被截断模型看不到已经拿到的东西缩短返回值,或提升截断阈值
工具描述暗示需要多次调用描述里写了"可分页获取"明确定义单次调用的完整语义

最隐蔽的是第三种。你看着历史里明明有返回值,但模型看不到——因为你在塞回上下文时做了截断,把关键部分砍掉了。所以我在dispatch里加了一行日志:记录每次塞回上下文的实际字符数,以及截断标记。出现打转的时候先看这行日志,八成能直接定位。

第二种也值得展开。很多人写任务描述就一句话"帮我整理一下这个目录里的文件",模型当然不知道该整理到什么程度。改成"列出目录下所有 .md 文件,按修改时间倒序,输出前五个文件名",打转概率立刻下降。模糊的任务描述会以看似"模型能力不足"的形式暴露出来,这是很多新手最容易误判的地方。

3.3 返回值污染:一次工具输出撑爆了三点

第三次翻车最惨烈。我加了一个"搜索项目内文本"的工具,它在某个目录里命中了上千行结果,原样返回。下一轮模型调用直接报上下文超限,整个任务中断。

事后的处理分三层:

第一层是工具内部限流。搜索类工具默认最多返回二十条,超过的部分只报告总数,不返回内容。这一层最有效,因为它在源头就掐住了。

第二层是统一的截断包装器。所有工具返回都过一遍truncate(result, max_chars=4000, keep_head=0.7, keep_tail=0.3)。保留尾部很重要,很多工具的错误详情、分页信息在末尾。

第三层是让模型知道被截断了。截断后的结果里加一个"truncated": true, "total": 1240字段,模型看到这个会主动调整策略,比如缩小搜索范围。这一点很多人不做,导致模型以为自己看到的就是全部,然后基于残缺数据得出一本正经的错误结论——这种错误比直接报错难查得多。

4. 提示词管不住的部分要靠工程手段兜住

4.1 步数预算与熔断的合理取值

八步这个数字是我试出来的,不是理论值。我试过五步、八步、十五步,五步对多文件任务明显不够,十五步会让失败任务白白烧掉一半时间。

判断标准很简单:把你能想到的十个典型任务跑一遍,统计步数分布。我的分布大概是:单工具任务两到三步,双工具任务四到五步,多文件处理六到八步。取分布的九十分位,就是你的预算值。这个值应该随项目能力增长而调整,不是一成不变的。

熔断触发之后,我做两件事:一是返回完整历史给调用方,二是打一个专门的日志标记。熔断本身不可怕,可怕的是熔断之后没人知道它为什么熔断。我见过一些实现,超过步数就直接返回一句"任务失败",然后你什么线索都没有。

另外建议把步数预算做成任务级别的参数。探索型任务给宽一点,确定型任务给紧一点。我在一个批量处理脚本里把某些路径的预算压到了三步,因为那些路径本来就只该调两个工具,多一步都说明出问题了。

4.2 超时、重试与幂等该怎么配

超时分两层:单次模型调用超时和整体任务超时。前者我设得比较宽松,因为大模型的首 token 延迟波动很大;后者设得紧,六十秒。这个组合的意思是:允许单次调用慢,但不允许整体拖沓。

重试只对模型调用做,而且只对明确的网络类错误做。工具调用我基本不做自动重试,原因很简单:工具往往有副作用。写文件、发请求、改数据,这些操作重试一次可能就变成两次执行。要重试就必须先保证幂等,而保证幂等的工作量往往比不重试大得多。

如果确实需要重试工具,我会在工具内部做,而不是在调度层做。比如读取类工具重试两次是安全的,写入类工具就设计成"先写临时文件再原子替换",这样重复执行不会产生两份结果。

还有一个容易被忽略的点:重试要带退避。固定间隔的连续重试在模型服务抖动的时候基本无效,反而会加剧问题。指数退避加一点随机抖动,代码五行的量,效果差别很大。

4.3 输出校验与降级路径

模型返回的工具调用参数不一定合法。JSON 可能少括号,类型可能对不上,枚举值可能编造。我在dispatch里做三层校验:

def dispatch(call, tools): name = call["function"]["name"] tool = next((t for t in tools if t["name"] == name), None) if tool is None: return {"ok": False, "error": f"unknown tool: {name}"} try: raw = call["function"]["arguments"] args = json.loads(raw) if isinstance(raw, str) else raw except json.JSONDecodeError as e: return {"ok": False, "error": f"invalid json: {e}", "raw": raw[:500]} schema = tool["spec"]["function"]["parameters"] missing = [k for k in schema.get("required", []) if k not in args] if missing: return {"ok": False, "error": f"missing args: {missing}", "got": list(args.keys())} try: return {"ok": True, "data": tool["fn"](**args)} except Exception as e: return {"ok": False, "error": f"{type(e).__name__}: {e}"}

三层校验的返回值全部是结构化的、带明确原因的。这样模型下一轮就有机会自己修:少了参数就补参数,JSON 坏了就重新生成。实践下来,这三种错误的自我修复率都相当高,尤其是缺参数这类。

降级路径也得有。如果连续两轮工具都返回错误,我的策略是主动插入一条系统提示,明确告诉模型"已经连续失败两次,请换一种方法或直接说明无法完成"。这条提示的作用是打断它的固执模式,比单纯靠步数熔断温和得多。

5. 第一个小项目跑通之后能干什么、不能干什么

5.1 我用它跑的三个真实小任务

第一个任务是批量整理一个文档目录。给定一个目录路径,让它读取每个文件的前若干行,识别出主题标签,然后生成一份分类清单。总共用了两个工具,平均步数六步,成功率在我测的三十个文件上大概是八成五。失败的几个都是文件编码有问题,读取工具直接抛异常,模型拿到异常后试图用同样的路径重试,最后还是熔断。

第二个任务是核对一份结构化数据里的一致性。给定两个 JSON 文件,找出字段冲突。这个任务很快,基本三到四步搞定,因为它的决策空间很小:读文件 A、读文件 B、比对。这类任务最适合新手上手验证,失败率极低,能快速建立信心。

第三个任务稍微复杂些,是让它根据一段需求描述去修改配置文件。这个成功率明显下降,大概只有六成。失败原因很集中:模型倾向于做过度修改,把没让它动的字段也一起调了。这个问题我在下一篇会重点处理,思路是加一个"修改前先输出 diff 预览"的两阶段模式。

5.2 它一定会翻车的场景

跑了一段时间之后,边界挺清楚了。下面这几类任务我不再交给它:

  • 需要精确数值计算的任务。模型的算术能力在短链路上还行,一旦涉及多位小数或者复合公式就不可靠。这类任务要么用代码解释器工具直接算,要么别交给 Agent。
  • 开放式搜索后需要判断可信度的任务。它能搜到东西,但没有判断来源可靠性的能力,最后经常一本正经地引用一条明显不对的信息。
  • 步骤数不确定的任务。比如"把所有问题都找出来并逐个修复"这种,你根本没法预估步数,八步上限会直接卡死。这类要么改成批处理模式一次只处理一个,要么别用 Agent。
  • 需要跨会话记忆的任务。第一版状态全在内存,进程一停全丢,这个局限非常明确。

把这些边界写下来我觉得挺有必要的。很多关于 Agent 的失望情绪,其实是期望值没对齐——你拿一个只有八步预算、只有三个工具的小循环去处理需要三十步的任务,失败是设计使然,不是技术不行。

5.3 下一版准备动哪块

下一篇我打算做三件事,都是这几周跑下来觉得最值得动的:

第一,把工具返回值从纯文本 JSON 改成带类型的结构,让模型更容易判断"成功/失败/部分成功"三态,而不是只有成功和异常两种。

第二,加一个最简的两阶段执行:模型先输出操作计划,我这边校验完计划再让它执行。这样既能拦住过度修改,也能在计划阶段就发现步数超预算。

第三,把状态桶落盘。不需要数据库,一个 JSON 文件加时间戳就够,主要目的是让长任务可以中断续跑。

这三件事的共同点是:它们都不增加 Agent 的"智能",只是让它更好控制。我个人在实际操作中的体会是,Agent 项目前期的价值增量,八成来自工程约束而不是模型能力。你把循环写稳、把工具描述写清、把失败原因如实回传,一个能力平平的模型也能干出像样的活;反过来,模型再强,循环里有截断 bug,一样白搭。

最后分享一个我养成的习惯:每次线上跑失败,我都把完整历史存到一个单独的目录里,文件名带上时间和任务哈希。攒到几十条之后,你会发现失败模式高度集中,往往就那么三四种。修掉这几种,成功率能往上跳一大截,比漫无目的地优化提示词有效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询