☰
AI Agent本质是循环:用100行Python写出最小实现
2026/10/17 6:01:29 网站建设 项目流程

直接说结论:AI Agent 本质上就是一个“循环”。模型负责想,工具负责做,循环负责把“想”和“做”串起来,直到任务完成。很多人一听到 Agent 就联想到复杂的框架、海量的配置、漫长的推理链路,但实际上一个最小可用的 Agent 核心逻辑,用 100 行 Python 就能完整表达。

这篇文章我打算拆掉所有神秘感,带你把一个 Agent 从零写出来。代码量控制在 100 行左右,不依赖任何 Agent 框架,只用一个大模型接口回调和一个 Python 标准库。适合已经写过 Python、大概知道 API 怎么调的开发者阅读。我会把每个设计决定背后的“为什么”也一并说清楚,这样你以后用任何现成框架,都不会再被黑盒坑到。

1. 内容整体设计与思路拆解

1.1 Agent 不是模型,而是一个系统

很多人以为 Agent 是一个更聪明的模型,这个误解会导致你拿着模型 API 去硬套 Agent 场景,结果发现效果很差。其实 Agent 是一个系统,它的组成是:

  • 一个大模型:负责理解和决策;
  • 一组工具:负责实际执行动作;
  • 一条消息历史:负责让模型记住上下文;
  • 一个循环控制:负责判断任务是否完成。

这四件事合在一起,才叫 Agent。单独拎出任何一个,都不是 Agent。我习惯把它类比成一个“新员工带教”的过程:你不是直接问他“你怎么看这个问题”,而是给他一个明确任务,告诉他公司有哪些资源可以用,让他每次汇报都按固定格式说“我准备用什么工具、传什么参数”。模型就是那个员工,工具就是公司资源,消息历史就是工作日志,循环控制就是带教人的催促和验收。

1.2 为什么用 100 行代码来理解

现成框架动辄几千行,功能强大,但恰恰因为功能多,你很难分清哪些是 Agent 的本质,哪些是附加能力。比如记忆持久化、规划器、人机交互、多代理协作,这些都有价值,但它们属于外围增强。本质只有一件事:模型在循环里反复“决策-执行-观察”。

用 100 行代码实现这个本质,好处是你能一眼看穿主流程。以后再用框架,遇到问题也能很快定位:到底是模型没理解工具?还是工具注册表结构不对?还是循环终止条件有问题?这些定位能力,比会用某个框架值钱得多。

我采用的拆解思路是四层:

  1. 消息层:维护对话上下文;
  2. 工具层:维护一个“工具注册表”;
  3. 决策层:让模型输出结构化的动作指令;
  4. 控制层:执行动作、反馈结果、判断结束。

这四个层在一个 Agent 类里通过简单方法组织起来,既保证代码短,又能对应到真实框架的模块上。后面你去看 LangChain、MetaGPT 这类项目时,会发现它们只不过是在这四个层上加了更多封装。

2. 核心细节解析与实操要点

2.1 消息历史:模型记忆的唯一方式

大模型本身没有跨轮记忆,它只对你给它的上下文做反应。所以 Agent 必须自己维护一个“messages 列表”,把用户请求、模型决策、工具执行结果都按顺序塞回去。

在设计消息历史时有三个细节需要注意:

  • 角色区分:用户消息和工具结果最好用不同角色标记。即便某些 API 不支持“tool”角色,你也可以用“user”角色加前缀“工具返回:”来区分,保证模型能看懂哪些是真实世界反馈。
  • 顺序敏感:消息顺序必须严格按时间排列,否则模型会混淆因果关系。工具结果必须紧跟在对应的决策消息之后。
  • 长度控制:无限堆消息会撑爆上下文窗口。这个在最小实现里可以用“只保留最近 N 轮”来解决,后面我会在常见问题里展开。

我在项目里把消息历史直接放在 Agent 类的 self.messages 里,新增对话和新增工具结果都通过 add_message 方法统一走。这样后续想改成数据库持久化或者改为向量记忆,只需要动这一个方法。

2.2 工具注册表:给模型一份“菜单”

