☰
从零手写AI Agent:原理、代码与踩坑实录
2026/10/19 16:03:12 网站建设 项目流程

别被“AI Agent”这个词吓到。我当年第一次听到这个概念,脑海里浮现的是科幻片里那种能独立执行任务的机器人,总觉得要会分布式系统、要懂一堆工程框架,才有资格碰这个东西。后来被一个朋友拉着从零搭了一个小智能体,我才发现:Agent 的入门门槛没有想象中那么高,但它背后的设计思路——怎么让模型自己决定调用什么工具、怎么把结果喂回去继续推理——确实需要一点耐心去理解。

这篇文章不会教你“用某个框架三分钟生成一个 Agent”,因为那种速成作品解决不了任何实际问题。我会从原理到代码,带你亲手写一个真正能用的智能体:它能理解你的问题,自己判断是否需要调用工具,拿到工具结果之后再组织语言回答你。我用的语言是 Python,整体代码量不大,每段关键代码我都会解释为什么这么写。如果你是第一次接触智能体开发,哪怕是只会最基础的 Python 语法,照着抄一遍也能跑通。

1. Agent 没那么玄乎:先建立正确的心智模型

很多人学 Agent 一开始就陷进框架文档里,结果越看越懵。我建议反过来:先别写代码,先把 Agent 到底是什么这件事想清楚。

1.1 普通聊天机器人和智能体的分水岭在哪儿

普通聊天机器人,就是你问一句它答一句。它像一个知识渊博但没有手和脚的顾问,只能凭训练时见过的数据回答你。你在群里问“明天适合爬山吗”,它能给你一段车轱辘话,但既查不了天气预报,也算不了空气质量。

Agent 的差别在于:它把“说话”和“做事”打通了。同样是“明天适合爬山吗”这个问题,一个合格的 Agent 会先拆解任务:爬山需要知道天气、气温、风力,甚至空气质量。然后它会去调用天气查询工具,拿到结构化数据,再结合这些数据给你一个明确的结论。

普通机器人是“我知道什么就告诉你什么”,Agent 是“我不知道就去查,查完再告诉你”。这个分水岭听起来简单,却是整个智能体开发的灵魂。

1.2 拆开看:Agent 就是一个“思考-行动-观察”的循环

我搭完第一个 Agent 之后最大的感悟是:它本质上是在跑一个循环。

第一步,模型拿到你的问题,进行推理——“这个问题我需要查天气”。 第二步,模型输出一个结构化的工具调用指令,而不是自然语言。 第三步,你的代码执行这个指令,比如真的请求天气接口。 第四步,把工具返回的结果当成一条新消息,再次交给模型。

模型看到工具结果后,如果信息够了,就给你最终答案;如果不够,它就再次输出下一个工具调用。这个循环不断重复,直到模型认为问题已经解决。

你在各种演示里看到的“智能体自动完成多步骤任务”,拆到底都是这个循环:推理、调用工具、看到结果、再推理。模型是大脑,工具是手脚,中间那层胶水代码,就是我们写的 Agent 本体。

想明白这一点之后,后面所有代码都是在实现这个循环而已。

2. 选型:别掉进“框架越多越好”的陷阱

在动手之前,有两类选型问题躲不开:用什么模型,用什么框架。我先说结论——对于新手,选型的优先级是:模型能稳定支持工具调用 > 接口简单 > 框架花哨程度。

2.1 模型选型:核心是接口能力,不是参数大小

很多新手选模型,第一反应是“哪个聪明选哪个”。但搭 Agent 这件事,模型聪明不聪明只是其中一个维度,更重要的是它对外提供的接口能力。

我在实际项目中主要看三个标准:

考察维度为什么重要入门建议
是否支持函数调用没有这个能力,模型就无法输出结构化工具指令,Agent 循环就转不起来优先选明确支持工具调用协议的服务
上下文窗口长度每次循环都会把历史消息重新发给模型,窗口太小容易爆8K 起步,32K 以上会更从容
返回速度与稳定性循环意味着一次任务有多次模型请求,单个请求慢,整体体验会翻倍慢优先选响应快、限流少的方案

