ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写AI Agent全流程拆解:从零掌握核心闭环与工程化

手写AI Agent全流程拆解:从零掌握核心闭环与工程化

1. 先别急着手写:你缺的不是框架,是拆解能力

1.1 一次让我"破防"的线上问题

去年我做内部支撑的时候,有位同事用某款热门的Agent框架搭了一个客服助手,跑了两周就开始出幺蛾子。表现很诡异:同样的问题,上午答得挺好,下午就开始胡说八道,后台一看token消耗曲线,直接起飞。我们一群人围着框架源码翻到半夜,最后发现原因是循环退出条件没配好——模型一直在"调用工具→拿到结果→再调用工具"的圈里空转,压根没走到"给用户最终回答"这一步。

那一刻我最大的感受不是"框架有bug",而是:如果当时有人能把这个循环从头到尾手写一遍,这种问题五分钟就能定位。框架帮你封装了大量细节,同时也把调试线索一并吞掉了。你以为是你在用Agent,实际是Agent在替你做决定,你只是在填配置。

这也是今天这篇内容的起点。标题里那句"从调包侠到造轮子的人",说的就是同一个事儿:别再满足于"框架调通了、Demo能跑了",而是去弄明白一个AI Agent系统到底是由什么组成的,然后亲手把它搭出来。这套东西不需要你懂深度学习、不需要你推导Transformer公式,只要你会写点Python、知道HTTP请求大概怎么回事,就能跟着做出来。

1.2 手写Agent,写的到底是什么

先说清楚一个容易误解的点:手写Agent,不是让你从零训练一个大模型,也不是让你自己实现注意力机制。绝大多数场景下,你写的是一套"调度逻辑"——怎么把用户的请求送给模型,怎么解析模型返回的内容,怎么决定要不要调用某个工具,怎么把工具的结果交还给模型让它继续思考。

用做饭类比。用框架开发Agent,相当于买速冻料理包,微波炉叮三分钟就能上桌,味道稳定但你没参与任何环节。手写Agent则是自己去菜市场买菜、切菜、开火,每一步都清楚放了多少盐。一开始肯定比速冻慢,但食材一变、口味要调的时候,你的主动权完全不同。

所以"造轮子的人"这个说法其实有点夸张,我们造的不是汽车发动机那种级别的轮子,而是把Agent这条流水线上的传送带自己装一遍。装过之后,你再看那些开源框架,就不再是"黑盒子使用说明书",而是一行行可以和你自己的代码对照的参考答案。

1.3 "零基础"到底需要什么基础

课程标题写了"零基础",我必须把这个边界划清楚,免得有人被劝退,也免得有人带着错误预期进来。我理解的零基础,是指"零AI基础",但不是"零编程基础"。

你需要具备的最低能力大概是这三样:

  • Python基础语法:会写函数、会处理列表和字典、知道什么是装饰器就更好了。
  • JSON的基本概念:能看懂一段JSON的结构,能用Python的json库做序列化和反序列化。
  • 最朴素的HTTP知识:知道POST请求是干什么的,能在代码里调用一个接口。

如果你这三样都没问题,那剩下的交给课程就行。整个路线的设计逻辑是"每节课只增加一个新零件":第一节课搭一个只会对话的极简助手,第二节课给它加上工具调用,第三节课处理多轮记忆,第四节课做任务规划,第五节课做工程化加固。每加一块,你都会清楚地知道"原来框架帮我处理的是这个东西"。

我见过不少非科班出身的朋友,从零开始跟我这个路线走下来,最后不仅能独立写出一个可用的小Agent,还能看懂主流框架的核心源码。这就是"调包侠"到"造轮子的人"的真实路径:不是靠天赋,是靠把每一层都亲手拆开再装上。

2. 解剖一个最小可用Agent:五个零件与一个闭环

2.1 Agent Loop:所有框架都在兜售的同一条流水线

如果你把市面上任何Agent框架扒开看,会发现它们内核几乎都是同一个循环模式,只是换着花样包装:接收任务→丢给大模型→模型说"我要调用某个工具"→执行工具→把结果喂回去→模型继续推理→直到模型给出最终答案。