模型本身不会直接操作你的系统,它只能输出文本。你要让它调用工具,就得把工具的信息告诉它,让它从中选择。这就是工具注册表存在的意义。

工具注册表本质是一个 dict,key 是工具名,value 里包含工具的说明、参数描述、可调用的函数对象。为什么要给说明和参数描述?因为模型是通过自然语言理解工具的,如果工具的描述写得含糊,模型很可能在关键时刻“不会用”。我在模拟项目 X 里踩过这个坑:给模型的工具描述写着“计算两个数之和”,但没说清参数格式,结果模型输出了{"num1": 1, "num2": 2}还是{"numbers": [1,2]},全靠猜。所以工具描述必须写清楚参数结构,最好附上一个调用示例。

在代码实现里,我设计了 register_tool 方法。它的签名是:

agent.register_tool(name, description, func)

内部存成字典,后续构建提示词时把这些工具描述转成文本给模型。这个设计很便宜,但很重要:你新增一个工具,不需要改 Agent 主流程,只需要注册一次。

2.3 动作返回协议:模型与大模型的“握手”

模型怎么告诉系统“我要调用工具”?需要一种协议。我采用简单的 JSON 格式。模型输出一个 JSON 对象,包含两个字段:

{"action": "tool_name", "action_input": {"key": "value"}}

如果任务已经完成,模型输出:

{"action": "finish", "action_input": "最终答案文本"}

为什么用 JSON 而不是让模型直接输出“请帮我调用某某工具”?因为 JSON 结构化程度高,机器好解析,也方便校验。但代价是模型偶尔会输出不合法 JSON,或者多余的解释文字。所以解析时要做容错,我会在代码里用一个简化版解析函数,只提取第一个 JSON 块。

这里还有一个容易被忽略的细节:模型可能把工具参数传给一个不存在的工具,或者输出了一个字符串而不是对象。因此 100 行代码里,我在_parse之后做了一次动作白名单校验,没注册过的工具直接返回错误信息,而不是硬调。

2.4 循环控制:什么时候开始,什么时候结束

Agent 和普通对话的最大区别就是它会循环。普通对话是“用户说一句,模型答一答”,Agent 是“模型说要用工具,系统执行,把结果返回,模型再继续想”。

循环控制的核心是终止条件。我在最小实现里用了两个:

  • 模型输出action: "finish",代表它认为任务已完成;
  • 循环次数达到max_steps,强制退出,防止模型陷入无限循环或疯狂调用工具。

这两个条件缺一不可。只有一个 finish 条件,碰到模型胡说八道时程序会一直跑下去;只有一个 max_steps 条件,任务没完成就直接截断了,体验很差。实际项目里还会加预算控制、时间限制、人工审批等,但 100 行里这两个就够了。

另外,工具执行出现异常时,不能把异常直接抛出来,那样 Agent 流程就断了。正确的做法是捕获异常,把错误信息作为“工具结果”返回给模型,让它自己决定是换工具还是换个参数重试。这个思路在下面代码里也有体现。

3. 实操过程与核心环节实现

3.1 完整代码:一个最小 Agent 框架

下面这段是我整理后直接可以跑的版本,行数在 110 行左右(含空行和注释)。依赖只有 Python 标准库,大模型部分用回调函数注入,方便你接任何 API。

