☰
从零手写极简Agent:200行代码理解LLM循环与工具调用
2026/10/22 11:26:32 网站建设 项目流程

1. 为什么我要自己动手写一个 Agent

市面上关于 Agent 的文章,十篇里有八篇在讲概念——什么自主规划、工具调用、记忆机制,翻来覆去就那几个词。但真到动手环节,要么是调个云端 API 跑个 Demo 就完事,要么是甩出一个几万行的框架让你自己啃。我一开始也是这么过来的,直到某天晚上盯着一个跑不通的 Agent 循环发呆,突然意识到:如果连一个最小可用的 Agent 都写不出来,那所谓的“理解 Agent 原理”就是自欺欺人。

这个项目的出发点特别朴素——参照一个叫 pi-agent 的轻量实现思路,从零搭一个能跑、能看、能改的小 Agent。它不需要多智能,但必须把 Agent 的核心骨架暴露出来:循环怎么转、工具怎么挂、上下文怎么管、错误怎么兜。适合那些已经会写 Python、调过 LLM 接口,但一提到“自己实现 Agent”就不知道从哪下手的开发者。读完你至少能拿到一个两百行以内、结构清晰、可以随意魔改的 Agent 原型。

我踩过的坑先摆在这里:别一上来就想着做多 Agent 协作、别急着接向量数据库、别把 Prompt 写得像论文。先把单 Agent 的单轮循环跑通,比什么都重要。

2. 核心设计思路与方案选型

2.1 为什么选“极简循环”而不是“复杂编排”

Agent 的本质是什么?剥开所有花哨的概念,它就是一个带工具调用能力的 LLM 循环。你给它一个任务,它决定下一步做什么,执行,看结果,再决定下一步,直到任务完成或达到终止条件。

pi-agent 这类轻量实现的聪明之处在于,它没有引入 DAG 编排、没有状态机、没有复杂的消息总线。整个核心就是一个while循环:

while not done: response = llm.chat(messages, tools) if response.has_tool_call: result = execute_tool(response.tool_call) messages.append(result) else: done = True

就这么简单。但简单不代表简陋,这个循环里藏着几个关键决策点,每一个都值得展开说。

第一个决策:工具调用用原生 Function Calling 还是自己解析文本?我试过两种。自己解析文本(比如让模型输出Action: search\nInput: xxx)的好处是不依赖特定模型能力,坏处是解析逻辑极其脆弱,模型稍微换个格式就崩。原生 Function Calling 稳定得多,但要求模型支持。我的选择是优先用原生,同时保留一个文本解析的降级通道。这样换模型的时候不至于推倒重来。

第二个决策:上下文怎么管理?最粗暴的做法是把所有历史消息一股脑塞进去,但 token 会爆炸。我的方案是滑动窗口 + 工具结果截断。保留最近 N 轮对话,工具返回的结果如果超过阈值就截断并加省略标记。这个策略不完美,但对于小 Agent 来说够用,而且实现成本极低。

第三个决策:终止条件怎么定?不能只靠模型说“我完成了”,因为它可能永远不这么说。我设了三重保险:模型主动返回无工具调用的回复、达到最大循环次数、连续两次工具调用结果相同(说明卡住了)。这三个条件任意触发就退出。

2.2 工具系统的设计:注册、描述与执行分离

工具是 Agent 的手脚。我见过很多实现把工具的定义、描述、执行逻辑揉在一个大字典里,改一个工具要动好几处。我的做法是用装饰器注册,元数据和执行函数分离。

TOOL_REGISTRY = {} def tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] = { "name": name, "description": description, "parameters": parameters, "func": func } return func return decorator @tool( name="calculator", description="执行数学计算,输入一个表达式字符串", parameters={"expression": {"type": "string", "description": "数学表达式"}} ) def calculator(expression): return str(eval(expression))

这样加一个新工具只需要写一个带装饰器的函数,其他地方自动感知。描述信息直接转成 Function Calling 的 schema,不用手动维护两份。

注意:eval只用于演示,实际项目里必须换成安全的表达式解析器,否则就是一个远程代码执行漏洞。

