ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

零基础手写AI Agent:从原理到实践,掌握核心设计决策

零基础手写AI Agent:从原理到实践,掌握核心设计决策 1. 为什么“手写一遍”才是AI Agent进阶的真正分水岭1.1 从“会调用”到“懂原理”的鸿沟在哪里很多人接触AI Agent的路径都差不多先拿现成框架跑一个Demo用LangChain或者某个低代码平台拖几个节点接上大模型看到它能回答问题、能调用工具就觉得“我会了”。但真到了要改行为、调性能、排查诡异Bug的时候立刻抓瞎。问题出在哪儿出在你只看到了Agent的“壳”没摸过它的“骨”。所谓“会调用”本质上是把Agent当成一个黑盒API来用。你输入一段提示词它返回一个结果中间发生了什么你并不清楚。而“懂原理”要求你能回答这些问题Agent的决策循环是怎么转起来的工具调用的参数是怎么从自然语言里被解析出来的多轮对话中上下文是怎么被裁剪和拼接的如果模型返回了格式不对的内容你的代码在哪一层兜底这些问题的答案只有在你亲手写过一遍之后才会真正浮现。手写不是目的手写是手段——它逼着你面对每一个被框架封装掉的细节逼着你做选择而每一次选择都会让你对Agent的理解加深一层。1.2 为什么选“零基础”作为起点反而更高效这里说的“零基础”不是指你完全不懂编程而是指你不需要先成为大模型专家、不需要先啃完Transformer论文、不需要先精通某个框架。你只需要会基本的Python语法知道什么是函数、什么是字典、怎么发一个HTTP请求就可以开始手写一个最小可用的Agent。为什么这样反而高效因为从零手写的路径是“需求驱动”的。你遇到一个问题解决一个问题每个知识点都是在具体场景中被消化的。比如你不知道怎么让模型输出结构化数据那就去学JSON Schema和函数调用你不知道怎么管理多轮对话那就去设计一个消息队列。这种学习方式比“先系统学完再动手”要快得多也扎实得多。我见过太多人卡在“准备阶段”——花几周时间看教程、搭环境、选框架结果真正动手写第一行Agent代码的时候前面学的东西已经忘了一半。零基础手写的核心逻辑是先用最小成本跑通一个闭环再在这个闭环上不断加东西。这个闭环哪怕只有“用户输入→模型→输出”三个环节它也是一个完整的Agent雏形。1.3 手写Agent的最小闭环应该长什么样一个最简化的AI Agent核心就是一个循环接收输入→构造提示→调用模型→解析输出→判断是否需要调用工具→执行工具→把结果塞回上下文→再次调用模型→直到模型给出最终答案。这个循环里最关键的三个模块是提示构造器、输出解析器、工具执行器。提示构造器负责把系统指令、历史对话、可用工具列表拼成模型能理解的格式输出解析器负责从模型的自然语言回复中提取出结构化的动作指令工具执行器负责真正执行动作并把结果返回。手写的时候你可以先不接任何真实工具就用一个假的“计算器”工具来测试整个循环。比如用户问“3加5等于多少”模型应该输出一个JSON说“我要调用计算器参数是3和5”你的解析器读到这个JSON调用本地的加法函数把结果8塞回上下文模型再输出“答案是8”。这个流程跑通了你就理解了Agent最核心的运转逻辑。注意不要一上来就追求多工具、多轮、多Agent协作。先把单工具单轮跑通再逐步加复杂度。很多教程一上来就讲多Agent编排结果初学者连一个工具调用都没跑明白最后只能复制粘贴代码什么都没学到。2. 手写Agent前必须想清楚的四个设计决策2.1 模型选型不是越强越好而是越合适越好手写Agent的第一步是选模型。这里有一个常见的误区很多人觉得一定要用最强的模型才能跑出好效果。实际上对于学习目的来说一个中等能力但响应速度快、API稳定的模型反而更合适。原因很简单你在调试阶段需要反复调用模型如果每次调用都要等十几秒甚至更久调试效率会极低。选模型的时候要考虑三个维度指令遵循能力、结构化输出能力、调用成本。指令遵循能力决定了模型能不能按照你设定的格式输出结构化输出能力决定了它能不能稳定地生成JSON调用成本决定了你调试时敢不敢频繁调用。对于零基础手写阶段我的建议是先用一个轻量级模型跑通流程等逻辑稳定了再换更强的模型对比效果。这样你能清楚地看到模型能力对Agent行为的影响而不是把所有问题都归咎于“模型不行”。2.2 提示设计系统指令怎么写才不让模型“跑偏”系统指令是Agent的“宪法”它定义了Agent的角色、能力边界、输出格式和行为准则。写系统指令的时候最容易犯的错误是“写得太少”和“写得太模糊”。写得太少模型就自由发挥输出格式千奇百怪写得太模糊模型就按自己的理解来你觉得它“不听话”其实是你的指令本身就有歧义。一个好的系统指令应该包含这几个部分角色定义你是谁、任务描述你要做什么、工具说明你有什么工具可用、输出格式你必须按什么格式回复、约束条件什么不能做。举个例子如果你希望模型在需要调用工具时输出JSON在不需要调用工具时输出自然语言那你的系统指令里必须明确写出这两种情况的判断标准和各自的输出格式。不要指望模型“猜”你的意图它猜不准。2.3 工具定义怎么让模型知道“什么时候该用哪个工具”工具定义的核心是“描述”。模型选择哪个工具完全依赖于你对工具的描述。描述写得好模型就能在正确的场景选择正确的工具描述写得差模型就会乱选或者不选。一个好的工具描述应该包含工具名称简洁明确、功能说明一句话说清楚这个工具能做什么、参数说明每个参数的类型、含义、是否必填、使用场景什么情况下应该用这个工具。参数说明尤其重要因为模型需要根据你的描述来生成正确的参数值。我试过把工具描述写得非常详细结果模型反而变得犹豫因为它觉得每个工具都“可能”适用。后来我发现工具描述要“精确”而不是“详尽”——精确地告诉模型这个工具解决什么问题而不是把所有可能相关的场景都列上去。2.4 上下文管理多轮对话怎么不“失忆”又不“爆token”多轮对话是Agent的基本能力但也是新手最容易踩坑的地方。最直接的做法是把所有历史消息都塞进上下文但这样很快就会超出模型的token限制。你需要一个策略来决定哪些消息保留、哪些丢弃、哪些压缩。常见的策略有三种滑动窗口只保留最近N轮、摘要压缩把旧对话总结成一段话、关键信息提取只保留工具调用结果和最终答案。滑动窗口最简单但可能丢失重要信息摘要压缩需要额外调用模型增加成本关键信息提取最精准但实现起来最复杂。对于手写阶段我建议先用滑动窗口把最近5到10轮对话保留下来。等你的Agent逻辑稳定了再尝试更复杂的策略。记住一个原则上下文管理的目标不是“记住所有东西”而是“让模型在需要的时候能拿到需要的信息”。3. 从零手写一个Agent的完整实操流程3.1 环境准备与最小依赖安装手写Agent不需要复杂的开发环境。一台能跑Python的电脑、一个代码编辑器、一个模型API的访问权限就够了。依赖方面只需要安装HTTP请求库和JSON处理库Python标准库里的json和urllib就能满足基本需求如果你想更方便一点可以装requests。pip install requests不需要装LangChain不需要装任何Agent框架。你要做的就是自己写一个HTTP请求把提示词发给模型然后处理返回结果。这个过程会让你对API的调用方式、返回格式、错误处理有最直接的认识。我建议在项目根目录下建三个文件agent.py主逻辑、tools.py工具定义、prompts.py提示词模板。这样结构清晰后面加功能的时候不会乱。3.2 第一步跑通“模型调用”这个最基础的环节在写Agent之前先确保你能成功调用模型并拿到返回结果。这一步看起来简单但很多人在这里就会遇到问题API Key怎么配、请求格式怎么写、返回结果怎么解析。import requests import json def call_model(messages, modelyour-model-name): headers { Content-Type: application/json, Authorization: fBearer {API_KEY} } payload { model: model, messages: messages, temperature: 0.1 } response requests.post(API_URL, headersheaders, jsonpayload) result response.json() return result[choices][0][message][content]这段代码的关键点在于temperature参数。对于Agent场景我建议把temperature设低一点0.1到0.3之间因为你需要模型稳定地按照格式输出而不是发挥创造力。温度太高模型就会“自由发挥”输出格式就会不稳定。提示调试阶段一定要把每次请求和返回都打印出来存到日志文件里。后面排查问题的时候这些日志就是你的“黑匣子”。3.3 第二步设计Agent的“大脑”——系统提示词模板系统提示词是Agent的灵魂。我一般会把它写成一个模板函数方便动态插入工具列表和上下文信息。SYSTEM_PROMPT_TEMPLATE 你是一个智能助手可以使用以下工具来帮助用户解决问题。 可用工具 {tools_description} 当你需要使用工具时请严格按照以下JSON格式输出 {{action: tool_name, parameters: {{param1: value1}}}} 当你不需要使用工具或者已经获得足够信息可以回答用户时请直接输出自然语言回复。 注意事项 1. 每次只能调用一个工具 2. 工具参数必须符合工具定义中的类型要求 3. 如果工具返回错误请根据错误信息调整参数后重试 这个模板里{tools_description}是动态生成的工具列表。每次调用模型之前你把当前可用的工具描述填进去模型就知道自己有哪些能力。写系统提示词的时候有一个技巧把最重要的约束放在最前面和最后面。模型对开头和结尾的内容注意力更强中间的约束容易被忽略。比如“每次只能调用一个工具”这条约束如果放在中间模型可能会一次输出多个工具调用。3.4 第三步实现工具注册与执行机制工具注册机制的核心是“描述与实现分离”。你用一个字典来存储工具的描述和对应的函数模型只看到描述你的代码根据模型输出的工具名来找到对应的函数并执行。TOOLS {} def register_tool(name, description, parameters, func): TOOLS[name] { description: description, parameters: parameters, function: func } def execute_tool(name, params): if name not in TOOLS: return {error: f工具 {name} 不存在} try: result TOOLS[name][function](**params) return {result: result} except Exception as e: return {error: str(e)}注册一个计算器工具的例子def add(a, b): return a b register_tool( namecalculator, description执行两个数字的加法运算, parameters{a: number, b: number}, funcadd )这里的关键是execute_tool里的异常处理。工具执行失败是常态模型可能传错参数类型可能传了不存在的参数名可能参数值超出范围。你的代码必须能捕获这些错误并把错误信息返回给模型让模型有机会修正。3.5 第四步实现输出解析器——从自然语言里“抠”出结构化指令输出解析器是手写Agent里最考验功力的部分。模型返回的是一段文本你需要判断这段文本是“工具调用指令”还是“最终答案”如果是工具调用指令还要把JSON解析出来。def parse_output(text): text text.strip() if text.startswith({) and text.endswith(}): try: data json.loads(text) if action in data: return {type: tool_call, action: data[action], parameters: data.get(parameters, {})} except json.JSONDecodeError: pass return {type: final_answer, content: text}这个解析器的逻辑很简单如果模型输出的是一个大括号包裹的JSON并且里面有action字段就认为是工具调用否则就认为是最终答案。但实际使用中模型经常会输出一些“脏”数据比如JSON外面包了一层markdown代码块标记或者在JSON前后加了一些解释性文字。你需要在解析之前做一层清洗def clean_output(text): text text.strip() if text.startswith(): lines text.split(\n) text \n.join(lines[1:-1]) return text.strip()注意不要试图用正则表达式去“完美”解析模型的输出。模型的输出格式是概率性的你永远无法覆盖所有情况。正确的做法是解析成功就执行解析失败就把原始输出返回给模型让它重新按格式输出。3.6 第五步组装Agent主循环把前面几个模块串起来就是Agent的主循环def run_agent(user_input, max_turns10): messages [ {role: system, content: build_system_prompt()}, {role: user, content: user_input} ] for turn in range(max_turns): response call_model(messages) parsed parse_output(clean_output(response)) if parsed[type] final_answer: return parsed[content] if parsed[type] tool_call: tool_result execute_tool(parsed[action], parsed[parameters]) messages.append({role: assistant, content: response}) messages.append({role: user, content: f工具执行结果{json.dumps(tool_result, ensure_asciiFalse)}}) return 达到最大轮次限制未能完成任务这个循环里max_turns是一个重要的安全阀。没有这个限制模型可能会陷入无限循环——调用工具、得到结果、再调用同一个工具、再得到同样的结果。设置一个合理的上限比如10轮可以防止这种情况。3.7 第六步加入上下文裁剪防止token溢出当对话轮次增多消息列表会越来越长。你需要在每次调用模型之前对消息列表做裁剪。def trim_messages(messages, max_messages20): if len(messages) max_messages: return messages system_msg messages[0] recent messages[-(max_messages-1):] return [system_msg] recent这个简单的滑动窗口策略保留了系统提示和最近的消息。对于大多数场景这已经够用了。如果你需要保留更早的关键信息可以在裁剪之前把旧消息里的工具调用结果提取出来压缩成一段摘要。4. 手写Agent过程中最容易踩的五个坑4.1 模型不按格式输出解析器频繁失败这是新手遇到的第一个大坑。你明明在系统提示里写了“必须输出JSON”但模型就是会输出一段自然语言或者在JSON外面加解释。原因通常有两个一是系统提示里的格式说明不够明确二是temperature设得太高。解决办法在系统提示里给出具体的输出示例让模型“照着抄”。比如正确输出示例 {action: calculator, parameters: {a: 3, b: 5}} 错误输出示例不要这样输出 我需要使用计算器来计算3加5参数是a3, b5给出正反示例之后模型的格式遵循率会大幅提升。另外把temperature降到0.1以下也能显著改善格式稳定性。4.2 工具参数类型错误执行时报异常模型生成的参数值经常是字符串类型但你的工具函数期望的是数字。比如模型输出{a: 3, b: 5}你的加法函数收到的是字符串执行3 5得到的是35而不是8。解决办法在工具执行器里做类型转换。根据工具定义中的参数类型把模型传来的值转换成正确的类型。def convert_params(params, param_types): converted {} for key, value in params.items(): expected_type param_types.get(key) if expected_type number: converted[key] float(value) elif expected_type integer: converted[key] int(value) else: converted[key] value return converted这个转换逻辑要放在execute_tool里面在调用实际函数之前执行。4.3 多轮对话后模型“忘记”了之前的工具调用结果模型本身是无状态的它之所以能“记住”之前的对话完全是因为你把历史消息都传给了它。如果你在裁剪上下文的时候把工具调用结果裁掉了模型就会“失忆”。解决办法在裁剪上下文的时候优先保留包含工具调用结果的消息。你可以给消息打标签标记哪些是“关键消息”裁剪时优先保留。def trim_messages_with_priority(messages, max_messages20): if len(messages) max_messages: return messages system_msg messages[0] important [m for m in messages[1:] if m.get(important)] normal [m for m in messages[1:] if not m.get(important)] remaining max_messages - 1 - len(important) if remaining 0: recent_normal normal[-remaining:] else: recent_normal [] return [system_msg] important recent_normal4.4 工具执行超时或卡死整个Agent挂起如果你的工具涉及网络请求或者耗时操作一定要设置超时。没有超时机制一个卡住的工具调用会让整个Agent无限等待。import signal def execute_tool_with_timeout(name, params, timeout10): def handler(signum, frame): raise TimeoutError(f工具 {name} 执行超时) signal.signal(signal.SIGALRM, handler) signal.alarm(timeout) try: result execute_tool(name, params) signal.alarm(0) return result except TimeoutError as e: return {error: str(e)}提示signal.alarm只在Unix系统上有效。如果你在Windows上开发可以用threading模块来实现超时控制。4.5 模型陷入“调用工具→得到结果→再调用同一个工具”的死循环这种情况通常发生在工具返回的结果不符合模型预期时。模型觉得“这个结果不对我再试一次”然后传了同样的参数得到同样的结果再试一次。解决办法在工具执行器里记录每个工具的调用次数如果同一个工具用相同参数调用了超过2次就直接返回一个错误信息告诉模型“你已经用相同参数调用过这个工具了请换一种方式或者直接回答用户”。call_history {} def execute_tool_with_dedup(name, params): key f{name}:{json.dumps(params, sort_keysTrue)} call_history[key] call_history.get(key, 0) 1 if call_history[key] 2: return {error: 重复调用相同工具和参数请尝试其他方法或直接回答} return execute_tool(name, params)这个简单的去重机制能解决大部分死循环问题。5. 从手写Demo到可用Agent的进阶方向5.1 加入流式输出提升交互体验手写Demo跑通之后第一个值得加的改进是流式输出。现在的模型API基本都支持流式返回你可以逐字接收模型的输出而不是等它全部生成完再显示。这对于提升用户体验非常明显尤其是当模型需要生成较长回复的时候。实现流式输出的关键是把HTTP请求的stream参数设为True然后逐行读取响应。需要注意的是流式模式下工具调用的JSON可能会被分成多个片段返回你需要在接收完所有片段之后再解析。5.2 支持多工具并行调用当你的Agent需要同时查询多个数据源时串行调用工具会很慢。你可以让模型一次输出多个工具调用指令然后并行执行这些工具。实现方式是修改系统提示允许模型输出一个工具调用数组然后你的执行器用线程池并行执行这些工具最后把所有结果一起返回给模型。5.3 加入记忆机制让Agent跨会话记住用户偏好手写Demo的上下文只在单次会话内有效会话结束就丢了。如果你希望Agent记住用户的偏好就需要一个持久化的记忆层。最简单的做法是用一个JSON文件或者SQLite数据库把用户的关键信息存下来每次会话开始时加载到系统提示里。记忆机制的设计要点是存什么、什么时候存、什么时候读。我的经验是只存“事实性信息”比如用户的名字、偏好、常用参数不存“过程性信息”比如上次对话的中间步骤。事实性信息可以跨会话复用过程性信息只在当前会话有意义。5.4 加入评估机制量化Agent的表现手写Agent的一个优势是你完全掌控了代码可以很方便地加入评估逻辑。你可以准备一组测试用例每个用例包含输入和期望的输出或期望的工具调用序列然后自动运行这些用例统计成功率。评估机制的价值在于当你修改系统提示或调整工具描述时你可以量化地看到改动的影响而不是凭感觉判断“好像变好了”。6. 一些个人体会和后续扩展思路手写Agent这件事最大的收获不是写出了一个能跑的代码而是在这个过程中被迫理解了每一个环节的细节。用框架的时候遇到问题你只能去翻文档、搜Issue、猜原因手写的时候遇到问题你可以直接看代码、加日志、改逻辑。这种掌控感是框架给不了的。我个人的经验是手写一遍之后再用框架你会用得更明白。你知道框架帮你做了什么也知道框架在哪些地方做了取舍。当框架的行为不符合预期时你知道该去哪个层面找原因。后续扩展的话可以从这几个方向入手一是加入更复杂的工具比如数据库查询、文件操作、API调用二是尝试不同的提示策略比如Few-shot、Chain-of-Thought三是把Agent部署成一个HTTP服务让其他程序也能调用。每一步扩展都会让你对Agent的理解更深一层。最后分享一个小技巧在开发阶段把每次模型调用的完整请求和响应都存到一个日志文件里。当你觉得Agent“行为诡异”的时候翻日志比调试代码更快找到原因。这个习惯我保持了很长时间帮我省下了大量排查时间。
返回列表