最近几年“AI Agent”这词火得有点过分,市面上讲概念的、包装产品的、卖课的都有。但我始终觉得,对一个真正想入行或者想搞懂原理的人来说,最值钱的路子永远是自己亲手写一遍。那个“零基础手写AI Agent系统课”的标题我琢磨了很久,题目本身没什么特别,特别的是它把“零基础”和“手写”放在了一起——这意味着不靠现成框架,不靠复制粘贴,而是从第一行代码开始,把一个Agent从无到有搭出来。
这篇内容就是把我按照这个路径走下来的整个思路和实操过程整理一遍。它不是课程广告,也不是框架评测,而是一条“不依赖任何Agent框架,自己动手实现Agent最小闭环”的完整路线。我把中间踩过的坑、想清楚的原理、可以直接照搬的代码结构都放在里面。如果你是那种不喜欢黑盒、喜欢把每个环节都弄明白再往下走的人,这篇内容应该能帮你省下不少时间。
1. “零基础手写”到底该怎么理解
1.1 这门路径的核心不是调框架,而是拆结构
很多人一听到“手写Agent”,第一反应是“现在框架这么多,为什么不直接用”。这问题问得很合理,但答案恰恰是手写存在的意义:框架给你的是封装好的黑盒,调用起来很方便,可一旦运行结果不符合预期,你根本不知道是哪一层出了问题,是模型没理解工具描述,还是工具返回值格式不对,或者是上下文被截断了。
我自己最开始也是从现成框架入手的,用一个高级编排工具写了个带工具调用的流程,当时觉得特别顺畅。可后来产品需求一变,需要在工具调用之后做一些很细微的条件判断——比如结果里有多个候选值时要按评分排序再决定下一步——框架自带的能力就不够用了。我翻了半天文档查它的源码钩子,最后发现改起来比重写还费劲。从那之后我果断把手头所有练手项目改成了手写实现,反而心里踏实了。
手写Agent的第一课应该学的是数据结构,而不是模型API。你要搞清楚消息列表是什么结构,工具描述用什么格式传给模型,模型返回的工具调用结果怎么解析,工具执行完怎么把结果回填到对话里。这些内容靠看框架文档是学不到的,框架帮你把这些都藏起来了。
1.2 “零基础”指的是对Agent陌生,而不是对代码陌生
关于“零基础”这个词,所有准备上手的人先把心态摆正:这门课要求的零基础,准确说是“没写过Agent”“没深入理解过大模型应用架构”。你不需要懂强化学习,也不需要能推导Transformer公式,但基础Python语法、函数怎么写、JSON怎么解析、HTTP请求怎么发,这些基本功必须先过关。
我在一个交流社群里见过不少人兴致勃勃报了名,但连字典和列表都分不清就跑来学工具调用,结果卡在解析响应这一步就放弃了。说句实在话,如果Python基础不牢,先把基础补一补再动手,手写Agent本身对代码能力要求不算高,但它是建立在你对被操作的语言有一定熟悉度的前提上的。
另外两个最好提前掌握的小知识点:一个是装饰器,后面做工具注册表的时候会非常方便;另一个是类型标注,写多模块交互的时候能帮你减少很多低级错误。这两样不需要精通,懂大概就能跟上。
1.3 手写一遍之后,你能获得什么额外优势
手写一遍Agent之后,有几个特别实际的收益,这里先摆出来,后面实现的时候你们会发现处处都在呼应。
第一个收益是你终于能定位问题了。Agent跑挂了,你会知道先查工具调用返回的JSON是不是被截断了,再查是不是消息历史太长把上下文塞满了,而不是像以前一样只能重启服务碰运气。
第二个收益是你不需要被框架绑定。任何高级框架能提供的能力本质上就是一串代码实现的模式,你自己写熟悉了之后,遇到需要定制逻辑的场景,直接在工程里加一个分支函数就行,完全不用考虑框架允不允许你这么做。
第三个收益是面试或者做技术分享时底气完全不同。你讲Agent原理不是背概念,而是带着自己实现过的代码细节去讲。你在“如何设计工具描述才能提高模型调用准确率”这个问题上是有实践经验的,这个问题在真实线上系统里特别常见。
2. Agent跟聊天机器人差在哪:先想清楚再动手
2.1 三大核心差异:记忆、工具、规划循环
很多人最初学习的误区是:把Agent当作一个会聊天的机器人。这里直接把差异说清楚:聊天机器人是单轮或者多轮对话,模型收到消息直接返回文本;Agent则是一个带“行动能力”的系统,它在对话之外还能调用搜索、执行代码、读写文件、请求外部接口。要实现这个行动能力,靠的不是给模型几句提示词,而是要设计一套代码逻辑,让模型能“提出意图”,让程序能“执行意图”,再把结果“反馈给模型”。
这套逻辑里最关键的是三大件。
记忆不是一句空话,它分为两个层次。短期记忆就是当前这段对话的消息列表,模型必须能看到刚才说了什么才能继续往下聊;长期记忆则是把重要信息存到外部存储里,比如向量数据库或者普通文件,下次再对话时把相关内容检索出来放回上下文里。
工具是Agent的手脚。你给模型声明有哪些函数可以用、每个函数是干什么的、参数应该怎么填,模型看到用户问题之后会返回一个“我要调用某个工具”的结构化指令,你的代码负责真正执行这个指令。
规划循环是大脑运转方式。Agent不是模型回答一句就结束了,它会反复经历“观察当前状态 -> 决定下一步动作 -> 执行动作 -> 观察结果”的循环,直到模型认为任务已经完成。这个循环的终止条件设计直接影响Agent是靠谱还是失控。
2.2 最小闭环是什么
我建议所有初学者先别想得太复杂,一定先实现一个最小闭环。什么是最小闭环?就是用户提出一个问题,Agent自动决定调用一个工具,拿到工具结果后进行总结,最终给出答案。这整套流程在一次任务中完整走通,就叫一个闭环。
我最初的做法是做了一个超级简单的时间查询工具。用户问“现在几点”,Agent识别到这个请求需要调用时间工具,于是返回一个特殊格式的指令说“我要查系统时间”,代码执行这个工具拿到时间文本,再把这个文本回填给模型,模型组织语言告诉用户答案。
这个过程看起来简单得有点无聊,但它把Agent最核心的链路全部串起来了:意图识别、工具定义、工具调用、结果回填、最终回复。你在这个最小闭环上把每一个环节的数据流都调通了,后面加搜索工具、加代码执行工具、加向量记忆,都是在这个骨架上填肉,完全不存在重新学的问题。
2.3 为什么设计成“模型思考 + 代码执行”而不是“全交给模型”
有人会问:为什么不直接让模型自己把所有事情都做了,非要代码参与?答案很简单:模型擅长的是理解和生成自然语言,但它不擅长精确计算、实时查询和确定性执行。
举个例子,你让模型算一道“从今年1月1日到当前时间一共多少秒”的题目,它大概率会算错,因为它本质上是按概率在猜下一个字。但你给它声明一个计算工具,让代码执行time.time() - 某时刻,结果100%准确。同样的道理,让模型查询今天的天气,它只能凭训练数据瞎猜,但接一个天气API工具就能拿到真实数据。
模型负责决策,代码负责确定性执行,这就是Agent架构最核心的逻辑。理解这一点之后,你的设计方向就清晰了:凡是模型不擅长但代码能精确完成的事情,都应该做成工具;凡是需要理解意图、拆解任务、组织语言的事情,都交给模型。
3. 动手之前先画好架构:五个模块一个都不能少
3.1 核心模块与边界划分
我在动手写代码之前,先在纸上画了一遍系统结构。整个Agent被拆成五个模块,每个模块职责明确,不互相掺和。
第一个模块是模型接入层。它负责跟大模型API通信,把消息列表发过去,拿到响应返回。所有跟API相关的细节——鉴权、请求超时、重试、流式处理——都封装在这一层。其他模块完全不需要关心模型叫什么名字、API地址是什么。
第二个模块是消息管理模块。它维护一个消息列表,保存系统提示词、历史用户消息、历史助手消息、工具返回结果。每个角色都有自己的定位,这个列表是Agent的记忆载体。
第三个模块是工具注册表。每个工具以统一的格式登记,包含名称、描述、参数格式、执行函数四个字段。Agent启动时扫描注册表,把所有工具转换成模型可读的JSON Schema格式,然后传给模型。
第四个模块是执行引擎。它解析模型返回的工具调用指令,查工具注册表找到对应函数,传入参数执行,把执行结果格式化成一条“工具消息”追加到消息列表里。
第五个模块是控制循环,也就是Agent的主循环。它决定什么时候问模型要下一步动作,什么时候停止循环,把最终答案交给用户。
这个划分是我在踩了几次坑后定下来的版本。最早的时候我把工具逻辑直接写在循环里,代码看起来非常简短,但加第二个工具要改一堆逻辑。模块分开之后,新增一个工具只是往注册表里登记一条记录的事,主循环代码完全不用动。一开始养成这种拆分习惯,后面会轻松太多。
3.2 消息列表怎么流转
消息列表是整个Agent传递数据的核心管道。多个模块不直接互相调用,而是通过往消息列表里追加内容来实现数据的流动。
用户输入是一条role="user"的消息;模型普通回复是role="assistant"的消息;模型决定调用工具时返回role="assistant"的特殊消息,里面包含工具调用指令;代码执行完某个工具之后,我们把结果封装成role="tool"的消息追加到列表里;控制循环再次把更新后的完整列表发给模型,模型看到工具执行结果,继续判断下一步。
这里有个细节很关键:每一次循环,发给模型的是整个消息列表,而不是只发最新一条。模型本身没有记忆,它的记忆就在你给它看的上下文里。如果你只发最新一条工具结果,模型完全不知道前面在聊什么,整个任务必然跑偏。我自己最开始在这上面吃过亏,当时只把最后一次工具结果发给模型,模型完全无视了之前的对话背景,回答得驴唇不对马嘴。
3.3 模块间接口怎么约定
因为模块要频繁交换数据,接口定得规不规范直接决定开发体验。我制定的约定是这样的。
工具注册表里每个工具包含四个字段:name是工具名,全小写加下划线,比如get_current_time;description是给模型看的自然语言描述,要写得详细;parameters是JSON Schema格式的参数说明;handler是实际执行该工具的函数对象。
模型层返回的统一响应里包含两类字段:content是普通文本消息,可能是空的也可能有内容;tool_calls是工具调用列表,如果模型决定不调用工具,这个列表就是空的。控制循环只看这两个字段就能判断当前该干什么。
这种约定方式让新增一个工具变得非常轻松:写一个普通的Python函数,在注册表里加一条记录就完成了,后续完全不用改其他模块。我后来给系统加了个记事本工具,整个工作量不到十分钟,其中大部分时间还是在写函数逻辑。
4. 核心代码实现:五步搭出最小Agent
4.1 第一步:模型调用层封装
我把模型调用封装成一个独立的客户端类。用某个支持工具调用能力的大模型API来演示,不锁定任何具体厂商。
import json from typing import List, Dict, Any, Optional class LLMClient: def __init__(self, api_key: str, model: str): self.api_key = api_key self.model = model def chat(self, messages: List[Dict[str, Any]], tools: Optional[List[Dict]] = None, temperature: float = 0.3, max_tokens: int = 1024) -> Dict[str, Any]: """发送消息给模型,返回普通内容或工具调用指令。""" payload = { "model": self.model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } if tools: payload["tools"] = tools # 实际发送HTTP请求的代码在这里 # 你用的是哪个平台的SDK就调哪个SDK的方法 response = call_chat_api(payload) message = response["message"] return { "content": message.get("content", ""), "tool_calls": message.get("tool_calls", []) }这里有一个值得留意的封装细节:最好让这个类只干两件事——接收消息列表和工具定义,返回结构化结果。不要把解析工具调用的逻辑放进来,更不要把执行工具的逻辑放进来。单一职责的原则在代码层面能帮你省掉大量排查问题的时间。我的习惯是返回的tool_calls直接保留模型原始格式,解析逻辑放到执行引擎里去,这样模型层就保持得很干净。
4.2 第二步:工具注册表设计
设计工具注册表用Python装饰器非常顺手,既简洁又容易扩展。
class ToolRegistry: def __init__(self): self._tools = {} def register(self, name, description, parameters): def decorator(func): self._tools[name] = { "name": name, "description": description, "parameters": parameters, "handler": func, } return func return decorator def get_schemas(self): """转换成模型可读的JSON Schema列表。""" schemas = [] for tool in self._tools.values(): schemas.append({ "type": "function", "function": { "name": tool["name"], "description": tool["description"], "parameters": tool["parameters"], } }) return schemas def execute(self, name: str, arguments: str) -> str: if name not in self._tools: return f"错误:没有名为 {name} 的工具" func = self._tools[name]["handler"] try: args = json.loads(arguments) result = func(**args) return json.dumps(result, ensure_ascii=False) except Exception as e: return f"工具执行出错:{str(e)}" registry = ToolRegistry()使用这个注册表的时候非常爽,定义和注册放在同一个位置,维护起来毫不费劲。
import time @registry.register( name="get_current_time", description="获取当前的系统日期和时间,适合回答任何与时间相关的问题", parameters={ "type": "object", "properties": {}, "required": [] } ) def get_current_time(): return {"time": time.strftime("%Y-%m-%d %H:%M:%S")}工具描述是容易被忽略但影响极大的部分。同样的功能,描述写得模糊还是写得精确,模型调用准确率差距非常大。拿时间工具来说,“获取当前时间”这个描述不够好,模型不确定它拿到的会不会是用户所在时区的时间;而我改成“获取当前的系统日期和时间,适合回答任何与时间相关的问题”之后,模型在模糊场景下也倾向于调用它。描述里把工具的适用场景和不适用场景都写清楚,模型才能正确选择。
4.3 第三步:调用工具的消息回填
工具执行完之后,要把结果以正确的消息结构回填到消息列表里。工具返回的消息有专门的角色tool,并且必须带上tool_call_id,用来对应模型刚才发起的那次调用请求。
def append_tool_result(messages: List[Dict], tool_call_id: str, result: str) -> None: messages.append({ "role": "tool", "tool_call_id": tool_call_id, "content": result })这个环节看着简单,实际容易出问题。有的同学容易把工具消息的role写成user,然后模型就会困惑——“这段文字到底是用户说的还是工具返回的”。还有的容易忘记带tool_call_id,在多数接口里这会导致整个请求报错。我自己的习惯是每次追加完消息之后,都打印一遍完整列表结构检查一下,等后面跑通了再取消调试输出。
4.4 第四步:控制循环实现
最小Agent的主循环逻辑并不复杂,单线程、按顺序执行就足够了。
def run_agent(user_input: str, max_iterations: int = 5) -> str: messages = [ {"role": "system", "content": "你是一个能调用工具的智能助手。当用户需要实时信息或精确计算时,请调用合适的工具。请一步一步分析并完成任务。"} ] messages.append({"role": "user", "content": user_input}) llm = LLMClient(api_key="你的API密钥", model="default-chat-model") for step in range(max_iterations): response = llm.chat(messages, tools=registry.get_schemas()) # 没有工具调用,说明模型认为任务已完成 if not response["tool_calls"]: final_answer = response["content"] print(f"\n最终答案:{final_answer}\n") return final_answer # 有工具调用,逐一执行并把结果回填 for tool_call in response["tool_calls"]: tool_name = tool_call["function"]["name"] tool_args = tool_call["function"]["arguments"] print(f"\n[step {step}] 调用工具:{tool_name} 参数:{tool_args}") result = registry.execute(tool_name, tool_args) print(f"工具返回:{result}") # 追加模型这条带工具调用的消息 messages.append({ "role": "assistant", "tool_calls": [tool_call], "content": None, }) # 追加工具执行结果 append_tool_result(messages, tool_call["id"], result) return "已达到最大迭代次数,任务可能尚未完成。"这个循环里的关键细节是把模型返回的原始工具调用消息原样追加进消息列表,然后再追加工具结果。有些实现图省事不同时追加这两条,只追加工具结果,结果模型完全不知道工具结果是在回应哪一次调用,越往后越混乱。
最大迭代次数这个参数非常重要,它是系统的安全阀门。Agent如果陷入无限循环调用工具,你设置的max_iterations能强制终止。初学时建议设成3到5轮,后面跑熟了再根据任务复杂度调高。
4.5 第五步:找对断点与验证方式
写完第一版之后,一定要在关键位置埋好调试输出。我最常用的调试方式是每执行一步就把当前消息列表里的角色和内容摘要打印出来,确认每一步追加的数据都是对的。
运行一次完整流程,你想要的输出结果大致是这样的:
用户提问:今天是几号? [step 0] 调用工具:get_current_time 参数:{} 工具返回:{"time": "2025-04-08 14:22:31"} 最终答案:今天是2025年4月8日。看到这个输出,说明最小闭环已经通了。你拿到了Agent最核心的骨架,接下来无论往里面加多少个工具,都只是重复“定义工具 -> 注册工具 -> 循环自动调用”的过程。
5. 工程细节:参数、提示词与稳定性
5.1 模型参数怎么调
手写Agent时有一个好处,每个参数都得自己决定,能真正理解参数的作用。
温度参数是最常调的一个,它控制输出的随机性。做Agent任务的时候我会把温度调低,0.2到0.4之间比较合适。Agent任务要求稳定性和准确性,不需要太多的发散创造。如果温度调太高,模型可能在工具选择上变得不稳定,同一个问题这次选这个工具,下次换个工具,体验很糟糕。
最大生成长度同样重要。模型生成工具调用指令本身需要占用token,如果你把上限设得太小,模型可能在生成参数的过程中被截断,导致JSON格式不完整。我建议至少给到1024以上,如果工具描述比较长,还要再往上加。
另外,企业级应用里还需要考虑超时和重试。API接口地址偶发网络波动是常态,我一般会在模型调用层加三秒超时和最多三次重试,重试之间间隔两秒。别小看这两秒钟的间隔,接口如果是一时拥堵,立刻重试通常还是会失败,等两秒反而更合理。同时加指数退避,重试一次加长一点间隔,更稳妥。
5.2 提示词在Agent里的特殊地位
Agent系统里的提示词和聊天机器人的提示词写法有些不同。聊天机器人可能只需要设定人设和语气;Agent的系统提示词还承担着“工作流指引”的功能。
我比较常用的系统提示词模板是这样的:
你是一个能调用工具的智能助手。当用户需要实时信息或精确计算时,请调用合适的工具。 请一步一步分析问题。工具调用结果返回后,请根据真实结果组织最终回答。 不要编造工具的结果,不要假设工具一定能成功。里面有三件事很重要:告诉模型可以使用工具,指引模型在工具结果返回后把结果整理成答案,特别强调“不要编造工具的结果”。最后这条尤为关键。模型有时会在工具调用失败后依然强行编造一个答案,明明工具没返回数据,它却能“一本正经地胡说八道”。系统提示词里明确禁止这一点,能极大地减少幻觉问题。
5.3 流式输出和非流式输出的取舍
初学阶段我强烈建议先用非流式输出。原因特别简单:非流式是你拿到完整响应再做解析,逻辑简单直接;流式输出需要一边接收增量内容一边处理,工具调用指令要等待整个流传输完才能解析,复杂得多。
等最小闭环跑通之后,再考虑把“最终答案”那一步改成流式输出,而工具调用那一步保持非流式。这种混合模式在实际项目里非常常见。理由也直观:工具调用指令必须是一个完整的JSON结构,流式解析很容易拿到半个JSON;而最终答案只是自然语言文本,流式能让用户体验好很多。
5.4 并发和上下文长度问题
再往后一点,你会遇到并发问题。多个用户同时使用同一个Agent,模型API有并发限制,工具执行也可能有耗时操作。在没有并发控制的前提下,直接多线程调用会导致请求排队甚至超时。
我处理这个问题的方式是给模型调用层加一个信号量限制并发数,系统里同时进行的模型请求最多不超过五个,多余的在代码层面排队等待。这个方案简单可靠,足够应对绝大多数中小型场景。
上下文超长的问题也是必然会撞上的。对话轮次一多,消息列表越来越大,最终会超过模型API的上下文窗口上限。这个问题的工程解法是预设一个最大消息轮数,超过之后自动丢弃最早的消息,保留最近的内容。注意被丢弃的消息可能影响后续对话的连贯性。更高级的做法是把早期消息做摘要总结,把整段内容压缩成一段短的文本放进上下文。对这个阶段来说,简单截断已经足够。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
这里把初学者最容易遇到的问题整理成一张速查表,方便后续排查。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 模型从不调用工具,只会硬答 | 工具描述不清晰,或系统提示词没引导使用工具 | 重写工具描述,把适用范围写明确;在系统提示词里增加“实时信息必须调用工具” |
| 模型调用不存在的工具名 | 工具注册表没同步,或模型幻觉 | 检查registry.get_schemas()返回是否包含所有已定义工具;增加“只使用提供的工具”提示 |
| 工具参数JSON解析失败 | 模型生成的参数被截断或格式不完整 | 加大max_tokens;对参数解析增加异常兜底,解析失败时反馈给模型重新生成 |
| 工具结果返回后模型视而不见 | 消息列表里工具结果的角色或ID对应关系错误 | 检查工具消息的role是否为tool,tool_call_id是否正确对应 |
| 同一步反复调用同一个工具,陷入死循环 | 终止条件失灵或模型没理解结果 | 把当前上下文检查一遍是否真的追加了工具结果;检查是不是max_iterations没生效 |
| 上下文超长报错 | 消息列表只增不减 | 做历史消息截断或摘要压缩 |
| API偶发超时导致整个任务失败 | 没做重试和超时控制 | 在模型调用层加超时和指数退避重试 |
6.2 排查工具调用问题的方法论
常见的排查技巧里,最有效的其实是环境变量里的调试标注。调试模式会打印完整请求和响应,能直接用肉眼看到模型返回的原始数据结构。我发现大部分工具调用问题都是出在“以为模型返回了某种结构,实际上协议接口返回了另一种结构”这类差别上。
排查一个Agent问题,我的习惯顺序是这样:先看消息列表全貌,确认历史是否正确;再看模型原始返回值,确认tool_calls结构是否正常;然后手动调用工具函数,确认工具本身逻辑没问题;最后单独测试模型层,确认不是接口或网络的问题。
按照这个顺序排查,90%的问题都能定位出来。怕的是没有固定步骤,到处乱试改参数,最后问题在原地转圈,还说不清楚是哪里出的问题。
6.3 几个容易忽视的细节坑
工具函数必须返回能被JSON序列化的数据,如果返回的是自定义对象,序列化会直接报错。所以工具函数的最后一步最好统一做一次字符串转换。
多工具场景下,模型可能同时发起多个工具调用,这要求你的消息回填逻辑必须循环处理,而不是只处理第一个。最初做双工具Demo时,模型经常一次发两个工具调用请求,我没有循环处理,结果只有一个工具被执行。所以工具调用列表的处理一定写成for tool_call in response["tool_calls"]的形式,而不是取第一个就结束。
日志打印的内容要包含时间戳。Agent出问题时需要定位是哪一步卡住的,带了时间戳的日志能更快判断是网络等待超时还是模型生成停顿。
还有一个不太起眼但挺实用的点:工具名称尽量全小写加下划线,不要用大写字母或空格。模型生成工具名的时候一旦大小写不一致,执行引擎就会找不到对应函数,报错看着莫名其妙。
写在最后的个人体会
从只会填提示词到能把整个Agent链路手写出来,这个过程的收获比我想象中大得多。过去用框架写Agent的时候,总觉得自己在“组合积木”,出了问题只能换一块积木试试;自己手写一遍之后,才真正明白每一块积木内部是什么结构、哪根弹簧哪颗钉子在哪。现在再回去看高级框架的文档,很多之前理解不了的设计一下子就能看懂——原来工具调用是这么解析的,原来记忆是这样管理的,原来循环条件是这么控制的。
如果让我给刚准备动手的人一个建议:别一上来就追求大而全,别想着一口气把记忆系统、多工具、流式输出全部做完。先跑通一个只有时间查询工具的最小Agent,把从用户输入到最终答案的全链路吃透。这条路你只要走通一遍,后面所有Agent相关的概念、产品、框架,对你来说都是透明的。你看到的是一个系统,而不再是一个黑盒。这就是手写最大的价值所在。