2.3 消息结构的设计:让每一步都可追溯

消息列表是整个 Agent 的记忆。我把它设计成标准的三元组结构:role、content、metadata。metadata里存工具调用的 ID、耗时、是否被截断等信息。这样做的好处是调试的时候能清楚看到每一步发生了什么,而不是面对一堆纯文本发呆。

messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, {"role": "assistant", "content": "", "metadata": {"tool_calls": [...]}}, {"role": "tool", "content": "计算结果:42", "metadata": {"tool_call_id": "...", "truncated": False}} ]

这个结构看起来多了一层,但在排查“为什么 Agent 走了弯路”的时候,这层 metadata 能救命。

3. 核心模块拆解与实操要点

3.1 LLM 调用层:统一接口,屏蔽差异

不同模型的 API 格式不一样,有的用tools参数,有的用functions,返回结构也各不相同。我的做法是写一个薄适配层,对外只暴露一个chat(messages, tools)方法,返回统一格式的响应对象。

class LLMResponse: def __init__(self, content, tool_calls=None): self.content = content self.tool_calls = tool_calls or [] @property def has_tool_call(self): return len(self.tool_calls) > 0

适配层内部处理各家 API 的差异。比如某家返回的 tool call 参数是 JSON 字符串,另一家直接是字典,适配层统一转成字典。这样上层循环逻辑完全不用关心底层用的是哪家模型。

实操要点:超时和重试一定要在适配层做掉。我一开始偷懒没做,结果模型偶尔抽风返回空响应,整个循环就卡死了。后来加了指数退避重试,最多三次,问题解决。另外,每次调用都记录 token 消耗,不然月底看到账单会心疼。

3.2 工具执行器:安全、超时与结果处理

工具执行看起来简单——拿到函数名和参数,调用就完事。但实际跑起来,问题全在这里。

参数校验:模型可能传错参数类型,比如该传字符串的传了数字。我在执行前加了一层轻量校验,类型不对就尝试转换,转不了就返回错误信息给模型,让它自己纠正。

超时控制:有些工具(比如网络请求)可能卡住。我用concurrent.futures给每个工具调用加了超时,默认 10 秒,超时返回错误信息。这样 Agent 不会因为一个工具卡死而整个挂掉。

结果截断:工具返回的内容可能非常长,比如读取一个大文件。我设了一个阈值(默认 2000 字符),超过就截断并加上...[结果已截断]标记。这个阈值可以根据模型上下文窗口调整。

def execute_tool(tool_call, timeout=10): name = tool_call["name"] args = tool_call["arguments"] if name not in TOOL_REGISTRY: return f"错误:未知工具 {name}" tool_def = TOOL_REGISTRY[name] try: with ThreadPoolExecutor() as executor: future = executor.submit(tool_def["func"], **args) result = future.result(timeout=timeout) result_str = str(result) if len(result_str) > MAX_RESULT_LEN: result_str = result_str[:MAX_RESULT_LEN] + "...[结果已截断]" return result_str except TimeoutError: return f"错误:工具 {name} 执行超时" except Exception as e: return f"错误:工具 {name} 执行失败 - {str(e)}"

提示:错误信息一定要返回给模型,而不是直接抛异常终止。模型看到错误信息后往往能自己调整策略,比如换个参数重试。这是 Agent 自愈能力的关键。

3.3 上下文管理器:滑动窗口与摘要压缩

上下文管理是 Agent 能不能长时间运行的关键。我的方案分两层:

第一层:滑动窗口。保留最近 N 轮完整对话(默认 10 轮),更早的消息只保留摘要。摘要不是让模型生成的,而是简单地把工具调用和结果压缩成一行,比如[调用了 calculator,结果 42]。这样既保留了关键信息,又大幅节省 token。

第二层:系统提示词固定。系统提示词永远放在最前面,不参与滑动。它定义了 Agent 的角色、可用工具、行为规范。我把它写得很克制,不超过 200 字,因为系统提示词太长会挤占上下文空间,而且模型对长系统提示词的遵循度反而会下降。

