1. 从零理解 Agent 开发:为什么值得亲手造一个小 Agent
很多人第一次接触 Agent 这个概念,脑子里浮现的都是各种演示视频里那种能自己规划任务、调用工具、读写文件、还能自我纠错的“智能体”。看的时候觉得挺神奇,但真要自己动手做一个,往往就卡在第一步:这东西到底由哪些部分组成?它的“思考”是怎么变成“行动”的?为什么有的 Agent 跑得稳,有的跑两下就死循环?
我最初也有同样的困惑。后来参照了一个叫 pi-agent 的轻量实现,自己动手撸了一个最小可用的 Agent,才算把这条链路彻底打通。这篇文章就是把我从“看热闹”到“能跑通”的整个过程拆开来讲,包括整体设计思路、核心模块的实现细节、实操中踩过的坑,以及一套可以直接抄作业的最小实现方案。
先明确一下这篇文章适合谁看。如果你已经会写基本的代码,对 API 调用不陌生,但没真正做过 Agent,那这篇就是写给你的。如果你已经做过一些 Agent 项目,但总觉得结构混乱、不好扩展,也可以看看我这里的分层思路,或许能给你一些重构的灵感。整篇内容围绕一个核心目标:用最少的依赖,实现一个能理解指令、能调用工具、能多轮循环直到完成任务的小 Agent。
在开始之前,先把这个 Agent 的最小能力边界说清楚。它不需要多模态,不需要复杂的记忆系统,也不需要接入几十个工具。它只需要具备三样东西:一个能理解自然语言指令的“大脑”,一套能执行具体动作的“工具”,以及一个能把两者串起来的“循环控制”。这三样东西组合起来,就已经是一个能干活的最小 Agent 了。后面所有的复杂度,都是在这个骨架上长出来的。
我参照 pi-agent 的思路,把这个小 Agent 命名为 mini-agent,方便后面描述。它的核心流程可以用一句话概括:接收用户输入,交给模型判断下一步该做什么,如果需要调用工具就执行工具,把结果再喂回模型,循环往复直到模型认为任务完成。听起来简单,但每一步都有不少细节值得推敲。
2. 整体架构设计:mini-agent 的骨架怎么搭
2.1 核心模块划分与职责边界
在动手写代码之前,先把模块划分清楚,这一步偷懒后面会加倍还回来。我见过不少人一上来就写一个大函数,把模型调用、工具执行、循环控制全塞在一起,结果调试的时候根本不知道问题出在哪一层。mini-agent 我分成了四个核心模块,每个模块只干一件事。
第一个是模型交互层,负责和语言模型通信。它接收一段对话历史,返回模型的回复。这一层不关心业务逻辑,只负责把请求发出去、把响应拿回来。第二个是工具注册层,负责管理所有可用的工具。每个工具都有自己的名称、描述、参数定义和执行函数。第三个是循环控制层,这是 Agent 的心脏,负责驱动整个“思考-行动-观察”的循环。第四个是对话历史管理,负责维护消息列表,确保模型每次都能看到完整的上下文。
这样分的好处是,每一层都可以独立替换。比如你想换一个模型提供商,只需要改模型交互层;想加一个新工具,只需要在工具注册层注册;想调整循环策略,只需要改循环控制层。各层之间通过明确的接口通信,不会互相污染。
提示:模块划分的粒度没有绝对标准,但有一条原则值得参考——如果两个部分的修改原因不同,就应该分开。模型调用会因为换供应商而改,工具执行会因为加功能而改,这两者的修改原因不同,所以应该分开。
2.2 为什么选择“循环式”而不是“流水线式”
Agent 的架构模式大致有两种:一种是流水线式,把任务拆成固定的几个阶段,依次执行;另一种是循环式,让模型在每一轮自己决定下一步做什么。mini-agent 选择了循环式,原因很简单:流水线式需要你提前知道任务的所有步骤,但真实场景里,用户的需求往往是模糊的,需要 Agent 在执行过程中根据中间结果动态调整。
举个例子,用户说“帮我整理一下这个目录下的文件”。如果是流水线式,你得提前定义好“扫描目录、分类文件、移动文件”这几个固定步骤。但用户可能只是想重命名,也可能想按类型归档,还可能想删除重复文件。这些分支在流水线式架构里很难优雅处理。而循环式架构下,模型可以先扫描目录,看到文件列表后再决定下一步,灵活性高得多。
当然,循环式也有代价。最大的风险是死循环——模型可能反复调用同一个工具,或者在一个问题上绕圈子。所以循环控制层必须设置最大轮次限制,并且在检测到重复行为时主动中断。这个后面会详细讲。
2.3 消息协议设计:让模型“看得懂”上下文
模型交互的核心是消息列表。每条消息都有角色和内容两个基本属性。角色通常有三种:系统、用户、助手。系统消息用来设定 Agent 的行为准则,用户消息是输入,助手消息是模型的回复。但在 Agent 场景下,还需要第四种角色——工具。工具消息用来承载工具执行的结果,让模型知道刚才那个动作产生了什么输出。
消息的顺序也很关键。一个典型的循环是这样的:系统消息在最前面,然后是用户消息,接着是助手消息(可能包含工具调用请求),再是工具消息(工具执行结果),然后再是助手消息……如此往复。模型每次看到的都是完整的消息列表,这样它才能基于全部历史做决策。
这里有个容易踩的坑:工具消息的格式必须和模型期望的格式严格一致。不同模型提供商对工具调用结果的格式要求不同,有的要求用特定字段包裹,有的要求用特定角色标识。如果格式不对,模型可能会忽略工具结果,或者直接报错。我在实操中就遇到过因为工具消息格式不对,导致模型反复调用同一个工具的情况。
3. 核心模块实现细节:从模型调用到工具执行
3.1 模型交互层:如何稳定地拿到结构化输出
模型交互层最关键的任务,是让模型输出结构化的内容,而不是一段自由文本。因为 Agent 需要知道模型是想“说话”还是想“调用工具”,如果输出是自由文本,解析起来会非常脆弱。目前主流的做法有两种:一种是让模型输出特定格式的文本(比如 JSON),然后自己解析;另一种是使用模型提供商提供的工具调用接口,让模型直接返回结构化的工具调用请求。
mini-agent 选择了第二种,因为更稳定。工具调用接口会返回一个结构化的对象,里面明确标识了工具名称和参数,不需要自己做文本解析。但这里有个细节:不是所有模型都支持工具调用接口,如果不支持,就得退回到文本解析方案。文本解析方案的核心是设计一个清晰的输出格式,并且在系统提示里反复强调这个格式。
我在实操中的经验是,系统提示里对输出格式的描述要尽可能具体,最好给出一个完整的示例。比如不要只说“请用 JSON 格式输出”,而要说“请输出一个 JSON 对象,包含 action 和 parameters 两个字段,action 是字符串,parameters 是对象。例如:{"action": "read_file", "parameters": {"path": "test.txt"}}”。示例越具体,模型出错的概率越低。
注意:即使使用了工具调用接口,也要对返回结果做校验。我遇到过模型返回的工具名称不在注册列表里的情况,如果不校验直接执行,程序就会崩溃。校验的逻辑很简单:检查工具名称是否存在,检查参数是否完整,检查参数类型是否正确。这三步做完,能挡掉大部分异常。
3.2 工具注册层:设计一个易扩展的工具接口
工具注册层的设计目标只有一个:加一个新工具的时候,不需要改其他任何代码。为了实现这一点,每个工具需要自描述——它自己声明名称、描述、参数 schema 和执行函数。注册的时候只需要把这些信息塞进一个字典,循环控制层根据名称查找并执行。
工具的描述非常重要,因为模型是根据描述来判断什么时候该用这个工具的。描述写得好,模型用对的概率就高;描述写得含糊,模型就会乱用。我总结了一个写工具描述的模板:第一句说明这个工具做什么,第二句说明什么时候用,第三句说明参数含义。比如一个读文件的工具,描述可以写成“读取指定路径的文件内容。当需要查看文件内容时使用。参数 path 是文件的相对路径。”
参数 schema 建议用 JSON Schema 格式,因为大多数模型提供商都支持这种格式。JSON Schema 可以定义参数的类型、是否必填、默认值等信息,模型会根据 schema 来生成参数。如果 schema 写得清楚,模型生成的参数准确率会明显提升。
# 工具注册的简化示例 tools = {} def register_tool(name, description, parameters, func): tools[name] = { "name": name, "description": description, "parameters": parameters, "func": func } # 注册一个读文件的工具 register_tool( name="read_file", description="读取指定路径的文件内容。当需要查看文件内容时使用。", parameters={ "type": "object", "properties": { "path": {"type": "string", "description": "文件的相对路径"} }, "required": ["path"] }, func=lambda path: open(path).read() )3.3 循环控制层:Agent 的“心脏”怎么跳
循环控制层是整个 Agent 最核心的部分,它决定了 Agent 什么时候继续、什么时候停止、什么时候报错。mini-agent 的循环逻辑是这样的:先把系统消息和用户消息放进消息列表,然后进入循环。每一轮循环,把当前消息列表发给模型,拿到回复。如果回复里包含工具调用请求,就执行工具,把结果作为工具消息追加到消息列表,然后进入下一轮。如果回复里没有工具调用请求,说明模型认为任务完成了,循环结束,返回最终回复。
这个逻辑看起来简单,但有几个关键决策点需要仔细处理。第一个是最大轮次限制。如果不限制,模型可能永远不认为任务完成,循环就会一直跑下去。我一般设置 10 到 15 轮,具体取决于任务的复杂度。第二个是重复检测。如果模型连续两轮调用同一个工具、传同样的参数,大概率是卡住了,应该主动中断并提示模型换个思路。第三个是错误处理。工具执行可能失败,失败信息也要作为工具消息返回给模型,让模型知道刚才那个动作没成功,需要调整策略。
这里有个实操心得:在系统提示里明确告诉模型“如果任务已经完成,请直接回复结果,不要再调用工具”。这句话能显著降低模型在任务完成后还继续调用工具的概率。另外,在工具执行失败时,返回的错误信息要尽量具体,比如“文件不存在”比“执行失败”更有助于模型调整策略。
3.4 对话历史管理:上下文窗口的取舍
对话历史管理看起来简单,其实有不少讲究。最直接的做法是把所有消息都保留,但上下文窗口是有限的,消息太多会超出限制。所以需要一套裁剪策略。常见的做法是保留系统消息和最近 N 轮对话,把更早的消息丢弃。但这样有个问题:如果早期消息里有重要的工具执行结果,丢弃后模型可能会重复执行。
我的做法是分两级处理。第一级是保留系统消息和最近 N 轮完整对话。第二级是对更早的消息做摘要,把关键信息压缩成一段简短文本,作为一条系统消息插入。这样既控制了长度,又保留了关键信息。摘要的生成可以再调用一次模型,也可以用简单的规则提取。如果任务不复杂,用规则提取就够了。
提示:上下文窗口的裁剪策略没有标准答案,取决于你的任务特点。如果任务步骤多、依赖强,就多保留一些历史;如果任务独立性强,就可以激进裁剪。建议先用保守策略,观察模型表现后再调整。
4. 实操全流程:从零跑通一个 mini-agent
4.1 环境准备与依赖安装
先把环境搭起来。mini-agent 的依赖非常少,核心只需要一个 HTTP 请求库和一个 JSON 处理库。如果你用的模型提供商有官方 SDK,也可以直接用 SDK,省去自己处理请求格式的麻烦。我这里的示例用通用的 HTTP 请求方式,方便你替换成任何提供商。
# 创建虚拟环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装依赖 pip install requests环境变量里配置好 API 密钥和接口地址。不要把密钥硬编码在代码里,这是基本的安全习惯。可以用环境变量,也可以用配置文件,但配置文件要加入版本控制忽略列表。
export AGENT_API_KEY="your-api-key" export AGENT_API_BASE="your-api-base"4.2 核心代码逐段拆解
先写模型交互层。这一层的核心是构造请求体、发送请求、解析响应。请求体里需要包含模型名称、消息列表、工具定义等字段。响应里需要提取模型回复和工具调用请求。
import os import json import requests def call_model(messages, tools): url = f"{os.environ['AGENT_API_BASE']}/chat/completions" headers = { "Authorization": f"Bearer {os.environ['AGENT_API_KEY']}", "Content-Type": "application/json" } payload = { "model": "your-model-name", "messages": messages, "tools": tools, "tool_choice": "auto" } response = requests.post(url, headers=headers, json=payload) response.raise_for_status() return response.json()然后是工具注册层。前面已经给过示例,这里补充一下工具执行的部分。执行工具的时候要捕获异常,把异常信息作为结果返回,而不是让程序崩溃。
def execute_tool(name, arguments): if name not in tools: return f"错误:工具 {name} 不存在" try: result = tools[name]["func"](**arguments) return str(result) except Exception as e: return f"错误:{str(e)}"最后是循环控制层。这是把前面两层串起来的地方。
def run_agent(user_input, max_turns=10): messages = [ {"role": "system", "content": "你是一个助手,可以使用工具完成任务。任务完成后直接回复结果。"}, {"role": "user", "content": user_input} ] tool_definitions = [ {"type": "function", "function": { "name": t["name"], "description": t["description"], "parameters": t["parameters"] }} for t in tools.values() ] for turn in range(max_turns): response = call_model(messages, tool_definitions) message = response["choices"][0]["message"] messages.append(message) if not message.get("tool_calls"): return message["content"] for tool_call in message["tool_calls"]: name = tool_call["function"]["name"] arguments = json.loads(tool_call["function"]["arguments"]) result = execute_tool(name, arguments) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": result }) return "达到最大轮次限制,任务未完成"4.3 参数选择与调优记录
模型调用有几个参数值得调。第一个是温度,Agent 场景下建议用较低的温度,比如 0.1 到 0.3,因为需要模型稳定地输出结构化内容,而不是发挥创造力。第二个是最大输出长度,根据任务复杂度设置,一般 1000 到 2000 个 token 够用。第三个是工具选择策略,设置为 auto 让模型自己决定是否调用工具,如果希望强制调用可以设置为 required,但一般不建议。
最大轮次限制我一般设 10。实测下来,大部分简单任务 3 到 5 轮就能完成,复杂任务 8 到 10 轮也够了。如果超过 10 轮还没完成,大概率是卡住了,继续跑也没意义。重复检测的阈值我设的是连续两轮相同工具加相同参数,触发后就中断并返回提示。
注意:不同模型对工具调用的支持程度不同。有的模型能很好地理解工具描述并正确调用,有的模型则需要更详细的提示。如果发现模型经常调用错误的工具,先检查工具描述是否清晰,再考虑换模型。
5. 常见问题与排查技巧实录
5.1 模型不调用工具怎么办
这是最常见的问题。模型收到用户输入后,直接回复了一段文字,而不是调用工具。原因通常有三个:一是工具描述不够清晰,模型没理解这个工具是干什么的;二是系统提示没有强调可以使用工具;三是模型本身对工具调用的支持不好。
排查顺序是这样的:先看工具描述,把描述改得更具体,加上使用场景说明;再看系统提示,明确写“你可以使用以下工具来完成任务”;如果还不行,就换一个对工具调用支持更好的模型。我在实操中遇到过一次,工具描述写的是“处理文件”,模型完全不知道什么时候该用。改成“读取指定路径的文件内容,当需要查看文件内容时使用”之后,模型就能正确调用了。
5.2 工具调用参数错误怎么修
模型生成的参数可能缺少必填字段,或者类型不对。比如需要字符串的地方传了数字,需要对象的地方传了字符串。解决方法是把参数 schema 写得更严格,并且在系统提示里强调参数格式。另外,在工具执行前做一次参数校验,如果校验不通过,把错误信息返回给模型,让它重新生成。
我一般会在工具执行函数里加一层校验,检查必填参数是否存在、类型是否正确。校验不通过就返回具体的错误信息,比如“参数 path 是必填的,且必须是字符串”。模型收到这个信息后,下一轮通常会修正。
5.3 死循环的识别与中断
死循环的表现是模型反复调用同一个工具,或者在不同工具之间来回跳转但始终不结束。识别方法是记录每一轮的工具调用,如果连续两轮的工具名称和参数完全相同,就判定为死循环。中断后返回一个提示,告诉用户任务可能无法完成,并附上最后几轮的工具调用记录,方便排查。
还有一种隐蔽的死循环是模型不断调用工具但每次参数略有不同,比如每次读取的文件路径不一样。这种情况比较难自动识别,只能靠最大轮次限制兜底。如果发现任务经常跑到最大轮次,可以考虑在系统提示里加入“如果连续多次尝试仍未成功,请停止并报告问题”的指令。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 模型不调用工具 | 工具描述不清 | 检查工具描述是否具体 | 补充使用场景说明 |
| 参数格式错误 | schema 不严格 | 检查参数类型定义 | 加校验并返回错误信息 |
| 死循环 | 模型卡住 | 记录工具调用历史 | 加重复检测和轮次限制 |
| 工具执行报错 | 参数不对或环境问题 | 查看错误信息 | 捕获异常并返回给模型 |
| 上下文超限 | 消息太多 | 检查消息数量 | 裁剪历史或做摘要 |
5.5 几个让我少走弯路的实操心得
第一个心得是,系统提示要短而精。我一开始写了一大段系统提示,结果模型反而抓不住重点。后来精简到几句话,只保留最关键的指令,效果反而更好。第二个心得是,工具数量不要太多。工具越多,模型选择错误的概率越大。如果确实需要很多工具,可以考虑分组,或者用路由的方式先让模型选择工具类别,再选择具体工具。
第三个心得是,日志要打全。每一轮的模型输入、模型输出、工具调用、工具结果都要记录下来。出问题的时候,翻日志比猜原因快得多。我一般会把日志写到文件里,按时间戳命名,方便回溯。第四个心得是,先用简单任务验证链路,再上复杂任务。链路没跑通之前,不要急着加功能,否则出了问题很难定位是链路问题还是功能问题。
6. 扩展方向:从 mini-agent 到更实用的形态
6.1 加入记忆系统让 Agent 记住历史
mini-agent 目前是无状态的,每次运行都是全新的开始。如果要让它记住之前的交互,就需要加入记忆系统。最简单的做法是把历史对话存到本地文件或数据库,每次启动时加载。更复杂的做法是加入向量检索,把历史对话做嵌入,根据当前输入检索相关的历史片段。
我试过用本地文件存历史,实现简单,但检索效率低。后来改成用轻量数据库,按时间戳和关键词检索,效果好一些。如果任务对记忆要求高,可以考虑引入向量数据库,但复杂度也会上升。建议先用简单方案,确认需求后再升级。
6.2 多工具协作与任务分解
当工具数量增多时,可以让 Agent 先做任务分解,把大任务拆成小任务,再逐个执行。实现方式是在系统提示里加入“先规划再执行”的指令,让模型先输出一个任务列表,然后按顺序执行。每完成一个子任务,就把结果记录下来,作为后续子任务的输入。
这种模式的好处是可控性更强,每个子任务都有明确的完成标准。坏处是灵活性降低,如果子任务之间有依赖关系,需要额外处理。我在实操中的做法是让模型自己决定是否需要分解,如果任务简单就直接执行,如果任务复杂就先分解。这样兼顾了灵活性和可控性。
6.3 错误恢复与重试策略
Agent 执行过程中难免遇到错误,比如工具调用失败、模型输出格式不对等。一个好的 Agent 应该能从错误中恢复,而不是直接崩溃。mini-agent 目前的错误处理比较简单,只是把错误信息返回给模型。更完善的做法是加入重试策略,比如工具调用失败后自动重试一次,如果还失败再返回错误。
重试策略需要设置最大重试次数,避免无限重试。我一般设置 2 到 3 次,超过就放弃并报告错误。另外,重试的时候可以调整参数,比如换一个工具或者换一种方式调用。这些策略可以在循环控制层实现,根据错误类型决定是否重试以及如何重试。
6.4 性能优化:减少不必要的模型调用
模型调用是 Agent 中最耗时的环节,减少调用次数能显著提升性能。一个常见的优化是缓存。如果同一个工具用同样的参数被调用多次,可以直接返回缓存结果,不用重新执行。另一个优化是合并请求。如果模型在一轮里调用了多个工具,可以并行执行,减少等待时间。
还有一个优化是提前终止。如果模型已经给出了最终答案,就不用再进入下一轮。判断标准是模型回复里没有工具调用请求。这个逻辑 mini-agent 已经有了,但可以进一步优化,比如在系统提示里明确告诉模型“如果任务完成,直接回复结果,不要调用工具”,减少模型在任务完成后还继续调用工具的情况。
7. 我个人的一些体会
做 mini-agent 这个项目的过程中,最大的收获不是写出了多少代码,而是对 Agent 的工作机制有了更直观的理解。以前看别人的 Agent 演示,觉得神秘,自己动手之后发现,核心就是“模型判断加工具执行加循环控制”这三件事。把这三件事拆清楚,剩下的都是工程细节。
另一个体会是,Agent 的稳定性比功能丰富度更重要。我一开始总想加更多工具、更多功能,结果 Agent 经常出错。后来把工具精简到最必要的几个,把循环控制做扎实,稳定性反而上去了。对于刚上手的人来说,建议先把最小链路跑通,确认稳定后再逐步扩展。
最后分享一个小技巧:在调试 Agent 的时候,把每一轮的模型输入和输出都打印出来,用不同颜色区分。这样一眼就能看出模型在哪一步做了错误决策,比看日志文件直观得多。这个习惯帮我省了很多排查时间。