上周有个同学问我:老师,我用LangChain跑通了第一个AI Agent,看着它自己查天气、自己算数学,感觉很神奇,但我不明白它内部到底在干什么。我说那你想不想自己手写一个?他说那多麻烦,框架不香吗。我反问他:如果工具返回了报错,框架会怎么决定是重试还是放弃?他沉默了。
这就是典型的“调包侠”状态。用框架没问题,问题是框架把Agent的核心循环、工具调度、上下文管理全都封装成了黑盒,你跑通了Demo却不知道自己跑的是什么。“零基础手写AI Agent系统课”这个标题我很喜欢,因为它直接戳中了这个痛点:从“调包侠”到“造轮子的人”,差的不是敢不敢拆框架,而是有没有亲手把Agent最底层的轮子造一遍。
这篇文章我就按我带学员的路径来写,不讲玄学,不堆概念,直接拆解一个最小可运行的Agent是怎么从零写出来的,以及写完之后你会撞上哪些框架替你藏好的坑。
1. 拆掉LangChain黑盒:Agent到底是什么,为什么值得从零写一遍
1.1 大模型只会说话,Agent才会办事
先说清楚一个最基本的认知:大模型本身不是一个“能办事”的系统,它是一个“会说话”的系统。你问它杭州明天天气怎么样,它可以给你编一段听起来很合理的回答,但它在训练时没见过今天的天气数据,所以它本质上不具备查实时天气的能力。换句话说,大模型只有一张嘴,没有手。
Agent做的事情,就是给这张嘴装上手脚和记事本。
- 工具(Tools):让模型能调用外部函数,比如查天气、算算术、读文件、发请求。
- 循环(Loop):让模型不停地“思考→行动→观察结果→再思考”,直到解决用户的问题。
- 记忆(Memory):让模型记得之前说过什么、做过什么,避免答非所问。
我带的零基础学员里,很多人第一次听说这些话都会觉得抽象。我用一个比喻他们马上就懂了:大模型是一个刚毕业的实习生,脑子很好使,但没经验、没工具、没流程。Agent系统就是给这个实习生配了一间办公室——工具是桌上的电话和电脑,循环是主管在旁边盯着他“下一步该干什么”,记忆是他的工作日志。没有这套办公室,实习生再聪明也只能坐着空谈。
这也是为什么单纯调API不等于会做Agent。你调通了一个大模型接口,只是把实习生招进来了;你手写出一套Agent循环,才是把整间办公室搭起来了。
1.2 调包侠与造轮子的人,差的不是代码量是底层认知
我把“调包侠”当成一个中性词来用。在现代工程实践里,调包是一种非常高效的工作方式,没人会蠢到自己去重写HTTP库或者JSON解析器。但问题在于:调包的人分两种。一种人知道包里装的是什么,另一种人完全不知道。
不知道包里是什么的人,遇到问题只能靠试错和猜。
举个例子。你用LangChain跑了一个Agent,它答非所问。你的排查思路大概率是“换一个Prompt试试”“换一个模型试试”。但如果你手写过一遍,你的排查路径会变成:是模型生成的Action格式解析失败了?是工具执行异常后没有把错误喂回上下文?是历史消息太长把系统提示词给挤掉了?这些层次完全不一样。
技术圈里“手写”从来都不是退步。从手写数字识别(MNIST)到手写Transformer,从手写Spring到手写各种框架核心,每一代开发者都在用“手写一遍”来对抗黑盒焦虑。AI Agent也一样。现在网上有大量关于“AI Agent搭建”“AI Agent部署”“AI Agent主流架构”的教程,但绝大多数都是在教你怎么用别人的轮子;真正想理解Agent的底层逻辑,你得把轮子从圆木开始削一遍。
手写一次,你会获得三个很难用钱买到的东西:调试定位能力(报错时知道往哪看)、模型输出理解能力(知道它为什么会乱说)、架构选择能力(知道什么时候该用框架,什么时候不该用)。
2. 手写Agent的四块地基:LLM、工具、循环、记忆各司其职
2.1 四件套的职责边界
一个最简Agent,本质上由四部分组成。我拿一个表格把它们拆开,后面实现代码时你会反复对照这张表。
| 组件 | 类比 | 手写时要关心的核心问题 |
|---|---|---|
| LLM | 实习生的大脑 | 怎么把上下文正确地传给模型 |
| 工具 | 桌上的电话和电脑 | 怎么让模型知道有哪些工具、参数是什么 |
| 循环 | 盯着流程的主管 | 怎么判断该继续行动还是该给最终答案 |
| 记忆 | 工作日志 | 哪些历史信息要保留,哪些该丢掉 |
这里有个新手很容易误解的点:Agent的“智能”不是模型一个人给的,而是这四件套相互配合产生的。模型负责最核心的决策,但工具决定了它的能力边界,循环决定了它能不能把一件事做完整,记忆决定了它会不会越做越糊涂。任何一个环节掉链子,整个Agent都会翻车。
这也是为什么我不建议一上来就上重型框架。框架把这四样东西全给你组装好了,你看不见它们之间的接口长什么样。等你手写一遍,再回去看LangChain的AgentExecutor源码,你会发现自己能看懂它在干什么了——那种感觉非常爽。
2.2 LLM接口:先让Agent学会“说话”
手写Agent的第一步,不是写Agent,而是写一个能稳定调用大模型的函数。这个函数是所有后续循环的基础,它的作用只有一个:接收一段对话消息列表,返回模型的文本回复。
import requests # 以兼容OpenAI协议的模型服务为例,BASE_URL / API_KEY / MODEL_NAME # 请换成你实际使用的模型服务商配置 BASE_URL = "https://your-model-service.example.com/v1" API_KEY = "<your-api-key>" MODEL_NAME = "your-model-name" def llm_chat(messages): """向大模型发送对话消息,返回回复文本。""" resp = requests.post( f"{BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {API_KEY}"}, json={"model": MODEL_NAME, "messages": messages}, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]这段代码本身没什么神奇的地方,但它把Agent和外界的边界切清楚了。整个Agent系统里,所有对模型的调用都走这一个函数,以后你想换模型服务商、加缓存、记日志,只需要改这一个地方。
需要提醒的是,messages里的消息是有角色的。最基本的角色是system(系统提示词,定义模型的行为边界)、user(用户输入)和assistant(模型的历史回复)。后面写循环时,我们还要不断往这个列表里追加内容,这正是Agent记忆的实现基础。
2.3 工具注册表:决定Agent能力的“菜单”
工具在代码层面就是普通的Python函数。比如查天气,本质就是定义一个get_weather(city)函数,里面去调用一个天气接口,返回一段字符串。
但有一个关键点常常被新手忽略:模型并不知道这个函数存在,更不知道它怎么用。你必须把工具的“使用说明”告诉模型。这就是工具注册表的作用——它是一份给模型看的菜单。
TOOLS = { "get_weather": { "description": "查询指定城市的当前天气情况,参数city为城市名,例如'杭州'", "function": lambda city: f"{city}今天晴,气温22摄氏度,东南风2级" }, "calculator": { "description": "计算数学表达式,参数expr为合法的数学表达式字符串,例如'(3+5)*2'", "function": lambda expr: str(eval(expr)) } }注意,模型看到的不是你这段dict,而是你从description里拼出来的一段工具使用说明,随着系统提示词一起发过去。模型读了以后,会在它觉得合适的时机“说”出一句话:我想调用get_weather,参数是杭州。然后你的代码负责解析这句话、执行对应的函数、把结果拿回来。
这是手写Agent最重要的一个心法:模型不会主动调用任何函数,它只负责“说想调用什么”,真正动手执行的一直是你的代码。想通了这一点,你就理解了所有Agent框架的底层都在做什么。
3. 八十行代码跑通ReAct循环:一个最小Agent的手写全过程
3.1 ReAct是什么:推理与行动交替的循环
现在LLM接口有了,工具注册表也有了,缺的是把两者串起来的循环控制器。目前业界最主流的Agent工作模式之一叫ReAct,全称是Reasoning and Acting,核心思想就是让模型在推理和行动之间交替推进:
- 思考(Thought):模型分析当前情况,判断下一步该做什么。
- 行动(Action):模型输出一个工具调用指令。
- 观察(Observation):代码执行工具,把结果返回给模型。
- 循环上面三步,直到模型认为问题解决了,输出最终回答。
为什么这个模式会成为行业默认?因为它把Agent的每一步决策都显性化了。你可以看到模型每一步在想什么、调了什么工具、拿到了什么结果。出了问题,你能像回放录像一样定位是哪一步的判断错了,而不是面对一个黑盒干瞪眼。
3.2 完整实现:提示词设计、输出解析、工具执行、观察回填
我直接给出一份最小可运行的代码。为了让教学更清晰,这里的工具调用采用纯文本协议:模型输出一个以ACTION:开头的行,后面跟着工具名和JSON格式的参数;当模型准备回答用户时,输出以ANSWER:开头的内容。
import json import re SYSTEM_PROMPT = """ 你是一个智能助手。你有以下工具可用: - get_weather(city): 查询城市天气 - calculator(expr): 计算数学表达式 当需要调用工具时,你必须严格按以下格式输出: ACTION: get_weather({"city": "杭州"}) 当你不使用工具、准备回答用户时,按以下格式输出: ANSWER: 你的最终回答 你每次只能输出一个ACTION或一个ANSWER。 """.strip() def run_tool(name, arguments): """根据工具名执行函数,返回字符串结果。""" if name not in TOOLS: return f"错误:未知工具 {name}" try: return str(TOOLS[name]["function"](**arguments)) except Exception as e: return f"错误:工具执行失败,原因:{e}" def run_agent(user_query, max_steps=5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for step in range(max_steps): reply = llm_chat(messages) messages.append({"role": "assistant", "content": reply}) # 模型选择直接回答,结束循环 if reply.startswith("ANSWER:"): return reply[len("ANSWER:"):].strip() # 尝试解析ACTION指令 match = re.search(r"ACTION:\s*(\w+)\((.*)\)", reply, re.DOTALL) if not match: # 解析失败,让观察结果带着错误信息回到模型 messages.append({ "role": "user", "content": "格式错误:你的输出没有包含可解析的ACTION指令,请重新按格式输出。" }) continue tool_name = match.group(1) try: arguments = json.loads(match.group(2)) except json.JSONDecodeError: arguments = {} # 执行工具,并把观察结果回填给模型 observation = run_tool(tool_name, arguments) messages.append({ "role": "user", "content": f"工具 {tool_name} 的返回结果:{observation}" }) return "已达到最大步骤数,任务未完成。" # 使用示例 print(run_agent("帮我算一下 (3+5)*2 等于多少"))核心逻辑就一个循环:调模型→解析输出→执行工具→回填观察结果。每次循环的对话历史都会越来越长,模型基于新的上下文做出下一步决策,直到输出ANSWER或者步骤用尽。
这里你可能会问,为什么工具的返回结果用user角色塞回去?因为在最简实现里,模型只需要把这段内容当成“用户额外提供的已知信息”继续推理就行。真实生产环境可以用tool角色来让协议更规范,但理解循环本质的话,最简单的角色反而更好懂。
3.3 解析容错:第一道躲不开的坎
新手手写Agent时,遇到的第一个崩溃点基本都在解析这一步。因为模型输出是自然语言,它不可能每次乖乖给你标准格式。
常见的翻车现场有三个:
- 模型输出了
ACTION: get_weather(city=杭州)这种带等号的写法,不是严格JSON。 - 模型输出了
ACTION: get_weather({"city": "杭州"}),但后面跟了一大段解释文字。 - 模型输出了两个ACTION,想让你一次执行两个工具。
我上面的代码里加了一层最简单的容错:格式错误时把错误信息作为观察结果回填给模型,让模型自己修正格式。这个思路非常实用——与其用正则去兼容一万种写法,不如把问题抛回给模型,让它按规矩重新输出。实测下来的效果是,大部分模型在收到“格式错误”提示后,下一轮就会乖乖按规范输出。
你可能会觉得这太折腾了。没错,框架之所以有价值,就是因为它把这一层烦人的容错封装好了。但你不亲手踩一次这个坑,你永远不会理解为什么LangChain里会有OutputParser这个组件,也不知道它存在的价值是什么。
4. 手写之后才看得见的暗坑:Token、上下文失忆与工具报错自救
4.1 Token预算:每一轮循环都在烧钱
“AI Agent token是什么意思”——这是我最常被问到的问题之一。简单说,Token是大模型处理文本的最小单位。对中文来说大致一个汉字对应1到2个Token,一个英文单词大约1.3个Token。你每次调用模型接口,都要按Token数付钱,而且重要的是:模型是无状态的,每次调用都会把整个对话历史重新发送一遍。
这意味着什么?你的Agent每循环一轮,发送给模型的文本就增长一次。假设一轮对话消耗2000个Token,10轮就是2万Token。如果工具返回结果特别长,增长会更快。
| 循环轮次 | 历史消息Token量(示例) | 单轮费用趋势 |
|---|---|---|
| 第1轮 | 约800 | 低 |
| 第5轮 | 约4500 | 中 |
| 第10轮 | 约10000+ | 高 |
控制Token预算是手写Agent必须做的一笔账。我自己的习惯是三板斧:第一,max_steps必须设上限,防止Agent陷入死循环;第二,工具返回结果要做裁剪,只保留关键信息;第三,历史消息太长时要截断。这三步不做,你的Agent跑着跑着就会又贵又慢。
4.2 上下文滑动窗口:防止Agent“记太多而变笨”
你可能会想,Agent不是有记忆吗?把历史消息全留着不是更好吗?现实是,上下文窗口是有限的,而且历史消息堆积太多以后,模型在长文本里找重点的能力会下降,反而会忽略最新指令,甚至完全忘记系统提示词。
最朴素也最有效的方案是滑动窗口:只保留最近的N轮对话,早期的内容直接丢弃。
def trim_messages(messages, max_chars=6000): """近似用字符数控制上下文长度,保留system消息和最近的对话。""" # 保留第一条system消息不被删除 system_msg = messages[0] recent_msgs = messages[1:] total_chars = len(system_msg["content"]) kept_msgs = [system_msg] # 从后往前保留,直到达到近似上限 for msg in reversed(recent_msgs): if total_chars + len(msg["content"]) > max_chars: break kept_msgs.insert(1, msg) total_chars += len(msg["content"]) return kept_msgs这是一个典型的“牺牲远期记忆换稳定”的方案。代价是如果用户在第1轮提到了一个关键信息,到第10轮时这条信息可能已经被丢掉了。进阶方案是用小摘要线程——在滑动窗口丢弃前,让LLM把旧对话压缩成一个自然段再塞进上下文。但这个复杂度比较高,零基础阶段优先掌握滑动窗口就够了。
我提一个实战细节:截断时一定要把system消息保住。很多人写的截断函数把列表整个截了,结果系统提示词被冲掉,Agent立刻失去行为约束,开始乱输出。这个坑我至少见过五次。
4.3 工具报错自救:把错误喂回给模型
手写Agent的过程中,工具报错处理是最能体现“造轮子思维”的地方。大多数调包侠遇到工具报错,只会看框架日志里的红字;但手写者会明白一件事:工具报错不该直接中断流程,而应该变成一个观察结果交给模型处理。
举个例子。你的get_weather函数拿到城市名后去请求天气接口,如果城市名错了,模型可能会第二次调用同一个参数继续错。怎么让它学会自我修正?答案是:在系统提示词里加一句话,并且在工具异常时把异常信息作为观察结果返回。
我的代码里已经有这个逻辑了:run_tool把异常捕获后返回“错误:工具执行失败,原因:xxx”,后面接着把这句话作为观察回填给模型。模型看到后会调整参数,或者换一个工具,甚至直接向用户说明无法完成。
我还习惯在系统提示词里加一句:
如果工具返回错误信息,请先尝试修正参数再次调用;确认无法完成后再告知用户失败原因。
就这么一句话,Agent的“自救能力”会有质的提升。这个技巧在很多框架教程里都不会写,但它恰好是手写Agent最有意思的地方——你亲手决定了模型遇到挫折后的行为模式。
5. 从“能跑”到“好用”:顺着主流架构继续造轮子
5.1 从裸ReAct到Function Calling:解析这件事交给API
写完文本协议的ReAct循环后,你会很快发现生产环境里有个更省心的方案:主流模型服务商提供的Function Calling(也叫工具调用)。这个机制让你不再自己写正则解析模型输出,而是把工具定义以结构化JSON传给模型,模型直接返回一个结构化的工具调用请求。
| 对比项 | 裸ReAct文本解析 | Function Calling |
|---|---|---|
| 格式稳定性 | 靠模型自觉,偶尔乱来 | API层保证结构化返回 |
| 对模型的依赖 | 低,任意模型都能跑 | 高,需要模型原生支持 |
| 学习价值 | 极高,能看清循环本质 | 生产环境更省心 |
| 上手成本 | 低 | 需要读对应文档 |
我的建议是,学习阶段一定要先写裸ReAct,理解循环和解析的本质;生产阶段再用Function Calling。这个顺序不能反。如果你一上来就调Function Calling,你又会把它当成一个新的黑盒——你会用,但你不一定理解为什么模型能“直接”返回参数。
5.2 单Agent的边界与多Agent协作的演进
手写跑通单Agent之后,下一个绕不开的话题就是主流架构演进。单Agent能干很多事了,但它有明确的天花板:一个上下文窗口装不下太多工具定义;一个人既当规划者又当执行者容易上下文爆炸;职责混杂会让Prompt难以维护。
所以现在的AI Agent主流架构里,多Agent协作是很重要的一条线。思路也简单:像公司分工一样,搞一个Planner(规划者)负责拆解任务,再搞若干个Executor(执行者)各自负责一个具体领域。它们通过消息互相传递任务和结果,单个Agent的上下文压力被分流,整体的复杂度也上来了。
这个阶段离“零基础手写”稍微有点远,但方向值得你提前知道。我的经验是:先把单Agent的循环写到肌肉记忆,再去碰多Agent。否则你连单个Agent为什么会翻车都说不清楚,搭一个团队出来只会乱成一锅粥。
5.3 上线部署前必须处理的工程细节
网上很多教程到“Demo能跑”就结束了,但真实项目还得回答一个问题:AI Agent怎么部署才不像玩具?我踩过不少坑,挑三个最关键的提醒你。
第一是日志。Agent的每一步决策都是模型输出,你不用日志把Thought、Action、Observation全记录下来,出了问题根本没法排查。第二是超时和重试。模型接口和工具接口都可能卡住,你的循环里必须加超时控制,否则一个坏工具会拖死整个Agent。第三是会话隔离。生产环境有多个用户同时用,你不能让他们共享同一个消息队列,每个会话要有独立的ID和独立的内存状态。
这些细节单看都不难,但它们决定了一个Agent系统是“能用”还是“能上线”。手写一遍Agent的意义也在这里——你亲手搭过一遍完整链路,这些工程问题就会一个个浮现出来,逼着你解决。调包的时候它们都被框架藏起来了,你会误以为不存在。
我自己带的学员里,凡是认真手写过一遍最小Agent的,回去再碰LangChain、看Agent源码、查Function Calling文档,状态完全不一样——不是“照着抄”,而是“我知道它底层是怎么设计的”。从“调包侠”到“造轮子的人”,这一步跨过去,你就再也不会对着框架报错一脸茫然了。轮子自己造过一遍,才知道轮子为什么是圆的,哪一步磨损该往哪儿查。