def build_context(messages, max_rounds=10): system = messages[0] rest = messages[1:] if len(rest) <= max_rounds * 2: return [system] + rest recent = rest[-(max_rounds * 2):] older = rest[:-(max_rounds * 2)] summary = summarize_older(older) return [system, {"role": "system", "content": f"历史摘要:{summary}"}] + recent

实操心得:摘要压缩的粒度很关键。太粗会丢信息,太细省不了 token。我试了几轮之后发现,按“工具调用”为单位压缩效果最好——每次工具调用压缩成一行,包含工具名和结果的关键部分。这样模型能知道“之前做过什么”,但不需要看到完整结果。

3.4 循环控制器:终止条件与异常兜底

循环控制器是整个 Agent 的大脑。它的逻辑不复杂,但边界条件特别多。

def run_agent(task, max_iterations=15): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task} ] last_tool_result = None repeat_count = 0 for i in range(max_iterations): context = build_context(messages) response = llm.chat(context, tools=get_tool_schemas()) if not response.has_tool_call: messages.append({"role": "assistant", "content": response.content}) return response.content messages.append({ "role": "assistant", "content": response.content or "", "metadata": {"tool_calls": response.tool_calls} }) for tool_call in response.tool_calls: result = execute_tool(tool_call) if result == last_tool_result: repeat_count += 1 if repeat_count >= 2: return f"检测到重复调用,任务可能无法完成。最后结果:{result}" else: repeat_count = 0 last_tool_result = result messages.append({ "role": "tool", "content": result, "metadata": {"tool_call_id": tool_call["id"]} }) return "达到最大迭代次数,任务未完成。"

三个终止条件:模型主动结束、达到最大迭代次数、连续重复调用。第三个条件特别重要,我遇到过模型陷入“调用搜索→没找到→再调用搜索”的死循环,没有这个兜底就会一直烧 token。

注意:最大迭代次数不要设太大。我一开始设了 50,结果有一次模型钻牛角尖,跑了 40 多轮才停,账单直接翻倍。后来改成 15,大部分任务够用,实在复杂的任务可以手动调大。

4. 完整实操流程:从零跑通一个 Agent

4.1 环境准备与依赖安装

这个项目对环境的依赖极少,核心只需要一个能调 LLM 的 SDK。我用的是最通用的 HTTP 请求方式,不绑定任何特定厂商的 SDK,这样换模型只需要改一个配置。

pip install requests

就这一个依赖。不需要 LangChain,不需要任何 Agent 框架。我刻意不用框架,因为框架会掩盖太多细节,而学 Agent 最好的方式就是自己把细节写一遍。

项目结构也很简单:

mini-agent/ ├── agent.py # 核心循环 ├── tools.py # 工具定义 ├── llm.py # LLM 适配层 └── main.py # 入口

四个文件,加起来不到 300 行。你可以直接在一个文件里写完,但分开更清晰,方便后续扩展。

4.2 系统提示词的设计与调优

系统提示词是 Agent 的“人格设定”。我试过好几个版本,最后稳定下来的版本遵循三个原则:角色明确、工具说明简洁、行为约束具体。

SYSTEM_PROMPT = """你是一个任务执行助手。你可以使用提供的工具来完成任务。 规则: 1. 每次只调用一个工具,等待结果后再决定下一步。 2. 如果任务已完成,直接回复结果,不要调用工具。 3. 如果工具返回错误,尝试修正参数后重试,最多重试两次。 4. 不要编造工具返回的结果。 5. 回复使用中文,简洁直接。"""

这个提示词只有 100 多字,但覆盖了最关键的行为约束。我试过写 500 字的详细提示词,效果反而更差——模型会忽略其中的大部分内容。提示词的精髓在于“少而准”,每一条都对应一个实际会遇到的问题。

比如第 3 条“最多重试两次”,是因为我遇到过模型无限重试同一个错误参数的情况。第 4 条“不要编造结果”,是因为模型有时候会假装工具返回了某个结果,实际上根本没调用工具。

4.3 工具集的设计:从计算器到文件操作

我给这个 Agent 配了四个基础工具,覆盖最常见的操作类型:

