☰
AI Agent核心机制源码拆解:从零读懂智能体循环与工具调用
2026/11/3 8:18:19 网站建设 项目流程

想把 AI Agent 真正看明白,读源码是绕不开的一关。最近我从 GitHub 上翻了一个 203 star 的开源项目,star 不算多,但代码短小精悍,一千多行的 Python 把 Agent 的核心机制——大模型对话、工具调用、上下文管理、执行循环——全都串起来了。我花了一个周末把它从头到尾扒了一遍,读完最大的感受是:原来 Agent 没有想象中那么神秘,所谓“智能体”,本质上就是一个严格控制输入输出的循环调度器。这篇文章把我整个拆解过程、核心源码解读以及踩过的坑都整理出来,给同样在啃 Agent 源码的开发者做个参考。

如果你正准备从 0 到 1 搭建自己的 AI Agent,或者翻过 LangChain 的文档但觉得太重、太绕,那这个项目会是一个非常合适的切口。它没有复杂的抽象层,没有花哨的插件机制,就是老老实实把 Agent 的最基本形态写给你看。读懂它之后,你再去看 LangChain、LlamaIndex 那些重框架,会发现它们的骨架其实一模一样,只是包了一层又一层的封装。

1. 为什么偏偏挑了这么个 203 star 的小项目

1.1 star 数量不代表一切:我选源码的三条标准

很多人找开源项目第一眼看 star,我之前也一样,非上千 star 不看。但读源码这件事,star 多还真不一定是好事。那些几万 star 的项目,比如 LangChain,代码量已经膨胀到了几十万行,抽象一层套一层,初学者一头扎进去基本就是溺水,看完目录就放弃了。

我挑源码有三个标准:第一,代码量要控制在一两千行左右,保证一个周末能从头读到尾;第二,不依赖重型框架,最好只用标准库加一个 HTTP client,这样所有核心逻辑都暴露在明面上;第三,必须有完整的可运行示例,能让我跑起来做实验。按这三个标准筛下来,这个 203 star 的项目反而成了最合适的候选。

它的 star 少,很大程度是因为没有做宣传、没有漂亮的 README 封面图、没有放出 benchmark 数据。但真正读进去之后你会发现,代码质量相当不错,注释不多但命名清晰,模块划分也很干净。star 这个东西,反映的是传播能力,不代表代码本身的教学价值。

1.2 项目定位:一个不依赖框架的微型 Agent

这个项目把自己定位为 "a minimal agent framework",代码一共九个文件,核心逻辑集中在两个文件里。它做的事情很简单:接收用户的一段自然语言指令,调用大语言模型的 API,模型判断需不需要使用工具,如果需要就执行工具并把结果返回给模型,模型再生成最终回复。

这个流程你听起来可能觉得平平无奇,但这恰恰是当前所有 AI Agent 产品最底层的运行逻辑。市面上那些号称能帮你自动写报告、自动订机票的 Agent 产品,拆到最底层,跑的都是这个循环。区别只在于工具的丰富程度、上下文管理的策略、以及外围加了什么样的 UI。

我之所以推荐从这种小项目入手,是因为它把 Agent 从“玄学”变成了“工程学”。读完你会清楚地看到:Agent 不是一个智能的黑盒,而是一套由开发者定义好的状态机,每一步都有明确的输入输出和跳转条件。

1.3 目录结构:一分钟看清一个 Agent 项目长什么样

拿到源码之后,我第一步永远是看目录结构。这个项目的布局非常典型:核心调度逻辑在agent.py,工具定义在tools/目录下,上下文管理在memory.py,配置读取在config.py,入口在main.py。这种分层几乎是 Agent 项目的标准模板,后续你看任何 Agent 项目,先找这几个文件就够了。

我把整个项目的文件清单和职责整理了一下:

文件职责核心类/函数
agent.pyAgent 主循环与调度逻辑Agent, Runner
tools/工具定义与注册Tool, ToolRegistry
memory.py对话历史管理MessageStore
config.py模型参数与 API 配置load_config, Config
main.pyCLI 入口main
llm.py大模型 API 封装call_llm, parse_response