这个循环在业界一般叫"思考-行动-观察"模式。思考是模型分析当前情况,行动是决定调用哪个工具或直接回答,观察是拿到工具结果后重新评估。我写的时候喜欢叫它"Agent闭环",因为本质上这就是一个带反馈的流水线,和工厂里的质量检测循环没什么区别。

理解这个闭环,是你从"会调框架"到"会写Agent"的分水岭。很多时候你觉得框架难用,是因为它的流水线里夹带了太多你不需要的环节——什么记忆模块、向量数据库、多智能体通信,全都默认装上。而你自己搭的时候,完全可以只保留核心闭环,按需添加。这种"减法的自由",是手写最大的红利。

2.2 模型调用层:你只需要一个薄薄的封装

闭环里的第一步,是向模型发起请求。很多人在这一步就被框架"带偏"了,以为调用大模型是多高级的事,其实核心就一句话:发一个HTTP请求,把对话历史和工具定义塞进去,然后取回模型返回的消息。

关键点在于,你传给模型的"对话历史"是一串消息列表,每条消息有角色和内容。系统消息相当于给模型设定人设和规则,用户消息是输入,助手消息是模型之前的回复,工具消息是工具执行结果。整个"记忆",本质上就是这张不断变长的消息表。

我在课程里反复强调一件事:先把这个最薄的调用层写好,别加任何花活。不要想着一次封装就支持流式输出、多模型切换、自动重试——那些都是后话。第一版只要能同步调用、能拿到完整JSON响应就行。越薄越好,因为它只要做好一件事,后续所有复杂逻辑才可以建立在这块稳定地基上。

2.3 工具层:让Agent能动手的关键协议

Agent和普通聊天机器人最大的区别就在工具层。所谓"工具",就是一组你预先定义好的函数——查天气、算算术、查数据库、调某个内部接口,都可以。但要让模型知道"什么情况下该用哪个工具、参数怎么填",你必须把每个工具翻译成一份结构化的说明书,也就是JSON Schema。

这份说明书一般包括工具名、功能描述、参数列表,以及哪些参数是必填的。模型读了这份说明书后,如果觉得需要调用工具,就会在回复里夹带一个特殊的"工具调用"指令,告诉你它想调用哪个函数、参数填了什么。你的代码需要解析这个指令,执行对应函数,再把结果打包成一条工具消息送回给模型。

我最初手写工具层的时候,犯过一个经典错误:只给工具写了名字和参数,没写仔细的功能描述。结果模型经常在需要调用工具的时候不调,或者在一个工具上死磕。后来才明白,这部分说明书不只是给机器看的协议,更是在"说服"模型做出正确决策的提示词——描述写得越清楚,模型越容易选对。

2.4 记忆层:上下文管理的边界问题

很多人一提到Agent的记忆,第一反应就是上向量数据库。这个想法不能说错,但放在"手写入门"阶段就是个陷阱。向量数据库解决的是"长期记忆的检索"问题,而Agent日常运行的"短期工作记忆",就是那条越来越长的消息列表。

真正麻烦的问题在于,消息列表不能无限变长。模型有上下文窗口限制,而且即便没到硬上限,越长的上下文也意味着更慢的响应和更高的成本。所以记忆层的核心,不是"怎么存更多",而是"怎么在有限空间里保留最重要的信息"。最朴素的方案就三招:截断、摘要、滑动窗口。具体策略我放到第四章细讲,这里先记住结论:不要迷信所谓记忆框架,大部分业务场景一个截断策略加一段摘要提示词就能解决。

2.5 停止条件:别让Agent空转到破产

新手写第一个Agent循环时,最常见的bug就是循环停不下来。模型没说"结束",你的代码就一直调用、一直喂结果,最后要么超时,要么token烧穿。所以我一直把停止条件当作和主循环同等重要的零件来教。

终止循环的条件就三个:模型回复里没有工具调用指令,说明它决定直接回答问题;达到你设定的最大步数上限,强制结束;某次工具调用或解析出了问题,触发兜底逻辑返回提示。这三个条件缺一不可。第一个是正常出口,第二个是安全阀,第三个是异常保护。把这三个条件写全了,你的Agent才算是"能放出去跑"的东西,而不是实验室里的玩具。

