AI Agent 这个词这两年出现的频率越来越高,但真正动手搭过的人并不多。大部分人停留在“看过几篇概念文章、收藏了几个框架仓库”的阶段,一提到自己从零做一个能跑起来的智能体,第一反应是“是不是得先微调个模型”或者“是不是要买一堆算力”。我最初也是这么想的,直到真正把一个能用的 Agent 从空目录跑通之后才发现,门槛比想象中低得多,难点根本不在模型本身,而在你怎么把“思考—行动—观察”这个循环设计清楚。
这篇内容就是把我从 0 到 1 搭一个 AI Agent 的完整过程拆开讲。适合两类人看:一类是有一点编程基础、想搞明白 Agent 到底怎么运转的开发者;另一类是产品或者业务侧的同学,想搞清楚一个智能体内部到底由哪些零件组成、为什么有些任务它做得好有些做得烂。我会从最核心的循环机制讲起,然后一步步给出可复现的搭建路径,中间穿插我自己踩过的坑和那些文档里不会写的细节。全程不依赖任何特定商业平台,你有一台能跑 Python 的机器就能跟着做。
1. 先把“智能体”这个词拆开看:它到底比普通调用多了什么
很多人对 Agent 的理解是“更聪明的聊天机器人”,这个理解会把你带偏。普通的大模型调用是一问一答:你给一段输入,它返回一段输出,结束。而 Agent 的核心区别在于它多了自主决策和工具使用的能力,能在没有人类逐步指挥的情况下,自己判断“下一步该干什么”,并且真的去干。
1.1 一次调用和一次 Agent 运行的本质差异
我用一个具体场景来说明。假设任务是“查一下某个城市今天的天气,如果下雨就提醒我带伞”。
普通调用是这样:你把这句话丢给模型,模型只能基于训练数据回答,它不知道今天的真实天气,于是要么编一个,要么告诉你“我无法获取实时信息”。整个交互一次结束。
Agent 是这样:模型先判断“我需要调用天气查询工具”,然后生成一个工具调用请求,程序执行这个请求拿到真实天气数据,再把结果喂回给模型,模型根据“今天有雨”这个观察结果,决定“我需要输出提醒带伞的结论”。这里发生了至少两轮模型推理,中间夹了一次真实的外部动作。
这个差异看起来简单,但它带来一个关键变化:Agent 的输出不再是单次生成的,而是一个循环收敛出来的结果。这就引出了整个搭建过程中最核心的东西——循环。
1.2 一个 Agent 的最小零件清单
在动手之前,我习惯先把零件列清楚,不然写着写着就乱了。一个能跑的最小 Agent,其实就四样东西:
| 零件 | 作用 | 缺了会怎样 |
|---|---|---|
| 大模型 | 负责推理、决策、生成 | 没有大脑,无法判断下一步 |
| 工具集 | 模型可以调用的外部能力 | 只能空谈,拿不到真实信息、做不了真实动作 |
| 循环控制器 | 驱动“思考—行动—观察”反复进行 | 只能单轮,退化成普通调用 |
| 记忆/上下文 | 保存历史步骤和观察结果 | 每轮都失忆,无法基于前文推进 |
这四样里,模型和工具是“能力”,循环和记忆是“结构”。新手最容易犯的错是把注意力全放在选哪个模型上,结果结构没搭好,再强的模型也跑不出稳定的 Agent。我见过不少人用着顶级模型,Agent 却老是死循环或者提前收尾,问题几乎都出在循环控制和上下文管理上。
1.3 为什么我建议从“手写循环”开始而不是直接上框架
现在市面上有各种 Agent 框架,封装的封装、抽象的抽象,几行代码就能跑起来一个“智能体”。但我强烈建议第一次搭的时候不要用框架,自己手写那个循环。原因很实在:框架把最关键的决策逻辑藏起来了,你调不通的时候根本不知道是模型的问题、提示词的问题还是框架内部状态管理的问题。
手写一遍循环,你会被迫想清楚几个问题:每一轮到底把什么内容喂给模型?模型的输出怎么解析成“要调用工具”还是“可以收尾了”?工具返回的结果以什么格式塞回上下文?这些问题的答案,就是 Agent 的骨架。骨架清楚了,后面用不用框架都只是效率问题。我自己的顺序是:先手写跑通一个能查天气、能做简单计算的 Agent,再去看框架,这时候框架的每个抽象你都能对上号。
2. 循环控制器:整个 Agent 的心脏,也是最容易写崩的地方
如果只能讲一个决定 Agent 成败的部分,那一定是循环控制器。它决定了 Agent 什么时候继续、什么时候停、每轮带什么信息进去。这一块写得好,Agent 稳如老狗;写得糙,要么无限打转,要么三步就草草收场。
2.1 “思考—行动—观察”循环的完整时序
我把一轮循环拆成四个阶段,按顺序走:
- 组装上下文:把系统提示、历史对话、之前的工具调用记录、最新观察结果拼成一个完整的消息列表。
- 请求模型:把上下文发给模型,拿到它的输出。
- 解析输出:判断模型这次是想调用工具,还是想给出最终答案。
- 执行或收尾:如果是工具调用,执行工具、把结果作为“观察”追加进历史,回到第 1 步;如果是最终答案,结束循环。
这个时序听起来平平无奇,但每一步都有坑。比如第 1 步,历史记录不是无脑全塞,塞太多会撑爆上下文窗口还烧钱;第 3 步,模型输出的格式不一定规整,解析逻辑必须能容错;第 4 步,工具执行可能报错,报错信息怎么反馈给模型让它自己纠正,也是一门学问。
2.2 终止条件:不设好这个,Agent 能跟你耗到天荒地老
我第一个版本的 Agent 就栽在这上面。当时只判断“模型输出了最终答案就停”,结果模型有时候会陷入“我再查一次确认一下”的循环,反复调用同一个工具,永远不给最终答案。跑了一晚上,账单感人。
后来我加了三重终止条件,才算稳住:
- 正常终止:模型明确给出最终答案,且不再请求工具调用。
- 步数上限:设一个最大循环轮数,比如 10 轮或 15 轮,到了就强制收尾,把当前已有信息汇总输出。
- 重复检测:如果连续两轮调用了同一个工具、传了几乎相同的参数,判定为原地打转,强制中断并让模型基于现有信息作答。
提示:步数上限不要设太小。我一开始设 5 轮,结果稍微复杂一点的任务根本做不完就被截断了。实测下来,通用任务 10 到 15 轮是个比较舒服的区间,具体看任务复杂度调整。
这里有个经验:强制收尾时不要直接抛个错误给用户,而是给模型发一条“你已经达到步数上限,请基于目前掌握的信息给出最佳回答”的指令,让它体面地结束。用户体验会好很多,也不会浪费前面几轮已经拿到的信息。
2.3 上下文膨胀:为什么你的 Agent 越跑越慢越跑越贵
这是手写循环时几乎必然遇到的问题。每一轮都把完整历史塞进去,到第五六轮的时候,上下文可能已经几千 token 了,模型响应变慢、成本上升,而且过长的上下文反而会稀释关键信息,让模型抓不住重点。
我的处理办法是分层管理上下文:
- 系统提示永远保留,这是 Agent 的行为准则。
- 最近 N 轮的完整交互保留,保证连贯性。
- 更早的工具调用记录做压缩,只保留“调用了什么工具、关键结果是什么”的摘要,丢掉冗长的原始返回。
举个具体的:查天气工具返回了一大段 JSON,里面有湿度、风速、气压一堆字段,但任务只关心下不下雨。那我在追加观察结果时,就只提取“是否有雨”这个关键字段,而不是把整段 JSON 塞回去。这个“观察结果提炼”的动作,能省下大量上下文,也让模型更容易聚焦。
2.4 一个容易忽略的细节:工具调用的错误也要进上下文
工具执行失败是常态——网络超时、参数格式不对、接口返回异常。新手常犯的错是工具报错了就直接抛异常终止整个 Agent。正确做法是把错误信息作为观察结果喂回给模型,让它自己判断怎么处理。
比如模型调用了一个查询接口但参数传错了,接口返回“缺少必要参数 city”。你把这条错误信息原样追加进上下文,模型下一轮很可能就自己补上 city 参数重新调用。这其实就是 Agent 自我纠错能力的来源——不是模型多聪明,而是你把错误信息如实反馈给了它,给了它纠正的机会。
3. 工具层设计:Agent 的手脚,决定它能干多大的事
模型再强,没有工具也只能纸上谈兵。工具层就是 Agent 伸向外部世界的手脚。这一块的设计好坏,直接决定了你的 Agent 是“能聊”还是“能干”。
3.1 工具的描述比工具的实现更影响成功率
这是我最想强调的一点,也是踩坑最深的地方。很多人写工具,把精力全放在函数实现上,描述随便写两句。结果模型要么不知道该在什么时候调用它,要么调用时参数传得乱七八糟。
模型决定调不调用一个工具,完全依赖你给它的工具描述。描述写得好,模型选工具的准确率能差出一大截。我总结了几条写描述的硬标准:
- 说清楚“什么时候用”,而不只是“这个工具做什么”。比如不要只写“查询天气”,而要写“当用户询问某地当前或未来天气、是否需要带伞、穿衣建议时使用”。
- 参数说明要带类型和示例。模型对“city: 城市名称,如‘杭州’”的理解,远好于只写“city: 城市”。
- 明确边界。如果工具只支持国内城市,就在描述里写清楚,否则模型会拿国外城市去调,然后拿到一堆错误。
我做过一个对比:同一个查询工具,描述从“查询信息”改成“当需要获取某实体的实时状态数据时调用,参数 entity 为实体名称字符串”,模型调用准确率肉眼可见地提升。这不是玄学,是模型在根据描述做匹配决策。
3.2 工具粒度:太粗和太细都是坑
工具拆多细,是个需要权衡的事。拆太粗,一个工具干一堆事,模型传参复杂、容易出错;拆太细,工具数量爆炸,模型选择困难,还容易连环调用。
我的经验法则是:一个工具对应一个明确的动作意图。比如“查询天气”和“发送提醒”是两个意图,就该是两个工具,不要合并成一个“处理天气相关事务”的巨无霸。但也没必要把“查询天气”再拆成“查温度”“查降水”“查风力”三个,那样模型得连调三次才能拼出完整信息,纯属浪费轮次。
一个实用的判断标准:如果两个操作总是成对出现、且中间不需要模型做决策,那可以合并;如果它们之间模型可能需要根据前一个结果决定要不要做后一个,那就必须拆开。
3.3 给工具加一层“安全壳”
Agent 会自主调用工具,这意味着它可能调用出你意想不到的参数。如果工具有副作用(比如写文件、发请求、改数据),一定要加保护。
我的做法是在工具执行层包一层校验:参数类型检查、取值范围检查、危险操作二次确认。比如一个“删除记录”的工具,我会在真正执行前检查传入的 ID 是否存在于允许删除的列表里,不在就直接返回拒绝信息给模型。这层壳不是防模型“恶意”,而是防它“想当然”——模型有时候会自信地传一个不存在的 ID,你不拦着它就真去删了。
注意:只读类工具可以放宽校验,写操作类工具必须严格。这个区分能让你在保证安全的同时,不至于把 Agent 的手脚捆死。
4. 提示词工程:不是写作文,是给 Agent 立规矩
Agent 的系统提示词和普通聊天机器人的提示词完全不是一个写法。普通对话你写“你是一个友好的助手”就够了,Agent 的提示词更像一份岗位说明书加操作手册,得把行为边界、输出格式、决策原则都交代清楚。
4.1 系统提示里必须写死的几件事
我整理了一份自己每次搭 Agent 都会检查的清单:
- 角色与目标:这个 Agent 是干什么的,服务的核心任务是什么。
- 可用工具清单及使用原则:有哪些工具、什么场景该用哪个、什么场景不该用。
- 输出格式约定:尤其是需要结构化输出(比如要求它用特定格式表示工具调用)时,必须给例子。
- 决策原则:信息不足时是先问用户还是先自己查?工具报错时是重试还是换方案?这些都要提前定。
- 终止原则:什么时候可以给最终答案,什么时候必须继续。
少写一条,Agent 的行为就可能跑偏。我最初漏了“信息不足时的处理原则”,结果 Agent 遇到模糊任务时,有时瞎猜有时反复追问,行为极不稳定。补上“优先基于已有信息合理推断,仅在关键信息缺失且无法推断时才向用户确认”之后,行为立刻一致了。
4.2 用 Few-shot 例子锚定工具调用格式
如果你的 Agent 需要模型输出特定格式来触发工具调用(而不是依赖模型原生的函数调用能力),那一定要在提示词里放几个完整的例子。例子要覆盖:一次正常的工具调用、一次多工具连续调用、一次工具报错后的处理。
模型对格式的模仿能力很强,给两三个高质量例子,比写一大段格式说明有效得多。我习惯把例子写成“用户输入 → 模型思考 → 工具调用 → 观察结果 → 下一步”的完整片段,让模型看到整个流程长什么样,而不只是孤立的格式模板。
4.3 提示词里的“反模式”要显式禁止
有些行为你不明确禁止,模型就会时不时犯。比如:
- 不要在没有调用工具的情况下编造实时数据。
- 不要在工具已经返回明确结果后还反复调用同一工具。
- 不要在最终答案里暴露内部思考过程(除非你就是要它展示)。
把这些写成明确的禁止项,比事后调试省事得多。我现在的习惯是,每发现一次 Agent 的“坏行为”,就往系统提示的禁止清单里加一条,慢慢就攒出一份很实用的负面清单。
5. 从空目录到跑通第一个任务:完整实操路径
前面讲的是原理和设计,这一节给一条能直接照着走的路径。我用的是 Python,模型接口用通用的对话补全接口(你换成任何一家兼容的接口都行),全程不依赖特定框架。
5.1 环境与依赖准备
只需要一个能发 HTTP 请求的库和一个环境变量管理工具。我习惯用最轻的组合:
pip install requests python-dotenv把模型接口的地址和密钥放进.env文件,代码里用python-dotenv读取。这样做的好处是密钥不进代码库,换环境只改配置文件。
import os from dotenv import load_dotenv load_dotenv() API_URL = os.getenv("MODEL_API_URL") API_KEY = os.getenv("MODEL_API_KEY")提示:接口地址和密钥的具体值按你实际使用的服务填写,这里不展开。重点是别把密钥硬编码进代码。
5.2 定义工具:先做两个最简单的
我建议第一个 Agent 只配两个工具:一个“计算器”,一个“当前时间查询”。计算器验证模型会不会正确传参,时间查询验证模型会不会在需要实时信息时主动调工具。这两个工具实现都极简,但足够把整个循环跑通。
import datetime def calculator(expression: str) -> str: """计算数学表达式,如 '2+3*4'。仅支持基础四则运算。""" try: allowed = set("0123456789+-*/(). ") if not set(expression) <= allowed: return "错误:表达式包含不支持的字符" return str(eval(expression)) except Exception as e: return f"计算失败:{e}" def get_current_time() -> str: """获取当前日期和时间。""" return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")注意calculator里那个字符白名单校验——这就是前面说的“安全壳”的最小实现。eval直接跑用户输入是有风险的,加个白名单能挡掉大部分乱传的参数。
5.3 工具注册表与调用分发
把工具和它的描述、参数结构登记成一张表,循环里根据模型返回的工具名去查表执行:
TOOLS = { "calculator": { "func": calculator, "description": "当需要进行数学计算时调用。参数 expression 为数学表达式字符串,如 '2+3*4'。", }, "get_current_time": { "func": get_current_time, "description": "当需要获取当前日期和时间时调用,无参数。", }, }这张表就是 Agent 的“能力清单”。后面加工具,只需要往这里加一条,循环逻辑不用动。这种设计让扩展变得很轻松。
5.4 主循环的骨架代码
核心循环我写成这样,逻辑尽量直白:
def run_agent(user_input: str, max_steps: int = 12): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for step in range(max_steps): response = call_model(messages) action = parse_action(response) if action["type"] == "final": return action["content"] tool_name = action["tool"] tool_args = action["args"] if tool_name not in TOOLS: observation = f"错误:不存在名为 {tool_name} 的工具" else: observation = TOOLS[tool_name]["func"](**tool_args) messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": f"观察结果:{observation}"}) return "已达到最大步数,基于现有信息无法给出完整答案。"这段骨架里,call_model负责发请求,parse_action负责解析模型输出是“调工具”还是“给答案”。这两个函数的具体实现取决于你用的模型接口格式,但结构是通用的。
5.5 解析函数:容错是第一位
parse_action是整个流程里最容易出问题的地方。模型输出不一定严格符合你要求的格式,所以解析必须能兜底。我的策略是:先尝试按约定格式解析,失败就退化成“把整段输出当最终答案”。宁可少调一次工具,也不要因为解析失败让整个 Agent 崩掉。
import json def parse_action(response: str): try: data = json.loads(response) if data.get("type") == "tool": return {"type": "tool", "tool": data["tool"], "args": data.get("args", {})} return {"type": "final", "content": data.get("content", response)} except (json.JSONDecodeError, KeyError): return {"type": "final", "content": response}配合这个解析逻辑,系统提示里要明确要求模型“输出 JSON,包含 type 字段,type 为 tool 时附带 tool 和 args,type 为 final 时附带 content”。格式约定加容错解析,双保险。
5.6 第一次跑通的验证方法
跑通之后别急着上复杂任务,先用几个小用例验证行为是否符合预期:
| 测试输入 | 期望行为 |
|---|---|
| “3 加 5 等于几” | 调用 calculator,返回 8 |
| “现在几点了” | 调用 get_current_time,返回时间 |
| “你好” | 不调工具,直接回复 |
| “算一下 10 除以 0” | 调用 calculator,拿到错误,合理回复 |
这四个用例能覆盖:正常工具调用、无工具对话、工具报错处理。全过了,说明你的循环骨架是健康的,可以往上加复杂度了。
6. 跑通之后必然遇到的几个坑,以及我的处理方式
骨架跑通只是开始,真正让它“好用”是在解决一个个具体问题的过程中磨出来的。这一节列几个我实际遇到、且大概率你也会遇到的问题。
6.1 模型死活不调工具,只顾自己编
这是最高频的问题。模型明明有工具可用,却选择直接凭训练数据回答。原因通常有两个:一是工具描述不够“诱人”,没让模型意识到“这个场景必须用工具”;二是系统提示里没强调“涉及实时/精确信息必须调工具”。
我的处理是双管齐下:在系统提示里加一条硬规则“凡涉及实时数据、精确计算、外部信息,必须先调用对应工具,禁止凭记忆作答”;同时把工具描述改得更贴合触发场景。改完之后,该调工具的场景基本都能触发。
6.2 工具调用参数格式错乱
模型有时会把参数包成嵌套结构,或者该传字符串的传了数字。除了在工具层做类型校验和容错转换,更根本的办法是在工具描述里把参数类型和示例写清楚,并在系统提示里给一个正确调用的完整例子。我现在的习惯是每个工具描述都带一个“调用示例”,模型照着抄的准确率很高。
6.3 多步任务中途“忘了”前面做过什么
任务一长,模型容易丢失上下文,重复做已经做过的事。这本质是上下文管理的问题。我的对策是在每轮追加观察结果时,除了原始结果,再加一句极简的“进度标记”,比如“已完成:查询天气;当前:判断是否带伞”。这个进度标记让模型每轮都能快速对齐当前状态,重复劳动明显减少。
6.4 最终答案质量不稳定
有时候 Agent 流程走对了,但最后给出的答案很敷衍。这通常是收尾阶段的提示不够。我会在系统提示里对“最终答案”提明确要求:要基于所有已获得的观察结果、要直接回答用户原始问题、信息不足时要说明缺什么而不是硬编。把收尾标准写清楚,答案质量会稳定很多。
7. 什么时候该上框架,什么时候手写就够了
手写跑通几个 Agent 之后,你自然会对框架产生兴趣。我的看法是:框架解决的是工程化和规模化问题,不是“能不能跑”的问题。如果你只是做原型验证、任务相对固定,手写循环完全够用,而且可控性最高。
当你开始遇到这些情况时,再考虑框架:需要管理几十个工具、需要复杂的多 Agent 协作、需要持久化记忆和状态恢复、需要可视化的执行追踪。这些是框架的强项,手写会越来越吃力。但即便上了框架,前面讲的循环原理、工具描述、上下文管理这些底层认知依然是你的核心竞争力——框架会换,原理不会。
我自己的路径是手写跑了三四个不同任务的小 Agent,把循环、工具、提示词这三块彻底摸熟之后,再去看框架的源码,发现它内部干的事和我手写的骨架本质一样,只是封装得更通用。这时候用框架,是站在自己理解的基础上做效率提升,而不是把黑盒当救命稻草。
最后分享一个我一直在用的小习惯:每搭一个新 Agent,我都会先写一个“行为验收清单”,列出它必须做对的五到十个典型场景,每次改完提示词或工具就重跑一遍清单。这个清单比任何调试工具都管用,因为它逼着我把“什么叫好用”定义清楚,而不是凭感觉觉得“好像还行”。Agent 这东西,感觉最不靠谱,清单最靠谱。