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_turns10): 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: ftool_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 rtool_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: fBearer {API_KEY}}, json{ model: MODEL_NAME, messages: messages, temperature: 0.1, max_tokens: 2000, stop: [/tool_call] }, timeout30 ) 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 理解的一次加深。