参数大小反而没有那么关键。我的经验是:一个 70 亿参数的小模型,只要函数调用格式稳定,也能把一个单工具任务跑得很好;反过来,一个大模型如果工具调用协议不成熟,你会在这上面折腾到崩溃。

2.2 为什么我建议先自己手写一遍,再上框架

市面上的 Agent 框架已经很成熟了,封装了记忆、工具注册、多智能体编排一大堆能力。那为什么我还建议你从零手写一个?因为框架会掩盖细节。

我第一次用框架的时候,给了模型一个工具清单,它总是调不对参数,我根本不知道问题出在我给的工具描述不清楚,还是框架的解析层有问题。后来我自己写了一个最朴素的循环,才把整条链路看明白:原来模型要的不是“接口名 + 参数”,而是一份符合特定格式的工具说明文档。

自己手写一遍,你会理解三件事:

  • 工具描述是怎么影响模型决策的;
  • 工具返回结果是在哪一步塞回上下文的;
  • 循环失控时最容易卡在哪个环节。

有这层理解之后,再回去用框架,你会发现自己能看懂它的日志了,出了问题也知道往哪查。这就是我给所有新手的建议:先手写一个玩具版,再考虑框架效率。

2.3 环境准备其实很简单

不用搭什么复杂环境。一个 Python 3.10+,一个requests库,就够了。模型方面,你可以用任意提供 Chat 补全接口且支持函数调用的服务,我建议先注册一个带免费额度的服务商,把 Key 配到环境变量里:

export LLM_API_KEY="你申请的密钥" export LLM_API_BASE="https://你的模型服务地址/v1"

不需要一开始就本地部署模型,那会把你的学习成本拉高好几倍。先用现成的接口把逻辑跑通,这是最快路径。

3. 第一版 Demo:几十行代码,把一个会“用工具”的智能体跑起来

这一章是全文的核心。我会给你一份完整的 Python 代码,它实现了一个最简单的 Agent:能查天气、能看当前时间,并根据这两个信息回答你的问题。

3.1 核心交互协议:让模型调用你的函数

在写代码之前,你需要理解“函数调用”这个交互协议。它分为两步:

第一步,你把工具的描述以 JSON 格式发给模型,告诉它:有哪些函数、每个函数接收什么参数、参数是什么类型。模型读完这份“说明书”,才知道自己可以调什么。

第二步,模型在回答过程中,如果觉得需要查信息,它不会直接执行代码,而是输出一个特殊的结构化消息,里面包含函数名和参数。真正调用函数的,是你自己的程序。

理解这个协议之后,你会明白:模型的角色永远是“决策者”,执行者是你。这避免了让模型直接接触外部环境,安全边界清晰了很多。

3.2 完整代码:我的第一个能查天气的智能体

下面这段代码,是我自己写的第一个 Agent 的简化版。它非常朴素,但五脏俱全:

import os import json import requests from datetime import datetime API_KEY = os.getenv("LLM_API_KEY") API_BASE = os.getenv("LLM_API_BASE") MODEL = os.getenv("LLM_MODEL", "default-chat-model") SYSTEM_PROMPT = ( "你是一个智能助手。你有调用工具的权力。" "当你需要外部信息时,请调用对应工具;" "拿到工具结果后,再结合结果回答用户。" ) TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定经纬度位置的当前天气情况", "parameters": { "type": "object", "properties": { "latitude": {"type": "number", "description": "纬度,例如 39.9"}, "longitude": {"type": "number", "description": "经度,例如 116.4"} }, "required": ["latitude", "longitude"] } } }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前日期和时间", "parameters": {"type": "object", "properties": {}} } } ] def call_tool(name, args): if name == "get_current_time": return {"current_time": datetime.now().isoformat()} if name == "get_weather": # 这里替换成你自己的天气服务请求即可 return { "latitude": args["latitude"], "longitude": args["longitude"], "weather": "晴", "temperature_celsius": 26, "wind_level": 3 } raise ValueError(f"未知工具: {name}") def chat_once(messages): payload = { "model": MODEL, "messages": messages, "tools": TOOLS, "tool_choice": "auto", } resp = requests.post( f"{API_BASE}/chat/completions", headers={"Authorization": f"Bearer {API_KEY}"}, json=payload, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"] def run_agent(user_input, max_steps=8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for step in range(max_steps): msg = chat_once(messages) messages.append(msg) if msg.get("tool_calls"): for tc in msg["tool_calls"]: fn = tc["function"] name = fn["name"] args = json.loads(fn["arguments"]) result = call_tool(name, args) messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": json.dumps(result, ensure_ascii=False), }) continue return msg["content"] return "我在限定步数内没有完成这个任务,请稍后再试。" if __name__ == "__main__": print(run_agent("现在几点了?顺便帮我查一下北纬 39.9、东经 116.4 位置的天气,我该穿短袖吗?"))