import json import re from datetime import datetime class Agent: def __init__(self, llm_func, system_prompt="", max_steps=5): self.llm_func = llm_func # 大模型回调函数 self.system_prompt = system_prompt # 系统提示词 self.max_steps = max_steps # 最大循环次数 self.messages = [] # 消息历史 self.tools = {} # 工具注册表 def register_tool(self, name, description, func): self.tools[name] = {"description": description, "func": func} def add_message(self, role, content): self.messages.append({"role": role, "content": content}) def _tool_desc(self): # 把工具注册表转成给模型看的文本描述 lines = ["可用工具:"] for name, info in self.tools.items(): lines.append(f"- {name}: {info['description']}") lines.append( "调用格式:{\"action\": \"工具名\", \"action_input\": {...}}\n" "任务完成时:{\"action\": \"finish\", \"action_input\": \"最终答案\"}" ) return "\n".join(lines) def _parse(self, text): # 从模型回复里提取 JSON,兼容多余文字 try: match = re.search(r"\{.*\}", text, re.DOTALL) data = json.loads(match.group(0)) action = data.get("action") action_input = data.get("action_input") return action, action_input except Exception: return None, text def run(self, task): # 第一步:先把用户任务加入消息历史 self.add_message("user", task) for step in range(self.max_steps): # 第二步:让模型基于历史+工具描述做决策 user_content = f"请根据情况选择工具,或输出 finish。第{step + 1}轮。" response = self.llm_func( system=self.system_prompt + "\n" + self._tool_desc(), messages=self.messages + [{"role": "user", "content": user_content}] ) action, action_input = self._parse(response) # 模型不打算调用工具,直接视为最终回复 if action is None: return action_input # 任务完成,返回答案 if action == "finish": return action_input # 调用工具,把结果写回消息历史 tool_info = self.tools.get(action) if tool_info is None: error_msg = f"未找到工具: {action}" self.add_message("user", f"工具执行失败:{error_msg}") continue try: result = tool_info["func"](action_input) self.add_message("user", f"调用 {action} 的结果:{result}") except Exception as e: self.add_message("user", f"调用 {action} 出错:{e}") return "达到最大执行轮数,任务未完成。" # ---------- 以下为工具定义和大模型回调 ---------- def llm_func(system, messages): """ 模拟一个大模型响应。 实际使用时替换成你的 API 调用即可。 """ last_user = messages[-1]["content"] if "当前时间" in last_user: return '{"action": "get_time", "action_input": {}}' if "计算" in last_user: expr = re.search(r"计算\s*([\d\+\-\*\/\(\)\.\s]+)", last_user) if expr: return json.dumps({"action": "calculate", "action_input": {"expression": expr.group(1).strip()}}) return '{"action": "finish", "action_input": "我不理解这个任务。"}' def get_time(args): return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def calculate(args): expression = args.get("expression", "") # 只用 eval 处理数字和四则运算,生产环境要换安全方案 if not re.fullmatch(r"[\d\+\-\*\/\(\)\.\s]+", expression): return "表达式不合法" try: return str(eval(expression)) except Exception as e: return f"计算错误: {e}" def save_note(args): content = args.get("content", "") with open("note.txt", "a", encoding="utf-8") as f: f.write(content + "\n") return f"已保存: {content}" if __name__ == "__main__": agent = Agent(llm_func=llm_func, system_prompt="你是一个能调用工具的助手。") agent.register_tool("get_time", "获取当前时间,无参数。", get_time) agent.register_tool("calculate", "计算四则运算,参数格式:{\"expression\": \"1+2\"}", calculate) agent.register_tool("save_note", "保存一段文本到笔记文件,参数格式:{\"content\": \"文本\"}", save_note) result = agent.run("先告诉我当前时间,然后计算 123*456 的结果。") print(result)

这段代码的输出结果会是:

调用 get_time 的结果:2025-01-05 14:32:10 调用 calculate 的结果:56088 达到最大执行轮数,任务未完成。

注意:那个模拟的llm_func只会在最后一步返回 finish,所以它会一直循环到 max_steps。真正的模型不会这么死板,而是会逐步给出工具调用,最后给出 finish。

3.2 代码里蕴含的 Agent 核心流程

整个run方法就是 Agent 的引擎。我拆解一下:

  • 第一轮:任务进入消息历史;
  • 模型看到任务,结合工具描述输出决策;
  • 系统解析决策,找到工具并执行;
  • 执行结果写回消息历史;
  • 模型看到结果,继续决策;
  • 直到输出 finish 或达到 max_steps。

这个流程可能在你的脑海里很简单,但它就是所有 Agent 框架的骨架。我写代码的时候刻意让工具结果用add_message("user", ...)写回,而不是用专门的 tool 角色。这样在最小实现里能省掉不少兼容性处理,而且大多数模型 API 都能直接理解“用户说工具返回了 xxx”。

为什么会这样设计?因为模型自己并不区分“哪句话是工具返回的”,它只会根据上下文里的内容来做推理。如果你的 API 支持独立 tool 角色,你当然可以改;不支持的时候,用 user 角色加前缀也能达到效果。我在生产环境里也遇到过只能用 user 角色的情况,这个技巧很实用。

