过去半年我带了几批零基础学员做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系统是“大模型外面那套调度系统”,而不是大模型本身。你可以用任何大模型底座来做Agent,OpenAI、通义、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, tools=None): 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, keep=20): 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框架”的眼睛。以后想加工具、换模型、升级记忆策略,你会发现,一切都在你的掌控范围内。