这段代码的核心逻辑,就是你前文看到的那个“思考-行动-观察”循环。模型先输出工具调用请求,程序执行工具并回传结果,模型再次生成回答,直到不再产生新的调用为止。

3.3 代码关键点拆解:为什么这样写

我在写这个 Demo 时反复调整过几个地方,这里只挑最关键的讲。

第一点是messages.append(msg)这一行。很多新手会漏掉这一步:把模型生成的那条带工具调用的消息放进历史。如果你不把它加回去,模型不知道“它刚刚已经发起了调用”,紧接着你又把工具结果塞进来,模型会陷入错乱。

第二点是工具返回结果的格式。role必须是"tool",并且要带上tool_call_id,这样模型才能把工具结果和它刚才发起的调用对应上。这个对应关系一旦错乱,模型就会出现答非所问。

第三点是max_steps限制。我在循环外面套了一个最大步数,防止模型陷入无限调用。这一步看着不起眼,真实场景里能救命。

第四点是tool_choice: "auto"。这个参数告诉模型:你可以自行决定要不要调用工具。有些场景你可能希望强制它先调某个工具,那可以把auto改成具体工具名,但在这里,让模型自己判断才是 Agent 的意义。

3.4 跑起来,你会看到这样的过程

运行这段代码,你可能以为它会直接告诉你答案。但真正发生的过程,在日志里会暴露得很清楚:

  • 第一步:用户提问。
  • 第二步:模型返回一条tool_calls,内容是“同时调用 get_current_time 和 get_weather,参数是经纬度”。
  • 第三步:程序执行这两个函数,把时间、天气数据拼成一条结果,塞回消息列表。
  • 第四步:模型看到结果,生成最终回答:“现在是 14:30,天气晴,26 度,微风,可以穿短袖。”

我第一次跑通这个循环的时候,心情很复杂:模型确实在“自己决定调用工具”,但这个过程中真正干活的是我那几十行代码。Agent 不是什么魔法,它就是一套精心设计的循环逻辑。

4. 从“能跑”到“能用”:记忆、工具边界和多轮状态

很多人的 Agent 停在 Demo 阶段,就是因为只实现了“能跑”,没处理“能用”。这一章我把升级过程中必须面对的三个问题讲透。

4.1 短期记忆:消息列表本身就是记忆

Agent 的记忆问题,被很多人讲得很玄。其实最基本的记忆机制就藏在消息列表里:你把每一轮的系统提示、用户消息、模型回复、工具结果全部累积在一个数组里,每次请求时都带上,模型就“记得”前面发生了什么事。

这是最朴素的短期记忆。它的优点是无状态、好调试,坏处是 token 消耗会随对话长度不断增长。你聊了二十轮之后,光是把历史消息重新发送一遍就要花不少时间。

针对这个小问题,我的折中方案是:给消息列表设置一个滑动窗口,只保留最近五六轮的内容。再老的消息,可以用一句话概括成摘要,塞进系统提示词里。这样既保留对话上下文,又控制 token 增长。

我试过直接截断历史消息,效果很差,模型会突然“失忆”。后来改成摘要,虽然摘要会损失一些细节,但整体可用性强太多了。

4.2 工具注册表:别让模型瞎调用

Demo 里只有两个工具,问题不明显。一旦工具变多,你需要一套“工具注册表”来管理它们,而不是把每个工具的逻辑都硬编码在call_tool里。