工具名功能参数典型场景
calculator数学计算expression: 表达式字符串数值运算
read_file读取文件内容path: 文件路径查看配置、日志
write_file写入文件path, content保存结果
http_get发起 GET 请求url获取网络数据

这四个工具的设计遵循一个原则:每个工具只做一件事,参数尽可能少。我试过设计一个“万能工具”,参数一大堆,结果模型经常传错参数。拆成单一职责的工具后,调用准确率明显提升。

@tool( name="read_file", description="读取指定路径的文件内容,返回文本", parameters={ "path": {"type": "string", "description": "文件的完整路径"} } ) def read_file(path): with open(path, "r", encoding="utf-8") as f: return f.read()

实操要点:文件操作工具一定要加路径校验,限制在指定目录内。我一开始没做限制,模型直接去读系统文件了。后来加了一个ALLOWED_DIR白名单,所有文件操作必须在这个目录下。

4.4 跑通第一个任务:让 Agent 算一道应用题

环境搭好之后,用一个简单任务验证整条链路。我用的测试任务是:“一个水池有甲乙两个进水管,甲管单独注满需要 6 小时,乙管单独注满需要 4 小时,两管同时开需要多久?”

这个任务的好处是:需要数学计算,但不需要外部信息,能验证 Agent 的“思考→调用工具→整合结果”完整流程。

运行日志大概是这样:

[第1轮] 模型决定调用 calculator 参数: {"expression": "1 / (1/6 + 1/4)"} 结果: 2.4 [第2轮] 模型决定调用 calculator 参数: {"expression": "2.4 * 60"} 结果: 144.0 [第3轮] 模型返回最终答案 "两管同时开需要 2.4 小时,即 2 小时 24 分钟。"

三轮循环,两次工具调用,任务完成。这个过程中你能清楚看到 Agent 的决策链路:先算时间,再转成分钟,最后整合成自然语言回答。

踩坑记录:第一次跑的时候,模型把1/6 + 1/4算成了0.4166,然后自己心算得出2.4,根本没调用计算器。这就是典型的“模型偷懒”——它觉得自己能算。后来我在系统提示词里加了一句“所有数学计算必须使用 calculator 工具”,问题解决。

4.5 进阶任务:多步骤文件处理

验证完基础链路后,用一个更复杂的任务测试多步骤能力:“读取 data.txt 文件,统计其中有多少行,把行数写入 result.txt。”

这个任务需要:读文件→分析内容→写文件,三步操作,两次工具调用。运行日志:

[第1轮] 调用 read_file 参数: {"path": "./data.txt"} 结果: "第一行\n第二行\n第三行\n第四行\n第五行" [第2轮] 调用 write_file 参数: {"path": "./result.txt", "content": "5"} 结果: "写入成功" [第3轮] 模型返回 "data.txt 共有 5 行,已写入 result.txt。"

这个任务暴露了一个问题:模型在第二轮直接写了“5”,但它怎么知道是 5 行的?实际上它是在第一轮结果里自己数的。如果文件有几百行,模型数数就会出错。正确的做法是提供一个count_lines工具,让模型调用工具来数,而不是自己数。这个教训让我意识到:凡是模型可能算错的事情,都应该做成工具。

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

5.1 模型不调用工具怎么办

这是最常见的问题。模型要么直接回答,要么在回复里写“我需要调用搜索工具”但实际没调用。原因通常有三个:

工具描述不够清晰。模型不知道这个工具能干什么。解决方法是把 description 写得具体,包含输入输出示例。比如不要写“搜索工具”,要写“根据关键词搜索网络信息,返回相关摘要”。

系统提示词没有强调工具使用。加一句“你可以使用工具来辅助完成任务”往往就能解决。

模型本身能力不足。有些小模型对 Function Calling 的支持很差。这时候要么换模型,要么降级到文本解析模式。

排查顺序:先看工具描述,再看系统提示词,最后考虑换模型。

5.2 工具调用参数错误怎么处理

模型传错参数是家常便饭。常见错误类型和应对策略:

