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( namecalculator, 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_callsNone): 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, timeout10): 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(timeouttimeout) 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_rounds10): 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_iterations15): 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, toolsget_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. 完整实操流程从零跑通一个 Agent4.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( nameread_file, description读取指定路径的文件内容返回文本, parameters{ path: {type: string, description: 文件的完整路径} } ) def read_file(path): with open(path, r, encodingutf-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 格式化后打印出来比看日志直观十倍。你能清楚看到模型看到了什么、决定了什么、得到了什么。大部分问题看一眼消息列表就能定位。