这种结构的好处是单一职责非常明确。工具注册是工具注册的事,上下文管理是上下文管理的事,模型调用是模型调用的事。你要理解某一个环节,只需要打开对应的文件,不用在整个项目里翻来翻去。

2. 核心机制拆解:AI Agent 到底是怎么跑起来的

2.1 一次对话的完整旅程:从用户输入到最终回复

为了弄清楚 Agent 的运行机制,我直接在main.py里加了日志,把一次完整对话的调用链路打了出来。整个过程比我预想的要朴素得多:

用户输入一句“帮我算一下 23 乘以 17”,CLI 把这句话交给 Agent 的run方法,Agent 把用户消息追加到消息列表,然后调用大模型 API。模型返回的响应里带了一个工具调用请求,说明它认为需要计算器这个工具。Agent 解析这个请求,从工具注册表里找到对应的计算器工具,把参数传进去执行,得到结果 391。这个工具执行结果被追加到对话历史里,Agent 再次调用模型 API,这次模型没有请求工具,而是直接生成了最终回复:“23 乘以 17 的结果是 391。”

这段链路看起来非常简单,但它已经把 Agent 最核心的特性展现出来了:模型本身并不会算数,它通过“请求调用工具”这种方式,把自己的能力边界外延到了外部程序上。大模型负责理解意图、拆解任务、决定何时调用什么工具,工具负责执行具体操作,二者通过标准化的消息格式进行交互。

2.2 Agent Loop:一个写了无数遍的 while 循环

这个项目里最核心的代码就是一个 while 循环,整个 Agent 的“智能”全部发生在这个循环里。我把它简化成伪代码写在下面,方便你理解:

while True: # 1. 把当前的完整对话历史发给大模型 response = llm.chat(messages, tools=tools) # 2. 如果模型想调用工具,就执行工具并继续循环 if response.tool_calls: for tool_call in response.tool_calls: result = execute_tool(tool_call) messages.append(tool_result_message(result)) continue # 3. 如果模型正常回复了,就跳出循环 return response.content

这个循环在所有 Agent 项目里都有,只是换了不同的名字:LangChain 里叫 AgentExecutor,OpenAI 的 Assistants API 里叫 Run, semantic kernel 里 叫 LoopStep。但底层逻辑都是一样的:模型有工具调用意图就循环,没有就结束。

我第一次看懂这个循环的时候挺震撼的。AI Agent 给人的感觉很高级,但拆开看,核心就是一个while True。所谓“智能体”,就是让大模型在一个循环里不断“思考—调用—观察—再思考”,直到它认为问题解决为止。为什么现在很多 Agent 产品跑起来又慢又费钱?也是因为这个循环:每次工具调用都要把全部历史重新发给模型做一次推理,转一圈就是一次完整的 API 调用。

2.3 工具调用:Agent 的能力边界是如何扩展的

工具调用(Function Calling / Tool Calling)是这个项目里最值得细读的部分。大模型本身不能执行任何操作,它只能“提出请求”。比如用户让它查天气,模型没法真的去查,但它可以在回复中携带一个结构化 JSON,表示“我想调用 get_weather 工具,参数是北京”。

这个项目里,工具被抽象成了一个Tool类,每个工具需要声明三样东西:工具的名称、工具的描述、工具的参数 JSON Schema。模型靠前两个决定“什么时候用这个工具”,靠第三个决定“怎么填参数”。看下面这个例子:

class Tool: def __init__(self, name, description, parameters, func): self.name = name self.description = description self.parameters = parameters self.func = func def to_schema(self): return { "type": "function", "function": { "name": self.name, "description": self.description, "parameters": self.parameters, } }

这个to_schema方法生成的字典,就是发送给模型 API 的tools参数。你可以把它理解成一份“职位说明书”,每一项里面写了岗位名称(name)、岗位职责(description)、以及面试时需要提交的材料格式(parameters)。模型拿到这份职位说明书,就能判断当前任务应该调用哪个工具、材料应该怎么填。

工具注册就更简单了,本质上就是一个字典:

registry = {} def register_tool(tool): registry[tool.name] = tool def execute_tool(name, arguments): tool = registry.get(name) if not tool: return {"error": f"unknown tool: {name}"} args = json.loads(arguments) result = tool.func(**args) return json.dumps(result, ensure_ascii=False)

整个工具机制的核心就是“注册”和“分发”四个字。工具的注册表是一个普通字典,分发逻辑就是一个dict.get加一个函数调用。没有任何魔法。

2.4 记忆与上下文管理:对话历史是怎么被塞进 prompt 的

Agent 的“记忆”也是一个让我之前误会的概念。一开始我以为 Agent 真的有记忆机制,能记住以前聊过什么。读了源码才发现,所谓的记忆,就是把历史消息放在一个列表里,每次请求的时候原封不动地拼进 API 请求。

项目里维护了一个MessageStore类,本质就是一个列表,支持追加和读取。真正关键的是上下文裁剪策略。看它的实现你会发现一个残酷的事实:Agent 能记住多少内容,取决于大模型上下文窗口有多大。对话越长,发给模型的 token 就越多,成本就越高,响应也越慢。

这个项目用了最简单的裁剪策略:只保留最近 N 轮对话。当消息数量超过阈值时,把最早的消息丢出去。代码如下:

class MessageStore: def __init__(self, max_messages=20): self.messages = [] self.max_messages = max_messages def append(self, message): self.messages.append(message) if len(self.messages) > self.max_messages: self.messages = self.messages[-self.max_messages:]

注意这里有个很有讲究的细节:裁剪的时候只能丢普通消息,不能丢系统提示词(system prompt)。项目里把系统提示词作为列表的第一个元素特殊处理,裁剪时永远从索引 1 开始切。这个细节如果你自己实现 Agent,几乎一定会踩到——不保留系统提示词的话,你的 Agent 会突然忘记自己的身份设定,行为变得不可预测。

我在读这个项目的时候,把这个机制类比成人的工作记忆。短期记忆(上下文窗口)是有限的,超过就得靠“遗忘”来腾地方。至于长期记忆,需要向量数据库、外部存储等手段配合,属于进阶话题,这个小项目没有涉及,但理解了这个基础的上下文管理方式,后面再学向量检索会轻松很多。

3. 关键源码逐段精读:从入口到执行链路的完整解析

3.1 从入口文件看配置怎么被加载

这个项目的启动逻辑在main.py,非常朴素:加载配置、创建模型客户端、注册工具、创建 Agent 实例、进入 REPL 循环。整个流程跟创建一个普通的命令行工具没什么区别,Agent 没有被特殊对待。

配置加载部分的代码特别值得学一下,它用了一个很朴素的dataclass + yaml方案。读取config.yaml,把里面的模型名称、API base 地址、温度参数、最大 token 数等映射到配置对象上。让我印象比较深的是它对 API base 的处理,不是硬编码,而是从环境变量里读,这样既能对接官方 API,也能切换到本地部署的模型服务,实用性很强。

class Config: def __init__(self, path): data = yaml.safe_load(open(path, encoding="utf-8")) self.model = data["model"] self.api_base = os.environ.get("LLM_API_BASE", data.get("api_base", "")) self.api_key = os.environ.get("LLM_API_KEY", "") self.temperature = data.get("temperature", 0.2)

不要小看这个配置文件的写法。Agent 项目的调试,百分之八十的问题出在配置阶段:模型名字写错、api_base 多了一个斜杠、环境变量没设置导致 key 为空。把配置集中管理,起步就会顺畅很多。我拿到任何 Agent 项目,第一件事永远是翻配置文件,而不是读代码逻辑。

3.2 模型调用层:API 请求是如何被构造和解析的

llm.py是另一个值得细看的文件,它封装了模型 API 的调用逻辑。这里的实现很有意思,它没有用官方 SDK,而是直接用requests发送 HTTP 请求,请求格式遵循 OpenAI 兼容协议。这样做的一个好处是:代码完全暴露了请求体长什么样,你能清楚地看到发送给模型的数据结构。

请求体构造的核心部分大概是这样的逻辑:接收完整的消息列表和工具列表,组装成 JSON。如果模型 API 支持流式输出,就启用stream参数;否则直接一次性返回。对于学习 Agent 原理来说,看这个手写请求比看 SDK 封装要直观得多。