错误类型示例应对策略
类型错误该传字符串传了数字执行前做类型转换
缺少参数没传必填参数返回错误信息让模型补
参数名错误传了file而不是path在描述里明确参数名
值不合理路径不存在返回错误让模型修正

关键原则:永远不要把参数错误变成异常抛出,而是作为工具结果返回给模型。模型看到错误信息后,大部分情况下能自己纠正。

5.3 Agent 陷入死循环的排查

死循环的表现是:同样的工具调用反复出现,token 消耗持续增长,任务没有进展。我遇到过三种死循环:

搜索无结果循环。模型搜索一个关键词,没找到,换个关键词再搜,还是没有,再换……解决方案是设置重复检测,连续两次结果相同就终止。

参数修正循环。模型传错参数,收到错误,改一下再传,还是错,再改……解决方案是限制单个工具的重试次数,超过就跳过。

目标漂移循环。模型做着做着忘了原始任务,开始做别的事情。解决方案是在每轮循环时把原始任务重新注入上下文,提醒模型目标是什么。

提示:调试死循环的时候,把每轮的消息列表打印出来,一眼就能看出模型在哪一步开始跑偏。

5.4 Token 消耗过快的优化手段

Agent 的 token 消耗比普通对话高得多,因为每轮都要带上完整上下文。优化手段按效果排序:

第一,工具结果截断。这是最有效的。一个工具返回几千字,截断到几百字,token 直接降一个数量级。

第二,滑动窗口。只保留最近几轮对话,更早的压缩成摘要。

第三,系统提示词精简。系统提示词每轮都会带上,写太长就是持续消耗。

第四,限制工具调用轮数。大部分任务 10 轮以内能完成,超过 15 轮基本就是出问题了。

我实测下来,这四条做完,token 消耗能降到原来的三分之一左右。

5.5 常见问题速查表

现象可能原因排查方法解决方案
模型不调用工具描述不清/提示词缺失检查工具 description补充描述和提示词
参数错误模型理解偏差打印 tool_call 参数加类型转换和错误返回
死循环重复调用/目标漂移打印消息列表加重复检测和任务重注入
Token 暴涨上下文过长统计每轮 token 数截断+滑动窗口
工具超时网络/IO 阻塞加日志看卡在哪设置超时和降级
结果不准确模型自己算检查是否调用工具强制关键操作走工具

6. 后续扩展方向与个人体会

这个 Mini Agent 跑通之后,扩展方向其实很多。我按优先级排一下:

第一优先级是加记忆。现在的 Agent 每次运行都是无状态的,任务结束记忆就没了。加一个简单的 JSON 文件存储,把每次任务的关键信息存下来,下次运行的时候加载。不需要向量数据库,一个字典就够。

第二优先级是加流式输出。现在要等整个循环跑完才能看到结果,体验不好。改成流式之后,模型每吐一个字都能看到,调试的时候也更容易发现模型在哪一步开始跑偏。

第三优先级是加多工具并行。现在每轮只能调一个工具,如果模型一次返回多个工具调用,可以并行执行。这个改动不大,但能明显提升效率。

暂时不要碰的:多 Agent 协作、复杂的状态机编排、向量数据库检索。这些东西在单 Agent 还没跑稳之前加进来,只会让问题更难排查。

我在这个项目里最大的体会是:Agent 的难点不在“智能”,而在“工程”。模型本身的能力已经足够强了,真正决定 Agent 好不好用的是那些琐碎的工程细节——超时怎么设、错误怎么返、上下文怎么裁、循环怎么停。这些东西没有捷径,只能一个一个踩过来。

还有一个反直觉的发现:工具越少,Agent 越稳。我一开始恨不得把所有能想到的工具都加上,结果模型选择困难,经常调错工具。后来砍到四个核心工具,准确率反而上去了。这大概就是“少即是多”在 Agent 设计里的体现。

最后分享一个调试小技巧:把每轮的消息列表用 JSON 格式化后打印出来,比看日志直观十倍。你能清楚看到模型看到了什么、决定了什么、得到了什么。大部分问题,看一眼消息列表就能定位。

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

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

立即咨询