我习惯的做法是这样:每个工具都是一个函数,函数自带名称和描述,程序启动时遍历所有函数,自动生成 TOOLS 列表发给模型;调用时再根据函数名去注册表里找到对应的函数执行。

TOOL_REGISTRY = {} def register_tool(func): TOOL_REGISTRY[func.__name__] = func return func @register_tool def get_weather(latitude: float, longitude: float): """查询指定经纬度位置的当前天气""" return {"weather": "晴", "temperature_celsius": 26}

这种设计的两个好处:新增工具成本极低,只要写一个普通函数再挂个装饰器;调用层面不会出现“模型返回了一个不存在的工具名,程序直接崩溃”的问题,可以做一个统一兜底。

4.3 多轮对话状态:加一个会话管理外壳

如果不做会话管理,你的 Agent 只能处理单次提问,每次启动都是一张白纸。真实使用场景里,用户会说“刚才那个城市呢?”这种话,所以你得给 Agent 加上会话外壳。

最简单的实现是维护一个会话 ID 到消息列表的映射。用户发起请求时带上会话 ID,程序把历史消息取出来,追加新问题,调用循环,再把最新的消息列表存回去。

sessions = {} def chat_in_session(session_id, user_input): if session_id not in sessions: sessions[session_id] = [ {"role": "system", "content": SYSTEM_PROMPT} ] sessions[session_id].append({"role": "user", "content": user_input}) result = run_agent_with_messages(sessions[session_id]) sessions[session_id].append({"role": "assistant", "content": result}) return result

这个外壳看着简单,却是你从写脚本到写应用的重要一步。它意味着你的 Agent 可以嵌入到聊天软件、网页应用,甚至命令行工具里,变成真正可以持续交互的服务。

5. 踩坑实录:搭智能体最容易翻车的五个地方

我前前后后调过不少 Agent 项目,有些坑反复踩。这一章不说正确做法,只讲翻车案例,每个都能对应到一段真实调试经历。

5.1 没有最大步数限制,模型陷入工具调用死循环

首次跑通带多工具的 Agent 时,我遇到过模型反复调用同一个工具的场景:查完天气,再查天气,第三次还是查天气,每次参数还都一样。原因很简单,模型生成的答案因为某种原因不符合它自己的预期,它就不断尝试重复调用,试图“修正”一个不存在的问题。

这种问题不加max_steps限制的话,你的账单会先崩溃。我现在的习惯是:所有 Agent 循环默认限制三步,日常用六步,最多不超过十步。超出步数直接停止,把已收集到的信息返回给用户,并诚实说“我还没完成”。

5.2 模型返回的工具参数不是合法 JSON

函数调用协议要求模型输出 JSON 格式的参数,但实际运行中,你拿到的arguments字段偶尔会是残缺的字符串,比如少了一个右括号,或者参数名被加了多余的空格。我最初看到这个问题很崩溃,甚至怀疑是模型服务的问题。

后来排查发现,根源在于我写的工具描述不够精细:参数名、类型、是否必填都没有说清楚,模型只能“猜”。解决办法也很简单:把工具描述写得像产品说明书一样清楚,加上合理的required列表,并且每调用模型前提醒它“严格按 JSON 格式输出参数”。

另外,在代码里一定要做一层容错:arguments解析失败时,把这个错误信息返回给模型,让它重新生成,而不是直接让程序崩溃。

5.3 上下文爆炸,长会话把模型“喂”慢

第一次把 Agent 接入长对话场景时,我发现问答越来越慢,最后甚至直接超时。打开日志一看,消息列表里堆了几十条历史,再加上工具返回结果,每次都发了十多万 token 给模型。

这个问题的根源,还是我在前面说的滑动窗口没做。我的处理方案是:设定一个阈值,比如消息超过二十条就触发历史压缩,把最早的十轮消息用一个本地模型生成的摘要替换掉。这个方法的效果立竿见影,响应速度恢复到了正常水平。

5.4 工具执行异常没有回传给模型

有一次工具请求超时了,我的代码抛出了异常,结果整个 Agent 直接崩溃。事后我想明白了一个道理:Agent 的外部依赖不可能永远可靠,工具失败是常态,关键是失败之后怎么处理。

