
过去半年我带了几批零基础学员做AI Agent实操发现一个特别普遍的现象很多人装上一个Agent框架写三行代码跑通demo就觉得自己已经会做Agent了。等要换个模型、加一个内部工具、或者面对一个奇怪报错的时候整个人就卡住了——框架文档翻遍、配置试遍最后只能去群里问别人。我把这类同学叫“调包侠”。这称呼不带贬义我自己也当过。但如果你想往前走一步从“调包侠”变成“造轮子的人”这条路其实没有想象中那么难。这篇内容来自我在整理“零基础手写AI Agent系统课”时的主线心得不依赖任何Agent框架用最基本的Python和模型API亲手写一个最小但能跑的AI Agent系统。这门课适合刚学会调用模型API、想搞懂Agent原理、或者被框架折腾到怀疑人生的朋友。写完之后你会发现核心的Agent循环代码真的不多难的是理解“模型、工具、循环”三者之间如何协作。1. 先搞清楚AI Agent到底在“忙”什么1.1 从“一问一答”到“目标循环”普通调用大模型是什么样你发一句“帮我把这段文字翻译成英文”模型返回一段翻译对话结束。整个过程是线性的input进output出一次搞定。AI Agent不一样。它拿到的往往是一个模糊的目标比如“帮我查一下杭州明天适不适合跑步”。这个问题没法靠一次模型调用就给出靠谱答案因为模型自己并不知道杭州明天的天气。它需要先想办法“获得天气数据”再基于数据做判断最后把结论整理给你。这个过程用项目经理来类比特别贴切你是老板Agent是项目经理。你交代完任务项目经理会拆解目标、找工具、拿结果、做判断、写汇报。每一步都可能产生新的信息和新的决策。而大模型在里面扮演的角色是那个“思考的人”——它负责决定现在该直接回答还是该调用某个工具该调用哪个工具看到工具结果后该继续还是该收尾这就是Agent和普通Prompt调用的核心差异多了一整套“循环”。模型不是只回答一次而是在一个循环里反复决策、执行、观察、再决策直到任务完成或者达到你设定的终止条件。1.2 Agent系统的五个核心组成部分手写之前先把一张零件图刻在脑子里。任何一个AI Agent系统哪怕复杂到 LangChain、AutoGPT 这种级别拆到最底层也跑不出这五个部分组成部分作用类比大模型底座LLM负责理解、决策、生成回复项目经理的“大脑”工具集合Tools模型之外的函数比如查天气、算数学、查数据库项目经理手里的“人手”循环控制Agent Loop反复执行“模型决策 → 工具执行 → 结果回填”项目经理的工作节奏记忆上下文Memory保存历史消息、工具结果让模型连续推理项目经理的记事本停止条件Stopping Criteria什么时候退出循环返回最终结果项目验收标准很多零基础朋友一上来就被“Agent框架”里的各种抽象概念吓住了什么Planner、Executor、Callback、Retriever。其实那些都是对这五个基础组件的封装和扩展。你先能徒手搭出这个五件套后面看任何框架都像看拼装积木。1.3 “手写”不是让你手写Transformer我注意到有不少人搜到“手写AI Agent”这个词第一反应是要手写Transformer要从零训一个大模型这是个很深的误解。Agent系统是“大模型外面那套调度系统”而不是大模型本身。你可以用任何大模型底座来做AgentOpenAI、通义、DeepSeek、本地部署的Qwen都可以。只要它能通过接口接收消息、返回文本最好还能支持函数调用的结构化输出。手写Agent的核心工作是写清楚“模型之外”的这些逻辑如何定义工具、如何让模型知道工具的存在、如何执行工具、如何把结果送回给模型、如何终止循环。这些代码用Python写几十行到两三百行就能跑通。和手写Transformer、手写Spring完全不是一个量级的事别自己吓自己。2. 为什么非要从“调包侠”变成“造轮子的人”2.1 “调包侠”的三个典型痛点先说清楚调包本身不是坏事。成熟的框架能省大量时间该用就用。问题在于如果你只会调包而不理解包底下的机制你迟早会撞上这三堵墙。第一堵墙是黑盒排障。跑一个Agent应用报错了这错误到底是从框架配置来的还是prompt没写对还是模型返回格式变了框架把每一步包装得严严实实你看不到中间日志只能一层层猜。很多时候你把同样的代码换了另一个模型供应商结果直接不能用连错在哪都不知道。第二堵墙是定制受限。团队内部的数据库、内部API、私有工具链很多是框架不支持、或者支持得很别扭的。你为了一个简单需求要写一堆适配代码甚至要去改框架源码——那时候你才发现自己连框架的源码结构都看不懂。第三堵墙是知识不可迁移。你在某个框架上学会了拖组件、配置YAML离开这个框架换个语言、换个场景一切归零。因为学的全是“操作”而不是“机制”。用开车类比调包侠是开别人车的司机上下班没问题。但车半路抛锚他连引擎盖都不会开。我写这门课就是想把引擎盖打开把发动机、油箱、传动系统指给你看。2.2 手写的价值边界在哪里有人会问既然框架这么方便我为什么非要自己造轮子我的回答是你不需要永远自己造轮子但你需要至少亲手造一次。亲手写一遍最小Agent系统你会获得三样很难被替代的东西一是清晰的心智模型。当别人说“Agent memory不够了”你脑子里浮现的是消息列表变长、token超限的具体画面而不是一个模糊的报错。你会知道哪些参数该查、哪些坏消息该看。二是真正的调试能力。自己写的代码每一行都是透明的。模型调用了错误工具、参数传错了、答案没收敛你能从日志里一眼定位。这种能力在跨模型、跨场景迁移时价值巨大因为原理是通用的。三是对框架的“翻译能力”。我见过好几个学员手写之后回去用主流框架说的原话是“原来框架帮我干的就这些事还干得这么绕。”你能看懂框架文档里每个配置项背后的机制知道哪些是核心、哪些是噪音。不过也要说清楚手写的边界。如果你的场景是快速验证一个产品想法、或者公司已经跑在一套成熟框架上那直接上手框架是完全合理的。“造轮子的人”并不是不调包而是在需要的时候随时能打开轮子内部看个究竟。2.3 为什么零基础也能手写因为它真的不大。一个最小Agent核心几十行Python不需要异步、不需要缓存、不需要向量数据库。现代大模型API把最难的“理解语言”事都替你做了你要写的只是一层薄薄的调度层。这门课不是要你挑战难度而是带你看见那个看起来很高深的Agent底层逻辑是朴素的。3. 手写最小可用Agent循环、工具与停止条件的第一次落地3.1 最简单的心智模型把Agent循环压缩成一句话模型决定是“直接回答”还是“想调用工具”如果它想调用工具我们执行工具并把结果送回给它它看完结果继续决策直到它不想再调用工具、直接给出最终答案。用伪代码写就是这样用户输入 → 组装消息列表系统提示 用户输入 → 发送给大模型 → 模型返回 ├─ 包含 tool_calls → 执行工具 → 把结果追加到消息列表 → 回到“发送给大模型” └─ 不包含 tool_calls → 提取模型回答 → 返回给用户这个循环就叫Agent Loop。死记它这就是Agent的骨架。3.2 工具注册表先把“手”准备好第一步准备工具。我这里定义两个工具一个做加法一个模拟查天气。教学阶段够用了。# tools.py from typing import Callable, Dict, Any TOOLS: Dict[str, Callable] {} def register_tool(func: Callable) - Callable: 用装饰器把函数注册进工具表 TOOLS[func.__name__] func return func register_tool def add(a: float, b: float) - float: 两个数字相加 return a b register_tool def get_weather(city: str) - str: 模拟获取某个城市的天气 # 真实项目里这里可以调天气API教学阶段先返回固定值 return f{city}晴气温25℃微风适合户外活动 register_tool def send_email(to: str, title: str, content: str) - str: 模拟发送邮件 return f邮件已发送至 {to}标题{title}用字典做工具注册表而不是写一堆if-else核心好处是新增工具只需要注册一个函数Agent的调度代码完全不用改。后面你接真实API、数据库操作都是往这个表里加函数的事。3.3 工具描述Schema模型靠什么知道该调哪个工具有了函数模型并不知道它们存在。你需要把每个工具的“使用说明书”以固定结构传给模型。这个结构遵循OpenAI的Function Calling规范目前大多数兼容接口都支持同样的格式。# tools_schema.py tools_schema [ { type: function, function: { name: add, description: 计算两个数字的和。当用户需要进行数学加法运算时使用。, parameters: { type: object, properties: { a: {type: number, description: 第一个加数}, b: {type: number, description: 第二个加数} }, required: [a, b] } } }, { type: function, function: { name: get_weather, description: 获取指定城市的天气信息。当用户询问天气、出行穿衣、是否适合户外活动时使用。, parameters: { type: object, properties: { city: {type: string, description: 城市名例如杭州、上海、北京} }, required: [city] } } } ]这里最容易忽略的是description的写法。写描述的目标是让模型在“该用工具的时候想得起来、不该用的时候不会乱用”。一个及格模板是先写工具能做什么再写什么时候用。比如“获取天气。当用户询问天气、穿衣建议、是否适合跑步时可用”就比“天气查询函数”好得多。模型本质上是在读这些自然语言描述做判断描述质量直接影响Agent是否聪明。3.4 核心循环的完整代码现在写整个Agent的发动机。我用OpenAI的Python SDK做示例别的兼容接口只需要改base_url和api_key。# agent.py from openai import OpenAI import json client OpenAI( base_url你的接口地址, # 可换成任意兼容OpenAI接口的服务 api_key你的API密钥 ) SYSTEM_PROMPT 你是AI助手。你有工具可以使用。 当用户的问题需要实时数据、计算或外部操作时请调用对应工具 如果不需要工具直接回答用户即可。 def call_llm(messages, toolsNone): kwargs { model: gpt-4o-mini, messages: messages } if tools: kwargs[tools] tools kwargs[tool_choice] auto response client.chat.completions.create(**kwargs) return response def safe_call_tool(name, args): 安全执行工具参数不对就返回错误信息 if name not in TOOLS: return f错误没有找到名为 {name} 的工具 func TOOLS[name] try: return func(**args) except TypeError as e: return f工具参数调用失败{e} def agent_loop(user_input: str, max_steps: int 8) - str: messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] for step in range(max_steps): print(f——— Step {step 1} ———) response call_llm(messages, tools_schema) msg response.choices[0].message # 情况一模型想调用工具 if msg.tool_calls: messages.append(msg) # 把模型的“工具请求”放回对话 for tool_call in msg.tool_calls: name tool_call.function.name raw_args tool_call.function.arguments args json.loads(raw_args) print(f模型请求调用: {name}({args})) result safe_call_tool(name, args) print(f工具返回: {result}) # 把工具结果以tool角色加回消息列表 messages.append({ role: tool, tool_call_id: tool_call.id, content: str(result) }) # 情况二模型直接给出最终答案结束循环 else: return msg.content return 已达最大步数Agent循环停止。然后跑一个例子if __name__ __main__: answer agent_loop(杭州明天适合跑步吗请帮我查一下。) print(最终答案, answer)预期日志长这样——— Step 1 ——— 模型请求调用: get_weather({city: 杭州}) 工具返回: 杭州晴气温25℃微风适合户外活动 ——— Step 2 ——— 最终答案 杭州明天天气晴朗气温25℃微风非常适合跑步。就这么简单一个可用的最小Agent已经跑通了。3.5 这段代码里的四个关键决策为什么要把带tool_calls的assistant消息原样放回messages因为模型看完工具结果后需要知道自己刚才请求了什么工具。如果你丢掉这条消息模型就“失忆”了无法把工具结果和自己刚才的决策关联起来。为什么工具结果必须带tool_call_id这是协议规定。模型需要知道哪一次工具调用对应哪一条结果。尤其在一个回合里模型同时调用多个工具时这个映射关系绝对不能乱。为什么max_steps必须有因为循环天然有失控风险。模型可能永远在决定“我再查一次”或者被某个工具结果触发无限循环。生产环境不仅要设max_steps还要监控每一步的token消耗。为什么用safe_call_tool包一层因为模型生成的参数经常不按常理出牌多传参数、少传参数、类型错误这些都是必然会发生的事。不包这层一个TypeError就能让整个循环崩掉。4. Function Calling的秘密模型并不会“执行”函数它只会“说”它想调用4.1 本质拆穿工具调用靠的是“约定格式”很多新手第一次看到msg.tool_calls时以为模型“运行”了某个函数。完全不是。模型只是一个文本生成器它永远没有运行任何函数。它只是在你给的工具描述影响下按照训练时学会的格式输出了一段结构化文本“我建议调用get_weather参数是{city: 杭州}。”真正的执行者是你的Python代码。你收到这个结构化文本后解析它、查找工具表、传参、执行、把结果格式化后塞回消息列表。这个机制最大的好处是模型和你的系统完全解耦。你可以换一个模型、换一种语言、把工具从“查天气”换成“查数据库”Agent的骨架都不用变。你只需要保证Tools Schema的格式能对上。4.2 参数清洗别天真地相信模型的JSON输出模型返回的arguments在绝大多数情况下是合法JSON但坑就藏在“绝大多数”里。我见过模型输出多余注释、混入markdown代码块、把中文括号当成JSON括号、甚至把 \n 转义得乱七八糟。零基础写Agent第一个崩溃点大概率在这里。所以写一个健壮的解析函数很有必要import json import re def parse_arguments(raw: str): 先正常解析失败后尝试清洗 raw raw.strip() # 去掉可能的markdown代码块包裹 if raw.startswith(): raw re.sub(r^(?:json)?|$, , raw).strip() # 统一中文常见引号为英文引号 raw raw.replace(“, ).replace(”, ).replace(‘, ).replace(’, ) try: return json.loads(raw) except json.JSONDecodeError: # 再尝试提取第一个花括号到最后一个花括号之间的内容 match re.search(r\{.*\}, raw, re.S) if match: return json.loads(match.group()) raise把这段加进safe_call_tool的调用链路里模型输出再奇葩也能救回来。如果实在解析不出来也别让Agent崩溃直接把“参数解析错误”作为工具结果返回给模型让它重新生成参数。模型是会自行纠错的。4.3 为什么模型会乱调工具以及怎么约束它模型乱调工具通常有三种原因。第一种是工具描述写得太“万金油”。你的get_weather描述里写了“适合户外活动”用户可能问“帮我写首诗”模型都能联想到天气工具。解法是精确描述触发场景加一句“仅当用户明确询问天气时才调用”。第二种是System Prompt没写约束逻辑。只管说“你可以用工具”没说“不要滥用工具”。模型倾向于尽量使用工具因为训练数据里“调用工具解决问题”优先级更高。一个更好用的System Prompt模板你是AI助手。你有以下工具可用 调用工具前先判断 1. 这个问题是否需要实时数据、计算或外部操作 2. 如果不需要直接回答不要调用工具。 3. 如果多个工具都沾边选择描述最匹配的那个。 4. 不要编造工具参数严格按JSON Schema生成。第三种是温度设置太高。工具调用本质是个结构化决策任务温度越高越容易“发挥失常”。Agent场景下把temperature调到0.2以内稳定性能提升不少。很多人只调prompt忘调参数其实temperature才是乱调的常见元凶。4.4 把错误也当作“工具结果”返回这是很多成熟Agent系统的设计细节工具执行出错时不要把异常直接抛出去中断循环而是把错误信息作为正常结果返回给模型。比如send_email因为参数缺字段失败了错误的字符串会被塞进消息列表。模型看到“工具参数调用失败send_email() missing 1 required positional argument: content”它会自己做参数修正再次发起调用。这就是Agent自我纠错能力的一个朴素实现。这一步对了循环才真正变成“会反思”的系统。很多框架里叫Self-Correction本质就是这么一层朴素的错误回传。5. 上下文记忆为什么你的Agent聊几句就“断片”5.1 token到底是什么通俗版模型处理文本的最小单位是token不是字符。英文一个常见单词大约是1个token中文一个字在多数模型上约等于1到2个token。所有发给模型的messages最终都会拼成一段token序列这个序列有长度上限通常是几千到几万token不等。Agent里有个隐藏的“吃token大户”每执行一个工具完整消息列表里就会多一条模型请求、一条工具结果。如果工具返回的是好几段长文本几轮循环下来上下文立刻见底。“上下文爆了”是Agent开发里最常踩的坑——聊着聊着模型开始忘记最早的目标输出也变得一团糟因为最早的指令可能被截断了。5.2 三种菜鸟也能上手的记忆管理方案方案一窗口截断。只保留最近的N条对话消息更早的消息直接丢掉。实现最简单但有风险系统提示和最初的目标可能被丢掉。保留消息时要永远把system角色单独拎出来放在最前面。def trim_messages(messages, keep20): system_msgs [m for m in messages if m[role] system] other_msgs [m for m in messages if m[role] ! system] if len(other_msgs) keep: other_msgs other_msgs[-keep:] # 保留最近keep条 return system_msgs other_msgs方案二摘要压缩。上下文快爆时把前面的对话交给模型总结成一段摘要下一次循环只放摘要最近几轮原始消息。计算成本中等但对Agent的“记忆连续性”帮助很大。总结时别让模型把关键信息丢掉最好明确要求“保留用户的原始目标、已经拿到的事实数据、尚未完成的事项”。方案三外部向量检索。对话历史写入向量数据库Agent需要时检索相关片段放回上下文。效果最好但也最重新手阶段可以先不碰。5.3 工具结果也必须“瘦身”另一个容易被忽略的问题工具返回内容本身太大。查数据库返回了100行数据模型真没必要看到全部。一个通用的优化思路是拿到工具结果后先做一次裁剪或总结再放回上下文。MAX_TOOL_RESULT_LEN 500 def compact_tool_result(result: str) - str: if len(result) MAX_TOOL_RESULT_LEN: return result return result[:MAX_TOOL_RESULT_LEN] ...结果过长已截断在真实项目里甚至可以给重工具加一个“摘要模式”工具执行时本身输出一份精简摘要。这个设计能大幅延后上下文爆掉的节点也能省不少token成本。5.4 记忆设计的三问原则给Agent做记忆管理不是“全记住”就好。每一条放进上下文的信息都会占用空间、影响模型注意力。我的判断标准是三个问题这段历史对回答当前问题还有用吗如果只是寒暄该丢就丢。 是需要原始细节还是一段摘要就够了已经用完的中间过程压缩成一句话最好。 如果不放这条信息模型还能继续干活吗如果能就说明它是噪音。把这套原则想清楚你的Agent会有一种“拎得清”的感觉——该记得的都在不该记得的一点不占地方。6. 手写Agent最容易翻车的五个环节我踩过的坑6.1 死循环模型反复调用同一个工具症状日志里step疯狂增长模型一直在调用同一个工具、传同一个参数。用户问“杭州天气”模型查一次结果返回了它又查一次再查一次。排查链路先看日志里最近几步的tool_calls是不是完全一样的名字参数。如果是大概率是模型没见过工具结果——最常见的原因是messages里忘了把assistant消息和tool结果追加回去或者tool的role写错了。还有一种情况是工具结果太长被截断后模型没看懂于是拼命重试。解决方案除了检查消息组装代码还要在循环里加重复调用检测。同一个工具同一组参数连续出现3次直接终止返回“该问题暂时无法解决”。6.2 工具返回太大上下文一夜爆掉症状前面几轮还正常某次工具返回一个几百行的数据后下一轮请求直接报“context length exceeded”。你这个写入消息列表的系统提示也被挤出窗口模型行为立刻失智。排查链路在工具执行处打日志打印每次返回结果的字符数。你会发现罪魁祸首往往是某个“顺手就把整个列表返回”的工具。解决方案给所有工具结果做截断、只返回关键字段。更进一步的思路是大结果不要一次性全塞回去模型需要什么细节再通过一个“按ID检索详情”的工具按需拿。6.3 模型脑补参数症状工具Schema里只有city一个参数模型却传了temperature、unit、date一堆不存在的键。有些接口对多余参数直接报错有些干脆静默忽略但你这边的函数调用就会TypeError。排查链路把原始arguments打出来看一遍你会发现模型非常爱“锦上添花”尤其是你给工具描述写得不够严格的时候。解决方案两层防护。第一层是SafeCall只取Schema里声明过的参数多余的键直接丢弃第二层是在工具描述和System Prompt里都写清楚“严格按JSON Schema生成参数不要额外添加字段”。6.4 解析JSON崩溃症状程序在json.loads(raw_args)这行红字报错工具一个都没执行Agent直接宕机。排查链路打印原始raw_args十有八九能看见模型输出了一段“聊天式”的内容混着中文引号、多余注释甚至markdown代码块。这类问题在弱模型上尤其常见。解决方案按第4.2节写的parse_arguments做清洗。记住一个思路模型输出永远是不可靠的所有模型返回的内容都要当“用户输入”来处理——校验、清洗、兜底。6.5 System Prompt写得太“空”症状模型要么一次工具都不调什么问题都空手回答要么什么都要调工具连“你好”都想先查个天气。排查链路把System Prompt和工具描述从头到尾读一遍看有没有给出调用工具的理由和边界。解决方案把System Prompt从“你可以使用工具”改成“你有工具但在不需要的时候不要用”。再配合一个关键判断条件“需要实时数据、计算、或外部操作时才调用工具”。这个模板我在不同模型上测试过稳定性明显好一个档次。另外一个经验是给工具描述加“When to Use”段落模型参考它做决策时准确率高很多。6.6 解决完这些坑之后把日志变成你的第二双眼睛上面五个坑每个的排查都依赖一件事完整的日志。我建议手写Agent的第一天就养成习惯把每一步的step数、模型请求的参数、返回的tool_calls原始内容、工具执行结果、消息列表长度全部打出来。出了任何问题先看日志再猜原因。很多框架值钱的地方不是它的封装而是它帮你把日志打好了。你自己写Agent就得自己补上这层可观测性。写在最后这一路带下来我最大的感触是零基础到“造轮子者”之间差的不是天赋不是数学基础甚至不是代码量——而是“你敢不敢把模型返回的每个字段都打印出来一行一行看它到底干了什么”。手写Agent课程的核心不是把那几十行循环代码背下来而是建立一种“只有亲手搭过一遍才能形成”的直觉模型是个会上头的文本生成器工具是你的手循环是你定的工作节奏记忆是你给它划的工作台。你理解这四者的边界框架对你来说就不再是黑盒而是别人帮你写好的一组“可替换零件”。如果你正准备入门AI Agent我的建议很简单先别装任何Agent框架照着上文的最小循环用一个真实任务跑通一次。花不了半天时间你得到的却不是“会调一个库”而是“能看穿所有Agent框架”的眼睛。以后想加工具、换模型、升级记忆策略你会发现一切都在你的掌控范围内。