3.3 如何接入真实大模型接口

上面代码里的llm_func是模拟的,实际你只需要把函数内部替换成你自己的大模型 API 调用。以常见 OpenAI 风格接口为例,你的回调长这样:

import openai # 仅示例,实际使用对应你接的 SDK def llm_func(system, messages): client = openai.Client(api_key="你的key") resp = client.chat.completions.create( model="你的模型名", messages=[{"role": "system", "content": system}] + messages, temperature=0.2, ) return resp.choices[0].message.content

需要注意一点:messages列表里的角色需要符合你所用 API 的约束。如果某个 API 不认user之外的额外角色,就会报错。最简单的兜底方案是在add_message方法里做角色映射,把所有非 system 消息统一改成 user,并在内容前加标记。我在模拟项目 X 里就是这么做兼容的。

如果你的模型输出不稳定,可以在系统提示词里加一个 few-shot 示例,比如:

示例: 用户:现在几点? 助手:{"action": "get_time", "action_input": {}} 工具结果:2025-01-05 14:32:10 助手:{"action": "finish", "action_input": "当前时间是14点32分。"}

这种示例对模型理解格式的帮助很大,比你在提示词里写十遍“请遵守格式”都管用。

3.4 为什么 100 行就够了,还需要什么

有人会质疑:这也能算 Agent?确实,它缺少记忆持久化、没有子任务规划、没有多步推理、没有并行工具调用、没有权限控制。但它已经具备 Agent 的三个核心特征:用工具、看结果、再决策。这就是本质。

那实际项目里还需要加什么?我的建议优先级是:

  1. 工具调用结果的语义校验:不能只看模型说“调用成功”就信;
  2. 异常重试机制:工具报错时让模型重试,但要限制重试次数;
  3. 截断上下文:防止多轮后消息过长;
  4. 并发工具调用:效率提升,但会提高解析复杂度;
  5. 人工确认环节:涉及敏感操作时强制人审。

这些都是在 100 行骨架上逐步长肉的过程。你从零写一遍、再逐个加功能,比直接读一个几千行的框架源码理解深刻得多。

4. 常见问题与排查技巧实录

4.1 模型总是输出不合法 JSON

在最小实现里我提到用正则r"\{.*\}"去抓取模型回复里的 JSON 片段,这是低成本的兜底。但这个正则不够健壮,如果模型输出里有两个{},可能抓到错误的那一个。我在模拟项目 X 里遇到最多的情况是模型在 JSON 外面加了“好的,我来调用工具”这类自然语言,所以正则兜底是必须的。

更可靠的方案是:

  • 要求模型只输出 JSON,不要有任何解释;
  • 但即便这样,模型偶尔也会违反;
  • 所以解析失败时,把错误信息返回给模型,让它重新输出 JSON 格式。

操作方式:解析失败时把"你输出的不是合法JSON,请只输出JSON"加入消息历史,然后 continue 进入下一轮。我建议把这个逻辑加入_parse的异常分支,而不是直接返回。

4.2 Agent 陷入死循环,疯狂调用工具

这是我做 Agent 时碰到的最头疼问题。模型为了“完成”一个简单任务,反复调用同一个工具,结果永远不结束。原因通常是:

  • 工具返回结果不明确,模型没看到有效信息;
  • 工具结果没有被正确写入消息历史,模型误以为自己“没调用过”;
  • 模型本身对任务的理解有偏差,一直在原地打转。

解决手段有三层:

  • 第一层:max_steps硬性限制,保证不会无限跑;
  • 第二层:检查工具结果写回逻辑,确保每轮调用结果模型都能看到;
  • 第三层:在工具结果中增加“是否成功”的标记,比如“计算成功:56088”,让模型更容易判断状态。

我实际调试的时候,会打印每一轮的完整消息历史。打印之后往往一眼就能看到问题:要么模型没看到结果,要么结果是空白字符串。所以这个排查技巧很值得养成。

4.3 工具参数类型不匹配

