最近几年“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传递数据的核心管道。多个模块不直接互相调用而是通过往消息列表里追加内容来实现数据的流动。用户输入是一条roleuser的消息模型普通回复是roleassistant的消息模型决定调用工具时返回roleassistant的特殊消息里面包含工具调用指令代码执行完某个工具之后我们把结果封装成roletool的消息追加到列表里控制循环再次把更新后的完整列表发给模型模型看到工具执行结果继续判断下一步。这里有个细节很关键每一次循环发给模型的是整个消息列表而不是只发最新一条。模型本身没有记忆它的记忆就在你给它看的上下文里。如果你只发最新一条工具结果模型完全不知道前面在聊什么整个任务必然跑偏。我自己最开始在这上面吃过亏当时只把最后一次工具结果发给模型模型完全无视了之前的对话背景回答得驴唇不对马嘴。3.3 模块间接口怎么约定因为模块要频繁交换数据接口定得规不规范直接决定开发体验。我制定的约定是这样的。工具注册表里每个工具包含四个字段name是工具名全小写加下划线比如get_current_timedescription是给模型看的自然语言描述要写得详细parameters是JSON Schema格式的参数说明handler是实际执行该工具的函数对象。模型层返回的统一响应里包含两类字段content是普通文本消息可能是空的也可能有内容tool_calls是工具调用列表如果模型决定不调用工具这个列表就是空的。控制循环只看这两个字段就能判断当前该干什么。这种约定方式让新增一个工具变得非常轻松写一个普通的Python函数在注册表里加一条记录就完成了后续完全不用改其他模块。我后来给系统加了个记事本工具整个工作量不到十分钟其中大部分时间还是在写函数逻辑。4. 核心代码实现五步搭出最小Agent4.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_asciiFalse) except Exception as e: return f工具执行出错{str(e)} registry ToolRegistry()使用这个注册表的时候非常爽定义和注册放在同一个位置维护起来毫不费劲。import time registry.register( nameget_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密钥, modeldefault-chat-model) for step in range(max_iterations): response llm.chat(messages, toolsregistry.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是否为tooltool_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相关的概念、产品、框架对你来说都是透明的。你看到的是一个系统而不再是一个黑盒。这就是手写最大的价值所在。