正确做法是把异常信息转成文本,作为工具结果返回给模型,让模型知道“刚才那个调用失败了,请换个思路”。比如:

try: result = call_tool(name, args) except Exception as e: result = {"error": f"工具调用失败: {e}"}

模型收到这个错误说明之后,通常会换个方式继续尝试,或者直接在答案里告诉用户“天气服务暂时不可用”。这比让整个程序崩溃体面得多。

5.5 重试与幂等:别让同一个工具被反复执行

最后这个坑比较隐蔽。当 Agent 循环重发请求时,如果消息列表里包含的历史工具调用记录被重复发送,模型可能误以为应该再次执行同样的调用,导致同一个副作用被执行多次。比如“发送邮件”这种工具,执行两次你就尴尬了。

我的策略是:对工具的调用记录做全局幂等控制。每条工具调用记录都有一个唯一 ID,程序在执行前先检查这个 ID 是否已经执行过,执行过就直接返回上次结果,不再重复运行。

这个策略初看不重要,但一旦你的 Agent 从“查天气”升级到“下单、发消息、写文件”,它就是必须做的事。

6. 从玩具到工具:后续值得做的几个进阶方向

如果你已经照着前面的内容搭出了一个能跑通的小智能体,恭喜你,最难的部分已经过去了。接下来怎么继续,我根据自己的经验给几个方向,每个都是真实使用中会碰到的痛点。

6.1 让 Agent 自己找知识:接入检索能力

基础 Agent 的软肋是知识更新不及时。模型训练完之后,新知识它一概不知道。解决这个问题的方法是检索增强:把外部文档切块、向量化、存进向量库,Agent 需要信息时先做一次向量检索,把命中的片段塞进上下文,再让模型回答。

这相当于给 Agent 装了一个“图书馆查询员”。实现思路不复杂,但效果提升非常明显,尤其是你做垂直领域应用——比如客服、私有知识库问答——几乎是必备能力。

6.2 多 Agent 协作:拆成“规划者 + 执行者”

单 Agent 什么都干,上下文会越来越乱。我现在比较喜欢拆成多个小 Agent 分工合作:一个负责理解任务、拆解计划,另外几个分别负责检索、计算、写报告。

布局听上去复杂,实际上就是把不同功能的 Agent 串起来,上游 Agent 的输出作为下游 Agent 的输入。如果你只有一个模型接口,也可以不发多个 Agent,而是让同一个模型扮演不同角色,在不同阶段切换系统提示词。效果有差距,但思路是通的。

6.3 定时触发与持久化:让 Agent 从“应答”到“主动干活”

聊天机器人的交互模式是“用户发起,Agent 响应”。真要把它变成生产力工具,还需要让它定时主动跑起来。我之前给一个内部工具加了一个调度器,每天上午九点自动拉取数据、生成摘要、发到群里。模型在里面的角色不变,只是交互方向变了。

实现方式没有任何魔法,就是cron或任务队列 + Agent 状态持久化。把会话状态存到数据库里,Agent 就从一个随用随丢的脚本,变成了可持续运行的服务。

6.4 本地化部署:隐私与成本的终极解法

最后说一句本地部署。很多人一看到“本地部署”就想到要买吓人的显卡,其实现在很多模型压缩技术已经让中等配置的机器也能跑得动小参数模型。如果你的场景对延迟不敏感、数据又不能出内网,本地小模型完全可行。

不过我的真实体会是,不要一上来就本地部署。先用云接口把业务逻辑验证清楚,确认值得投入了,再考虑本地化。否则你会被环境配置、推理速度、模型效果这一圈问题劝退,捡了芝麻丢了西瓜。

我做了这么多 Agent 相关的小项目之后,最深的一个体会是:Agent 的边界在于“工具”和“任务目标”,不在于“模型多聪明”。你在前面给它多少可靠的工具、设计多清晰的循环边界,它就能帮你办多少事。整理这份教程的基础,不是我一开始就懂这些,而是踩过一连串坑之后,一点点把这段朴素经验沉淀出来的。希望你跑通第一版 Demo 的时候,也能感受到那种“原来它真的是在想事情”的兴奋。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询