3. 手写一个能跑的Agent:代码逐段拆解

3.1 先搭一个不依赖框架的LLM调用层

下面这段代码,我会直接用一个通用Chat Completions风格接口来写,你换成自己实际使用的模型服务地址和密钥就行。它做的事非常朴素:接收消息列表和工具定义,返回模型的最新回复。

import json import requests def call_llm(messages, tools=None, model="your-model"): url = "https://your-llm-endpoint/v1/chat/completions" headers = { "Authorization": "Bearer your-api-key", "Content-Type": "application/json", } payload = { "model": model, "messages": messages, "temperature": 0.3, } if tools: payload["tools"] = tools resp = requests.post(url, json=payload, headers=headers, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]

这里有两个参数我要多解释一句。第一是temperature设成0.3,是为了让输出更稳定:Agent在调用工具时必须按照固定格式输出,太高的随机性会导致它频繁写错参数或编造工具名。如果你做的是创意写作类任务,temperature才需要往高调;但做工具调度时,低温度是默认项。第二是timeout设成60秒,防止模型服务端迟迟不返回把主流程卡死。

第一版不用支持流式输出。流式是性能优化,不是功能必需,等你的核心闭环跑通了再回来加也不迟。

3.2 工具注册:把函数翻译成模型能懂的JSON

我习惯用装饰器来做工具注册,这样每新增一个工具,只需要写一个普通函数加一段装饰器,不需要去改主循环里的任何代码。这算是一个很小的架构设计,但能让你后期加工具时非常舒服。

TOOL_REGISTRY = [] def tool(name, description, parameters): def decorator(fn): TOOL_REGISTRY.append({ "definition": { "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, }, "fn": fn, }) return fn return decorator @tool( "get_weather", "根据城市名查询天气,适合回答与天气相关的问题", { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如:北京、上海"} }, "required": ["city"], }, ) def get_weather(city: str) -> str: return f"{city}天气:晴,24℃"

注意看这段代码的层次:TOOL_REGISTRY里每一项都包含两部分,definition是给模型看的说明书,fn是真正要执行的Python函数。这样做的好处是,主循环在组装请求时只需要把所有的definition提取出来,执行时只需要按名字找到对应的fn。模型和你的代码通过"工具名+参数JSON"这套协议沟通,彼此都不需要知道对方的内部实现。

写工具描述时有个实用技巧:description不要只写"查天气",要写清楚"什么场景下用、有什么副作用"。比如你有一个发送邮件的工具,描述里最好注明"会给真实收件人发送邮件,仅在用户明确要求时调用"。这类描述能显著降低模型乱调工具的概率。

3.3 主循环:思考、决策、行动、观察

现在写最关键的主循环。逻辑不复杂:把用户问题放进消息列表,反复调用模型,只要有工具调用指令就执行并把结果追加回去,直到模型给出纯文本答案。

def dispatch_tool(name, args): for item in TOOL_REGISTRY: if item["definition"]["function"]["name"] == name: try: return item["fn"](**args) except Exception as e: return f"工具执行出错: {e}" return f"未找到工具: {name}" def run_agent(query, max_steps=5): messages = [ {"role": "system", "content": "你是智能助手。你可以调用工具获取信息,最终必须用自然语言回答用户。"}, {"role": "user", "content": query}, ] tool_defs = [t["definition"] for t in TOOL_REGISTRY] for step in range(max_steps): assistant_msg = call_llm(messages, tools=tool_defs) messages.append({ "role": "assistant", "content": assistant_msg.get("content") or "", "tool_calls": assistant_msg.get("tool_calls"), }) # 没有工具调用,说明模型决定直接回答,循环正常结束 if not assistant_msg.get("tool_calls"): return assistant_msg.get("content") or "" # 逐个执行工具调用,并把结果作为工具消息追加进对话 for call in assistant_msg["tool_calls"]: fn_name = call["function"]["name"] args = json.loads(call["function"]["arguments"] or "{}") result = dispatch_tool(fn_name, args) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": str(result), }) # 达到最大步数,强制停止 return "已达最大轮数,停止执行。请调整问题或检查工具调用。"