响应解析的部分最关键是判断模型有没有请求工具调用。OpenAI 兼容协议下,这个字段叫tool_calls,是choices[0].message里的一个数组。每个元素包含id、function.name和function.arguments。解析时必须做一层容错,因为不同模型返回的字段命名可能略有差异,有的用tool_calls,有的兼容旧版叫function_call。项目里对这两种情况都做了兼容,这也是我们可以学习的健壮性思维。

3.3 工具注册与分发:从模型 JSON 到 Python 函数

工具的执行链路读下来,可以发现项目刻意保持了“字符串到函数”的简单映射:模型返回的name是字符串,arguments是 JSON 字符串,在execute_tool里解析成 Python 字典,再解包成关键字参数,最终调用注册表里的普通 Python 函数。整个过程没有任何动态代码执行,安全性相对可控。

这里要特别说一下参数解包的一个常见坑。模型返回的 JSON 字段名必须和 Python 函数的参数名完全一致,否则调用就会报TypeError: unexpected keyword argument。比如工具注册时定义的参数 schema 里是city,但 Python 函数定义的是city_name,模型按 schema 填了city,一执行就崩。这个项目提供了解决方案,在注册工具时校验参数名的一致性,不匹配就拒绝启动。

我实操的时候在这个坑上栽过一次,后面在第五章会展开说。这里只提醒一句:工具的参数 schema 不是你写给人看的文档,而是写给模型看的“填写说明”,字段命名必须严谨、无歧义,哪怕差一个字都会出问题。

3.4 完整走读:让 Agent 自己解一道数学题

光看不练容易忘,我照着项目写了一个自定义工具,让 Agent 解一道带括号的复合计算题,完整走了一遍链路。注册的工具有两个,一个计算器calculator,一个日志记录器log_note,然后我给 Agent 的指令是:“计算一下 (8 + 2) * 5,并且把结果记到日志里。”

Agent 处理这个任务时,大模型会先决定调用calculator,参数是{"expression": "(8+2)*5"}。工具执行后返回50,作为一条tool角色的消息追加进对话历史。模型看到计算结果后,又决定调用log_note,把50写进日志。最后模型生成总结:“计算结果为 50,已记录到日志中。”

这个例子展示了多轮工具调用的协作过程。注意关键细节:模型每次只能看到当前对话历史里的消息,它并不知道工具执行的内部过程,只能看到工具返回的字符串。工具返回结果的文本质量,也直接影响模型后续的判断。所以优秀的 Agent 工具实现,除了返回正确结果,还会把来源、精度、异常情况等元信息用自然语言包装好,方便模型理解。

我在自己改造这个项目时,给某个工具增加了返回结构化的 JSON 文本,并在开头加了一句 “result: ...,confidence: 0.98” 这样的提示词后缀,模型后续的处理准确率明显提升。工具返回的内容本质上也是一种“提示词工程”,这一点很多初学者会忽略。

4. 把项目跑起来:从环境搭建到改造验证

4.1 环境准备与最小依赖

这个项目的依赖少得感人,核心只是 Python 3.10 以上加一个requests库,连dotenv都没用,环境变量全靠os.environ读取。克隆下来之后,只需要执行两条命令就能创建虚拟环境并装好依赖:

git clone https://github.com/example/tiny-agent.git cd tiny-agent python -m venv .venv source .venv/bin/activate pip install requests pyyaml

模型配置上,由于它支持 OpenAI 兼容协议,我直接用了一个本地模型服务,把LLM_API_BASE指到本地的 11434 端口。如果你用国内厂商的兼容 API 也同理,只要服务端兼容这个协议就行。这个设计很聪明,不绑定任何特定厂商,也让这个项目在模型选择上非常灵活。

4.2 最小可运行示例:写一个自定义工具

跑通内置示例之后,我照着项目结构写了一个新工具,用来感受完整流程。我实现了一个获取本地时间的工具:

from datetime import datetime def get_current_time(format: str = "%Y-%m-%d %H:%M:%S"): return datetime.now().strftime(format) tool = Tool( name="get_current_time", description="获取当前时间,返回格式化后的字符串。当用户询问当前时间时必须使用此工具。", parameters={ "type": "object", "properties": { "format": { "type": "string", "description": "时间格式模板,默认为 %Y-%m-%d %H:%M:%S", "enum": ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%H:%M:%S"] } }, "required": [] }, func=get_current_time ) register_tool(tool)

这里有一个很重要的经验:工具的description写得好不好,直接决定模型调用它的频率和准确度。如果你写“获取当前时间”,模型可能在用户问“今天是几号”时不调用它;如果你写“当用户询问当前时间时必须使用此工具”,模型就会老老实实走工具调用。模型没有常识,它只按描述里的指令行事,这是 Agent 工具设计的一个核心心法。

4.3 我踩过的四个坑

先交代时间线:我是在没有仔细看 README 的情况下直接上手跑的,所以踩的坑都是典型的“看源码一时爽,跑起来火葬场”系列。最大的一个坑是 API base 配置。这个项目默认读环境变量LLM_API_BASE,我没设置,它用了配置文件的默认值,结果请求始终 404。排查了半天才发现是环境变量优先于配置文件,设置上之后一切正常。

第二个坑是上下文裁剪把系统提示词丢了。刚才提到MessageStore裁剪时保留索引 0,但我不小心改成了从索引 0 开始切,结果 Agent 跑了几轮之后突然行为异常,不再遵循工具调用指令,一直在输出闲聊内容。排查了很久才意识到是系统提示词被裁掉了,这个 Bug 的隐蔽性极强,强烈建议大家如果自己实现上下文管理,一定要写专门的单元测试。

第三个坑是工具参数类型不匹配。我的get_current_time工具参数里声明了format字段,模型有时候会传一个带中文括号的全角格式,导致strftime直接抛异常。后来我在工具函数内部加了 try-except,并在返回信息里注明了正确格式,模型下一次调用就正常了。工具函数一定要有容错,因为模型的输出不可控,你永远猜不到它会传什么奇怪的参数进来。

第四个坑比较隐蔽:本地模型服务对tools参数的支持不完整。我的本地模型跑的是较老版本,它虽然能解析tools,但返回的tool_calls字段格式和 OpenAI 官方不一致,导致项目的解析器识别不了。解决方法是升级模型版本,或者像这个项目一样,在解析代码里做一个字段兼容层。如果大家遇到模型返回空响应或者解析异常,优先检查模型版本对 Function Calling 的支持程度。

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

5.1 问题速查表:按症状找病因

这几天调试过程中,我把经常遇到的问题整理成了一张表,按“症状—原因—处理”的方式列出来,方便你以后排查:

症状可能原因处理方式
Agent 完全不调用工具工具描述不规范 / 模型版本不支持 Function Calling在描述中写“必须使用工具”,检查模型配置
工具参数总是填错JSON Schema 字段定义有歧义增加enum限定取值,补充 description
执行工具时报 TypeErrorschema 参数名和 Python 函数参数名不一致用装饰器自动校验并统一命名
多轮对话后行为异常上下文裁剪误删了系统提示词检查 MessageStore 裁剪逻辑,保留索引 0
API 请求 404api_base 地址错误 / 未设置环境变量打印请求 URL 确认拼接结果
响应速度极慢每次循环全量发送历史消息启用流式输出,或缩短上下文长度

这张表我打印出来贴在了显示器旁边,实际开发的时候对照着排查效率很高。你之后实现自己的 Agent,大概率也会遇到同样的问题,建议把它当作基础排查清单收藏。

5.2 调试 Agent 的三种有效方式

传统调试工具在 Agent 项目里不太管用,因为你看不到模型内部发生了什么。这个项目的源码给我最大的启发,是它提供了三种非常朴素但是高效的调试手段。

第一种是“打印对话历史”。在每次调用模型 API 之前,把完整的消息列表打印出来,看看系统提示词是否还在、历史是否被正确裁剪、工具结果是否正确定位。我 90% 的问题都是靠这个方法定位的。

第二种是“打印模型原始响应”。不要只看解析后的结果,要把模型返回的原始 JSON 完整打出来。有一个非常经典的问题,模型响应里带了多余的空白字符或换行,导致 JSON 解析失败,通过打印原始响应一眼就能看到。

第三种是“工具运行插桩”。在每个工具的入口处打印收到的参数和时间戳,确认真实传入的数据是什么样的。前文提到的全角括号问题,就是我通过插桩发现模型传了format="%Y-%m-%d"这种奇奇怪怪的值。

5.3 模型返回异常的边界处理

读这个项目源码时,我最欣赏它的部分是parse_response函数的健壮性。它不是简单地json.loads就完事儿,而是做了三层防护:先尝试标准解析,失败后用正则提取 JSON 片段,再失败就返回兜底对象。这个处理手法非常值得借鉴,因为大模型的输出有其固有的不确定性,它偶尔会在 JSON 前面多输出一句废话,或者漏掉一个花括号。

实际跑下来,我的本地模型大概有 2% 的请求会生成畸形 JSON。不要小看这 2%,在高频 Agent 场景下,这就是不可忽视的失败率。处理办法很简单:解析失败时,不要把错误抛给用户,而是把原始响应作为一条消息追加到对话历史里,并告诉模型“你刚才的输出格式不对,请重新生成一个符合要求的 JSON”。这种“反馈重试”的思路,比写死各种异常分支要优雅得多。

6. 从这一个项目出发:读懂任意 Agent 源码的通用方法论

6.1 五步源码阅读法

读完这个 203 star 的项目之后,我总结了一套针对 Agent 项目的通用读码方法,五步走,亲测对任何规模的 Agent 项目都适用。

第一步,先跑起来,后读代码。不运行就开读,你记不住,也理解不了上下文,而且在真实运行过程中看到的现象会反过来帮助你理解代码里看似奇怪的细节。

第二步,画调用链。不需要画很正规的时序图,就用纸笔把一次输入从入口到最终返回经过了哪些模块、哪些函数列出就好,核心是找“主循环”和“扩展点”。

第三步,找三个核心文件:上下文管理、工具注册、模型调用。这三者是 Agent 的三大支柱,哪里都可以不看,这三个文件必须抠明白。

第四步,改一行代码验证自己的理解。比如把裁剪阈值从 20 改成 3,观察对话行为的变化。如果你能预见改动效果,说明你真的读懂了。

第五步,带着问题读源码。读这个项目前我给自己提了三个问题:模型怎么决定要不要用工具?工具结果怎么回到模型?上下文满了会怎样?带着问题的阅读效率是漫无目的的网读至少两倍。

6.2 读完之后可以做什么改造方向

源码读完了只是开始,真正的收获是你现在有能力把它改造成自己的东西。我基于这个项目做了两个方向上的实验,效果都很不错,这里分享给你。

第一个实验是给它加上了“流式输出”。原始项目是等模型完整生成之后一次性返回,改动之后,通过SSE逐字输出给终端,响应体验好了很多。这个改造只涉及llm.py一个文件,非常适合练手。

第二个实验是给工具调用加“最大迭代次数保护”。原始循环是while True,如果模型陷入某类逻辑死循环,会无限调用 API,既费钱又卡死进程。我加了一个计数器,循环超过 5 次就强制结束,并把中间结果作为最终回复返回。这个改动虽然只有五行,却是上线前必须做的安全措施。

我后来把其中一个改造分支的代码分享给同事看,他顺着我的思路,给项目接上了一个本地知识库检索工具,相当于给 Agent 加了一个简单的长期记忆:用户提问时,先检索相关片段,拼进上下文,再交给模型。整个过程只花了一个下午。读源码的最终目的不是“读懂”,而是能在此基础上创造性地解决问题,这才是源码阅读最大的红利。

这个 203 star 的项目本身很小,但它让我把 Agent 从“概念”变成了“可拆解的系统”。我实际读完的最深体会是:Agent 的能力并不来自某个神秘算法,而来自开发者如何设计循环、如何定义工具、如何管理上下文。这些全都是工程问题,每一个都能在源码里找到答案。你读完一个项目后能改造成自己的东西,才是真的读懂了。

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

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

立即咨询