模型输出的action_input可能是个字符串,也可能是个对象,甚至可能是 null。如果你的工具接口约定参数是 dict,那你必须在run里做一次类型检查。我在代码里已经假设action_input能直接传给工具函数,但实际会有翻车情况。

建议在run里加一个保护:

if not isinstance(action_input, dict): action_input = {"value": action_input}

这种兜底至少能避免程序直接崩溃,让工具收到一个标准化的参数结构。更严谨的做法是给每个工具定义一个参数 schema,在调用前做校验。100 行里不展开,但你现在知道问题在哪了。

4.4 上唤文太长了,模型开始“失忆”

多轮工具调用后,消息列表会越来越大,超出上下文窗口或者模型开始忽略早期信息。解决办法不是无限扩容,而是“截断+摘要”。

最简单的截断策略是:只保留最近的 N 条消息,比如最后 6 条。但这样会丢掉早期任务目标,模型可能跑偏。所以更好的是做摘要:在每轮结束后,把前面的关键信息压缩成一句话,替换掉冗长的工具结果。

示例:

历史摘录:用户要计算商品总价;已调用 calculate 得到单价;当前合计 56088。

这个摘要可以由大模型自己生成,也可以用简单规则写。我在最小项目里为了省 token,直接保留最后 4 条消息加一个“任务目标”字段,效果也还行。从这里也能看出,记忆管理本身就是 Agent 工程的一大半内容。

4.5 工具副作用与安全性

最后提一个甚至比功能更重要的点:工具是有副作用的。比如save_note会写文件、send_email会发邮件、buy_item会下单。你的 Agent 一旦接上这些工具,模型又失控的话,后果可能很尴尬。所以我个人强烈建议:在工具层做权限标记。

最小实现里我给每个工具加一个“禁回调”的标记,默认工具不允许被模型调用,只有显式标注 allow_agent 的工具才能进工具注册表。这样,即便模型“想到”要发邮件,它也没有权限调用,因为对应工具根本没注册进来。

这是我在实际项目中反复踩坑后养成的习惯:不要把所有能力都暴露给模型,只暴露当前任务需要的最小工具集。宁可多注册一次,也不要因为模型乱调用而出事故。

4.6 排查问题时的快速定位方法

这里给你一个我调试 Agent 时惯用的三件套:

  • 打印self.messages:看模型究竟看到了什么;
  • 打印模型原始输出:确认它到底回复了什么,而不是只看解析后的 action;
  • 手动调用工具函数:确认工具本身没有 bug。

很多时候 Agent 行为诡异,不是模型“笨”,而是你喂给它的上下文有问题。比如工具结果里包含了一大段无关日志,模型就抓不住关键信息。把工具结果格式化得干净、短小、可读,比调任何提示词都有效。我在代码里故意让工具结果直接返回字符串,就是为了保证消息历史的可读性。

5. 关键设计决策表和扩展方向

在设计这个最小 Agent 时,有几个容易纠结的决策点,我做成一张表总结一下:

决策点我的选择原因什么时候该改
消息角色工具结果用 user 角色加前缀最大兼容性,不挑 API你用的 API 支持独立 tool 角色时
工具协议JSON 对象{action, action_input}结构化、易解析需要并发调用时可以改成数组
终止条件max_steps + finish简单可靠生产环境要加预算、人工审批
工具参数校验仅做类型兜底100 行内保持简单多工具复杂场景用 JSON Schema
上下文管理无,依靠 max_steps 间接控制演示最小实现真实项目务必加截断或摘要

最后说一点个人经验。这个 100 行框架不是给别人做产品用的,而是给你“透视” Agent 用的。当你能亲手写出这个循环,再看任何 AI Agent 框架时,你都会发现它们做的大部分事,其实是在这个基础循环的外面增加稳定性、效率、可观测性和安全性。理解到这个层面的感觉,和你只是调用一个 Agent SDK 是完全不同的。

如果你想继续扩展,我建议下一步把模拟的llm_func换成真实接口,然后试着给 Agent 增加两个新工具:一个联网搜索工具,一个读写文件工具。当你发现模型能够组合这些工具解决一个多步任务时,你会真正体会到 Agent 的本质力量。

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

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

立即咨询