这段代码里有两个细节容易被新手忽略。第一,assistant消息里即使没有content,也要把tool_calls原样保存并回传给模型,这是工具调用协议的一部分,漏了会导致下一次请求的上下文不完整。第二,tool_call_id必须一一对应,工具消息要关联到具体的那次调用,模型才能正确理解"这个结果是对应哪次调用"。

3.4 实际跑起来的效果

上面这个Agent虽然只有几十行代码,但已经具备完整的闭环能力。用户问"上海今天适合出门吗",模型会先调用get_weather工具拿到天气结果,再根据结果组织回答。整个过程你可以清楚地看到每一步消息是怎么流转的。

我建议你第一次跑通时,把messages列表打印出来看一遍。你会看到模型先产生一个含工具调用的assistant消息,然后是一条tool消息,最后又是一条assistant消息给出最终结论。这个"三次对话"的形态就是Agent最基础的工作方式,理解了它,后面什么多步规划、多工具协作都是在这个骨架上加东西。

4. 从"能跑"到"能打":四个绕不开的工程化问题

4.1 Token成本失控:对话越长烧钱越快

手写Agent跑通Demo之后,我遇到的第一个真实问题是:一个稍微复杂的任务,模型来回调用五六次工具,一轮对话消耗的token比想象中高得多。如果是生产环境还有大量用户,这账单会很感人。

解决思路分为两层。第一层是减少无效上下文:系统提示词只保留必要的角色设定和规则,别把冗长的知识库塞进去;每次追加的历史消息,可以只保留最近几轮完整对话,更早的做摘要压缩。之前提到的滑动窗口,本质上就是设定一个消息条数或token阈值,超过就把最早的非系统消息丢出去。

第二层是摘要压缩——我实战里最常用的方案。当对话接近窗口上限时,先把当前核心信息用模型Summarize成一小段文字,然后用这段摘要代替之前的完整历史。它的实现不复杂,核心逻辑是这样的:

def compress_if_needed(messages, max_blocks=10): # 只压缩中间的历史对话,保留系统消息和最近两轮 if len(messages) <= max_blocks: return messages system = [m for m in messages if m["role"] == "system"] recent = messages[-(max_blocks - 2):] history = [m for m in messages if m["role"] != "system" and m not in recent] summary = call_llm([ {"role": "system", "content": "把下面的对话压缩成要点,保留关键事实和未完成的任务。"}, {"role": "user", "content": json.dumps(history, ensure_ascii=False)}, ])["content"] return system + [ {"role": "assistant", "content": f"历史对话摘要:{summary}"}, ] + recent

这个方案的关键是"保留最近两轮完整记录",因为最近的信息往往最相关,摘要里的信息虽然准确但缺少细节,模型追问时可能不够用。压缩不是万能的,但作为第一道防线完全够用。

4.2 模型输出不规范:解析失败不是小概率事件

理论上,模型应该严格按工具调用协议输出JSON。但实际跑起来你会发现,它偶尔会犯各种怪毛病:在JSON前后加一段解释文字、把布尔值写成字符串、参数名多一个下划线。这些情况在框架里被静默处理掉了,所以你没见过;手写之后,你就要自己面对这些"天才"输出。

我的处理经验是:解析失败时不要直接崩溃,先做一次"抢救",再把问题反馈给模型让它自己改。抢救的逻辑很简单——找到字符串里第一个"{"到最后一个"}"之间的内容,尝试JSON解析:

def try_parse_args(raw): if not raw: return {} start = raw.find("{") end = raw.rfind("}") if start == -1 or end == -1: raise ValueError(f"无法从内容中找到JSON: {raw}") return json.loads(raw[start:end + 1])

如果抢救失败,就构造一条新的系统提示或用户消息,明确告诉模型"你上次返回的工具参数格式无法解析,请重新输出合法的JSON"。让模型自己修正,通常比你在代码里穷举各种错误情况要省事得多。这也是Agent式开发的一大特点:把异常处理的一部分"外包"给模型。

4.3 工具执行出错:让Agent学会自救而不是崩溃

你自己的代码调用工具函数,异常可能来自各种地方:参数类型不对、外部接口超时、依赖服务返回了错误码。如果工具一报错就让整个Agent崩溃,体验会非常糟糕。更合理的做法是:把异常信息当作工具执行结果返回给模型,让模型知道"这个工具没成功,原因是什么",然后由它决定是换个参数重试、换一个工具,还是直接告诉用户遇到了问题。

