1. 为什么我要自己动手写一个 Agent
市面上关于 Agent 的文章,十篇里有八篇在讲概念——什么自主决策、什么工具调用、什么记忆机制,看完之后脑子里全是名词,手上还是不知道从哪下手。我自己也是这样过来的,最开始看各种框架的文档,LangChain、AutoGPT、还有一堆名字都记不住的库,每个都号称能让你三行代码跑起来一个智能体。结果呢?三行代码确实能跑,但跑出来的是什么、为什么这么跑、出了问题怎么调,一概不知。
后来我换了个思路:与其在别人的框架上修修补补,不如从零参照一个轻量实现,把 Agent 的骨架亲手搭一遍。pi-agent 是我关注过的一个极简 Agent 实现,代码量不大,但核心该有的都有——任务循环、工具注册、上下文管理、结果解析。它不像那些大框架一样把什么都封装好,反而逼着你去理解每一步在干什么。
这篇文章就是记录我参照 pi-agent 的思路,从零实现一个小 Agent 的完整过程。我会把设计取舍、核心代码、踩过的坑、以及那些文档里不会写的经验都摊开来讲。适合谁看?如果你已经会写基本的 Python,对 API 调用不陌生,但每次看到“Agent”这个词就觉得隔了一层纱,那这篇就是写给你的。我不会假设你懂什么 ReAct、什么 Function Calling,这些概念我会在用到的时候用大白话解释清楚。
整个 Agent 的核心其实就三件事:让模型知道有哪些工具可以用、让模型决定用哪个工具、把工具的结果喂回给模型继续判断。听起来简单,但魔鬼全在细节里。下面我按实际搭建的顺序,一步步拆开讲。
2. 整体设计思路与核心架构拆解
2.1 为什么选“极简循环”而不是复杂框架
在动手之前,我先想清楚了一个问题:一个 Agent 最核心的循环是什么?答案其实很朴素——观察、思考、行动、再观察。模型拿到用户的问题和当前上下文,决定下一步做什么(可能是调用某个工具,也可能是直接回答),执行完之后把结果追加到上下文里,再让模型判断是否继续。这个循环直到模型认为任务完成,或者达到最大轮次为止。
pi-agent 的设计哲学就是这个:不搞复杂的规划器、不搞多智能体协作、不搞花哨的记忆压缩,先把最基础的循环跑通。我选择参照它而不是自己凭空设计,原因也很实际——它的循环边界清晰,每一轮的状态变化都可追踪,出了问题容易定位。相比之下,那些上来就搞“自主规划+反思+多轮压缩”的框架,一旦结果不对,你根本不知道是规划错了、工具选错了、还是上下文被压缩丢了信息。
所以我的整体架构就围绕这个循环来搭:
- 一个主循环:控制对话轮次,维护消息历史
- 一个工具注册表:管理所有可调用的工具及其描述
- 一个解析层:从模型输出里提取“要调用哪个工具、参数是什么”
- 一个执行层:真正调用工具并把结果格式化后塞回上下文
这四块各司其职,加起来代码量控制在几百行以内。下面逐个拆。
2.2 工具注册表的设计:让模型“知道有什么可用”
工具注册表是整个 Agent 的能力边界。模型再聪明,如果你不告诉它有哪些工具、每个工具干什么、参数长什么样,它就只能瞎猜。我见过不少人把工具描述写得极其简略,比如就写个“搜索”,结果模型根本不知道这个搜索是搜网页还是搜本地文件,参数该传什么格式。
我的做法是每个工具用一个字典来描述,包含四个字段:名称、功能描述、参数 schema、实际执行函数。功能描述要写得像给一个新同事交代任务一样清楚,参数 schema 用 JSON Schema 的格式定义,这样模型能准确理解每个参数的类型和含义。
tools = [ { "name": "read_file", "description": "读取指定路径的文本文件内容。当需要查看文件里写了什么时使用。", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "文件的相对路径,例如 data/notes.txt" } }, "required": ["path"] }, "func": read_file_impl }, { "name": "write_file", "description": "将内容写入指定路径的文件。如果文件已存在则覆盖。", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "目标文件路径"}, "content": {"type": "string", "description": "要写入的文本内容"} }, "required": ["path", "content"] }, "func": write_file_impl } ]这里有个关键点:描述的质量直接决定模型选工具的准确率。我实测下来,把“读取文件”改成“读取指定路径的文本文件内容,当需要查看文件里写了什么时使用”,模型在模糊场景下的选择正确率明显提升。因为后者不仅说了是什么,还说了什么时候用。
2.3 消息上下文的管理策略
上下文就是模型的“工作记忆”。每一轮循环,我们都要把系统提示、历史消息、工具执行结果拼成一个完整的消息列表发给模型。这里最容易出问题的是消息格式的一致性——不同角色的消息(system、user、assistant、tool)在拼接时有严格的顺序和配对要求,搞错了模型就会报错或者行为异常。
我的策略是维护一个列表,每条消息是一个字典,包含 role 和 content。系统提示固定在开头,用户输入追加在后面,模型每次的回复和工具结果依次追加。关键是要保证:每一条工具调用消息后面必须紧跟对应的工具结果消息,不能交叉,不能遗漏。这个约束在手动拼接时很容易违反,我后面会讲怎么用状态机来强制保证。
2.4 解析层的核心挑战:从自由文本里抠出结构化指令
这是整个 Agent 里最考验工程能力的地方。模型输出的是自然语言,但我们需要从中提取出“调用哪个工具、参数是什么”这样的结构化信息。有两种主流做法:一种是依赖模型 API 原生的 function calling 能力,另一种是让模型按固定格式输出然后自己解析。
我两种都试过。原生 function calling 更稳,但受限于具体模型和 API 的支持程度;自己解析更灵活,但需要处理模型不按格式输出的各种意外。pi-agent 用的是后者——让模型输出特定标记包裹的 JSON,然后正则提取。我参照这个思路,但加了一层容错:如果第一次解析失败,就把解析错误信息喂回给模型,让它重新输出。这个“解析失败自动重试”的机制,在实际使用中救了我无数次。
3. 核心模块的详细实现与关键细节
3.1 系统提示的写法:给模型立规矩
系统提示是 Agent 的“宪法”。它要告诉模型:你是一个 Agent,你可以使用工具,你输出工具调用的格式是什么,什么时候该直接回答,什么时候该调工具。我见过很多实现把系统提示写得太笼统,结果模型要么该调工具的时候不调,要么不该调的时候乱调。
我的系统提示包含这几个部分,每一部分都有明确目的:
第一段定义角色和能力边界:“你是一个可以调用工具的助手。你可以使用以下工具来完成任务。如果任务可以直接回答,就直接回答;如果需要读取或写入信息,就调用相应工具。”
第二段给出工具列表和调用格式。格式我用的是 XML 风格的标记,因为模型对这类标记的遵循度比纯 JSON 更高:
当你需要调用工具时,请严格按以下格式输出: <tool_call> {"name": "工具名", "arguments": {"参数名": "参数值"}} </tool_call> 调用完成后,你会收到 <tool_result> 包裹的结果。第三段是行为约束:“每次只调用一个工具。收到工具结果后,判断任务是否完成,未完成则继续调用,完成则直接给出最终回答。”
注意:系统提示里的格式示例必须和解析层的正则完全对应。我踩过一次坑,提示里写的是
<tool_call>,解析时正则写成了<tool-call>,结果模型老老实实按提示输出,解析层一个都匹配不到,Agent 直接卡死。这种低级错误排查起来特别费时间,因为模型行为看起来完全正常。
3.2 主循环的实现:状态机保证消息顺序
主循环我用一个简单的状态机来管理。状态只有两个:等待模型输出、等待工具执行。每轮循环根据当前状态决定下一步动作,这样能严格保证消息顺序不会乱。
def run_agent(user_input, max_turns=10): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for turn in range(max_turns): # 调用模型 response = call_llm(messages) messages.append({"role": "assistant", "content": response}) # 尝试解析工具调用 tool_call = parse_tool_call(response) if tool_call is None: # 没有工具调用,说明模型给出了最终回答 return response # 执行工具 tool_name = tool_call["name"] tool_args = tool_call["arguments"] result = execute_tool(tool_name, tool_args) # 把结果追加到上下文 messages.append({ "role": "user", "content": f"<tool_result>{result}</tool_result>" }) return "达到最大轮次限制,任务未完成。"这段代码看起来简单,但有几个细节值得说。第一,工具结果我用 user 角色追加,而不是自定义一个 tool 角色。原因是不同模型 API 对自定义角色的支持不一致,用 user 角色兼容性最好。第二,max_turns是必须的,否则模型可能陷入无限循环——我遇到过模型反复调用同一个工具读取同一个文件,因为它“忘记”自己已经读过了。第三,解析失败时我直接返回模型输出作为最终回答,而不是报错,这样至少能给用户一个交代。
3.3 工具执行的安全边界
工具执行层是 Agent 真正“动手”的地方,也是最容易出安全问题的地方。我给自己定了三条铁律:
第一,文件操作限制在工作目录内。所有路径先做规范化,然后检查是否以工作目录为前缀。不满足就拒绝执行并返回错误信息。这个检查看起来多余,但模型真的会生成../../etc/passwd这样的路径,尤其是在它“想”读取系统信息的时候。
第二,每个工具执行加超时。我用signal.alarm或者线程池来做超时控制,防止某个工具卡死导致整个 Agent 挂起。超时时间设成 10 秒,对于文件读写和简单计算足够了。
第三,工具执行结果做长度截断。如果工具返回的内容超过一定长度(我设的是 4000 字符),就截断并加上提示“结果过长已截断”。不截断的话,上下文会迅速膨胀,既浪费 token 又可能超出模型的最大上下文限制。
def execute_tool(name, args): tool = find_tool(name) if tool is None: return f"错误:未找到名为 {name} 的工具" # 路径安全检查 if "path" in args: safe_path = os.path.normpath(args["path"]) if not safe_path.startswith(WORK_DIR): return "错误:路径超出允许范围" args["path"] = safe_path try: result = tool["func"](**args) result_str = str(result) if len(result_str) > 4000: result_str = result_str[:4000] + "...(结果过长已截断)" return result_str except Exception as e: return f"工具执行出错:{str(e)}"实操心得:工具执行出错时,不要把 Python 的完整 traceback 返回给模型,那样会浪费大量 token 且模型也看不懂。返回简短的错误描述就够了,模型会根据错误信息调整策略。比如返回“文件不存在”,模型下一轮就会尝试列出目录看看有什么文件。
3.4 解析层的容错设计
解析层我用了两层策略。第一层是严格匹配<tool_call>...</tool_call>标记,提取里面的 JSON。第二层是如果第一层失败,尝试从输出里找任何看起来像 JSON 的片段。如果都失败,就把“解析失败,请按格式重新输出”作为工具结果喂回去,让模型重试。
import re, json def parse_tool_call(text): # 第一层:严格匹配标记 pattern = r"<tool_call>\s*(\{.*?\})\s*</tool_call>" match = re.search(pattern, text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 第二层:找裸 JSON json_pattern = r'\{[^{}]*"name"[^{}]*"arguments"[^{}]*\}' match = re.search(json_pattern, text, re.DOTALL) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass return None这里有个细节:正则里的.*?用了非贪婪匹配,配合re.DOTALL让点号能匹配换行。如果不加re.DOTALL,模型输出多行 JSON 时就匹配不到。这个坑我踩过,排查了半天才发现是正则标志位的问题。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖选择
整个项目我只用了 Python 标准库加一个 HTTP 请求库。标准库负责文件操作、正则、JSON 解析、路径处理,HTTP 库负责调用模型 API。没有用任何 Agent 框架,也没有用 LangChain 之类的重型依赖。这样做的好处是每一行代码都在自己掌控之内,出了问题不用去翻别人的源码。
Python 版本建议 3.9 以上,因为用到了字典合并操作符和一些类型注解的新语法。HTTP 库我选的是requests,够用且稳定。如果你要用异步,可以换成httpx,但我的建议是先把同步版本跑通,理解清楚每一步在干什么,再考虑异步优化。
pip install requests项目目录结构我保持极简:
mini-agent/ ├── agent.py # 主循环和核心逻辑 ├── tools.py # 工具定义和实现 ├── prompt.py # 系统提示 └── workspace/ # 工具操作的工作目录4.2 模型 API 调用的参数选择
调用模型时,有几个参数直接影响 Agent 的行为,我一个个说我的选择和理由。
temperature:我设成 0.1 而不是 0。设 0 的话模型输出完全确定,但有时候会陷入死循环——同样的输入永远给同样的输出,如果这个输出恰好导致解析失败,就永远失败。设 0.1 保留一点点随机性,给模型“换个说法”的机会。但也不能太高,太高了模型会不按格式输出。
max_tokens:设成 2000。太短了模型可能还没输出完工具调用就被截断,太长了浪费。2000 对于单次工具调用和简短回答足够了。
stop:我设了</tool_call>作为停止符。这样模型输出完工具调用就停,不会继续瞎编工具结果。这个技巧很实用,能有效防止模型“自问自答”。
def call_llm(messages): response = requests.post( API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json={ "model": MODEL_NAME, "messages": messages, "temperature": 0.1, "max_tokens": 2000, "stop": ["</tool_call>"] }, timeout=30 ) data = response.json() return data["choices"][0]["message"]["content"]注意:不同模型 API 的返回结构不一样,有的在
choices[0].message.content,有的在choices[0].text。这个要看你用的具体服务,别照抄。我建议先把 API 调通,打印出完整返回结构,确认字段路径后再写解析代码。
4.3 一个完整的任务执行实录
我拿一个实际任务来演示整个流程:让 Agent 读取工作目录下的notes.txt,统计里面有多少行,然后把行数写入count.txt。
第一轮,用户输入“读取 notes.txt,统计行数,写入 count.txt”。模型收到系统提示和用户输入后,输出:
我需要先读取文件内容。 <tool_call> {"name": "read_file", "arguments": {"path": "notes.txt"}} </tool_call>解析层提取出工具调用,执行read_file,返回文件内容。假设文件有 15 行,内容被追加到上下文。
第二轮,模型看到文件内容,输出:
文件共有 15 行,现在写入 count.txt。 <tool_call> {"name": "write_file", "arguments": {"path": "count.txt", "content": "15"}} </tool_call>执行写入,返回“写入成功”。
第三轮,模型看到写入成功,输出最终回答:“已完成。notes.txt 共 15 行,行数已写入 count.txt。”
整个流程三轮结束,消息历史里依次是:system、user、assistant(含工具调用)、user(工具结果)、assistant(含工具调用)、user(工具结果)、assistant(最终回答)。顺序严格配对,没有交叉。
4.4 参数计算与选择过程
上面例子简单,但实际任务里经常需要模型做参数计算。比如“把文件里所有数字加起来”,模型需要先读取文件,然后自己算加法。这里有个陷阱:模型的算术能力不稳定,尤其是数字多的时候。
我的处理方式是把计算也做成工具。加一个calculate工具,接收一个表达式字符串,用 Python 的eval在受限环境下求值。这样模型只需要提取数字和运算符,实际计算交给代码,准确率大幅提升。
def calculate_impl(expression): # 只允许数字和基本运算符 allowed = set("0123456789+-*/.() ") if not all(c in allowed for c in expression): return "错误:表达式包含不允许的字符" try: return str(eval(expression)) except Exception as e: return f"计算错误:{str(e)}"这个工具的描述我写的是“计算数学表达式,支持加减乘除和括号。当需要进行算术运算时使用,不要自己心算。”最后那句“不要自己心算”很关键,明确告诉模型把计算外包出来。
5. 常见问题与排查技巧实录
5.1 模型不调用工具怎么办
这是最常见的问题。模型收到任务后,直接用自己的知识回答,而不是调用工具。原因通常有三个:系统提示没说清楚可以调工具、工具描述不够吸引人、或者任务本身看起来不需要工具。
我的排查顺序是:先看系统提示里工具调用的格式说明是否足够醒目,我一般会把格式示例放在提示的靠前位置;再看工具描述是否说清楚了“什么时候用”,比如把“读取文件”改成“当需要查看文件内容时使用”;最后看任务本身,如果任务确实可以直接回答,模型不调工具反而是对的。
如果确认是提示问题,我会在系统提示里加一句强调:“对于需要读取或写入文件的任务,必须先调用相应工具,不要凭猜测回答。”这句话加上之后,该调工具的场景基本都能调对。
5.2 工具调用格式解析失败
模型输出的格式和解析正则对不上,这是第二常见的问题。表现是 Agent 卡在某一轮,反复重试解析失败。
排查时我先把模型原始输出打印出来,肉眼看看它到底输出了什么。常见情况有:用了单引号而不是双引号、JSON 里有多余的换行、标记名拼写不一致、或者模型把工具调用和普通文本混在一起输出。
针对这些情况,我的解析层做了这些容错:JSON 解析前先把单引号替换成双引号(简单替换,不处理嵌套情况)、去掉标记前后的空白、正则用re.DOTALL支持多行。如果还不行,就把解析错误信息喂回去让模型重试,通常重试一次就能对。
5.3 无限循环的识别与打断
模型反复调用同一个工具、或者两个工具来回调用,形成死循环。我遇到过模型读文件、发现内容不对、再读同一个文件、还是不对、再读……无限重复。
识别方法很简单:记录最近几轮的工具调用,如果连续三轮调用同一个工具且参数相同,就判定为循环。打断方式是在工具结果里加一句提示:“你已经调用过这个工具且参数相同,请尝试其他方法或直接给出回答。”这句话通常能让模型跳出循环。
更根本的解决办法是在系统提示里加约束:“不要重复调用参数相同的同一个工具。如果工具结果不符合预期,尝试其他工具或直接说明情况。”
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 模型不调工具直接回答 | 提示未强调工具必要性 | 检查系统提示 | 加“必须先调用工具”约束 |
| 解析失败反复重试 | 输出格式与正则不匹配 | 打印原始输出 | 放宽正则、加容错替换 |
| 无限循环调用 | 模型未记住已执行结果 | 记录最近工具调用 | 加重复检测和提示 |
| 工具执行超时 | 工具内部阻塞 | 加日志定位 | 设置超时、优化工具实现 |
| 上下文超长报错 | 工具结果过长 | 检查结果长度 | 截断结果、压缩历史 |
| 路径安全检查误拦 | 路径规范化问题 | 打印规范化后路径 | 调整工作目录判断逻辑 |
避坑技巧:我建议在开发阶段把每一轮的完整消息列表打印到日志文件,出问题时直接翻日志,比在代码里加断点高效得多。日志里重点看三样东西:模型原始输出、解析结果、工具执行结果。这三样对上了,流程基本就对了。
5.5 上下文膨胀的处理经验
Agent 跑多轮之后,消息列表会越来越长,最终超出模型的最大上下文限制。我的处理策略是分层压缩:最近三轮的消息保持完整,更早的工具结果如果超过 500 字符就截断到 500 字符并加省略号,系统提示永远保留完整。
这个策略的取舍在于:近期信息对当前决策最重要,远期信息只需要保留“做过什么”的痕迹,不需要保留完整内容。实测下来,压缩后 Agent 在长任务上的表现基本不受影响,但 token 消耗能降低一半以上。
6. 工具选型与扩展思路
6.1 为什么我坚持不用现成框架
写这个小 Agent 的过程中,我不止一次想过“要不直接用 LangChain 算了”。但每次都被自己按住了。原因很实在:现成框架帮你省掉的代码量,远小于它给你增加的调试成本。当 Agent 行为不符合预期时,你需要理解框架内部的提示拼接逻辑、工具调用解析逻辑、记忆管理逻辑,这些逻辑往往比你自己写的复杂十倍。
自己写的好处是每一行代码你都清楚它的意图。解析失败?看一眼正则就知道问题在哪。工具没调对?改一下描述就行。这种掌控感在调试阶段价值巨大。当然,如果你的需求是快速搭一个演示,用框架没问题;但如果你想真正理解 Agent 是怎么回事,自己写一遍是绕不过去的。
6.2 可以扩展的方向
这个极简 Agent 跑通之后,有几个自然的扩展方向。加记忆:把历史对话存到本地文件,下次启动时加载,实现跨会话记忆。加多工具协作:让模型在一次输出里调用多个工具,然后并行执行,提升效率。加人工确认:对于写文件、删文件这类危险操作,执行前先让用户确认。加流式输出:模型输出时实时显示,提升交互体验。
每个扩展方向都对应着新的工程挑战。比如加多工具并行调用,就要处理结果顺序和错误隔离的问题;加人工确认,就要设计一个暂停和恢复的机制。这些挑战正是 Agent 开发真正有意思的地方。
6.3 我个人的经验总结
最后分享几个我踩坑之后总结的经验。第一,先把单轮工具调用跑通,再考虑多轮。很多人一上来就搞复杂循环,结果基础没打牢,出了问题根本不知道是哪一层的事。第二,日志比调试器好用。Agent 的行为是概率性的,断点调试经常复现不了问题,完整日志才是排查利器。第三,工具描述值得反复打磨。我花在改工具描述上的时间,比写工具实现的时间还多,但每一分钟都值得。第四,别追求一次完美。先让 Agent 能跑,再让它跑得稳,最后才让它跑得好。这个顺序不能反。
这个项目后续还可以这样扩展:把工具注册表改成动态加载,从配置文件读取工具定义,这样加新工具不用改代码;把主循环改成异步,支持并发处理多个任务;把解析层改成可插拔的,支持不同的模型输出格式。每一步扩展都是对 Agent 理解的一次加深。