我在dispatch_tool里已经写了try/except,异常信息会拼成"工具执行出错: xxx"作为工具消息内容。你别小看这一步,它带来的行为差异非常大:模型看到错误信息后,通常会主动纠正参数再试一次,或者向用户解释现状。这比直接中断流程,或者傻傻地原样重试三次好得多。

当然,自救也要有边界,不然会变成死循环。Max步数上限就是兜底。我见过模型在一个查询类工具上反复用相同参数重试三次的场景,如果没有步数限制,每次重试都是一笔额外开销。所以"错误反馈给模型"和"强制终止"这两层必须同时存在。

4.4 没有日志的Agent等于没有黑匣子

框架自带日志通常很全,但层级太多,关键时刻反而不容易找到需要的信息。手写Agent最大的红利就是你自定义日志极其方便。我的做法是在主循环的每一步都落一条JSONL日志,记录时间戳、当前步数、模型返回的内容、调用了哪些工具、累计估算消耗了多少token。

import time def log_step(step, assistant_msg, tool_results=None): entry = { "ts": time.time(), "step": step, "content": assistant_msg.get("content"), "tool_calls": assistant_msg.get("tool_calls"), "tool_results": tool_results, } with open("agent_trace.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n")

事后排查问题时,这条日志就是黑匣子。模型哪一步开始胡说八道、哪一次工具调用返回了异常数据、哪一轮循环没有退出,翻一下JSONL全都对得上。我甚至建议你把每次请求的完整消息列表也存一份(至少存成压缩格式),这样可以离线复盘模型的行为模式,比在线上瞎猜高效得多。

5. 从调包侠到造轮子的人:学习路线与心态建议

5.1 手写和框架的边界在哪里

讲了这么多"手写的好处",我也得说句公道话:不是所有场景都该手写。生产环境里,如果团队要快速验证一个复杂的多智能体协作系统,成熟的框架确实能帮你省下大量时间。框架的价值在于"标准且高频的组件复用",手写的价值在于"对每一层逻辑拥有掌控力"。两者不是对立的,而是互补的。

我给自己定的选择标准很简单:学习阶段、调试阶段、以及对延迟和成本有特殊要求的场景,优先手写;业务规模大、需要多人协作维护、而且用框架的默认能力就能覆盖需求的时候,果断用框架。关键是,你得先手写过一遍,才有资格做这个判断。没拆过轮子的人,是分不清"框架不行"和"我不会用"的。

5.2 课程路线为什么这样设计

很多课程喜欢一开始就讲Agent的宏大架构、各种概念、各类框架横向对比,学生听完感觉懂了,一动手还是懵。我这个课程完全反着来,核心原则就一条:每一节课都产出一个能运行的东西,每个新增概念都绑定一个必须亲手实现的功能。

整个路线分五层:第一层做出一个能对话的最小闭环;第二层加上工具注册与调用,让Agent真正"动手";第三层解决多轮记忆与上下文压缩;第四层加入任务规划,让Agent能拆解复杂问题;第五层做工程化加固,包括日志、错误处理、质量评估。学完前两层,你已经能应付大部分常见场景;学完第五层,你就能自信地说自己"造过一个完整的轮子"。

5.3 我的最后建议

如果你现在正准备学这个方向,我只有一条建议:别囤课,别只看不动。找一个周末,把这篇文章里的代码从头到尾敲一遍,改成你自己的工具和提示词,让它跑起来。第一次跑通的那一刻,你会对"Agent"这个词产生完全不同的理解——它不再是什么高深莫测的技术,而是一个你亲手排好的流水线。

我也得提醒你,手写第一版一定很粗糙,会碰到各种莫名其妙的解析错误和循环问题。这很正常,我写第一版的时候也被模型气到过。但正是这些气人的瞬间,让后来用框架时遇到的报错变得亲切起来——因为你知道它背后发生了什么,知道该去看哪一环。如果你能坚持到"看框架源码像看自己写的代码"那一天,恭喜你,调包侠毕业了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进