ARTICLE DETAIL

资讯详情

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

零基础手写AI Agent:从API调用到原理实现

零基础手写AI Agent:从API调用到原理实现 1. 为什么“会调用API”和“懂Agent原理”之间隔着一道鸿沟很多人第一次接触AI Agent都是从调用某个大模型API开始的。写几行Python把用户输入拼进prompt拿到返回结果再根据结果决定下一步做什么——这确实就是一个最朴素的Agent雏形。但问题在于当你用LangChain或者某个现成框架搭出一个能跑的Demo之后你大概率会产生一种错觉我已经会做Agent了。这种错觉在面试或者实际项目里会迅速被击碎。面试官问一句“你的Agent怎么做工具选择的”你可能回答“框架自动处理的”。再问“如果模型返回了不存在的工具名怎么办”你就卡住了。再追问“多轮对话里上下文怎么截断、怎么保留关键信息”你只能说“用框架的memory模块”。这就是“会调用”和“懂原理”之间的真实差距——你用的是别人封装好的抽象层而抽象层下面的东西你一无所知。周瑜的这套“零基础手写AI Agent”路径核心价值就在于把抽象层一层层剥开让你用最原始的方式重新实现一遍。不是让你抛弃框架而是让你在用过框架之后能清楚地知道框架帮你做了什么、为什么这么做、如果不这么做会出什么问题。这就像学编程不能只会用IDE的自动补全还得知道编译器在背后干了什么。这篇文章会沿着“从会调用到懂原理”这条主线把AI Agent的核心机制拆成几个可以动手实现的模块。每个模块我都会先讲清楚“为什么需要它”再给出“最小可运行的手写实现”最后补充“实际项目里容易踩的坑”。适合已经用过至少一种大模型API、想真正搞明白Agent内部运转逻辑的开发者。零基础也能跟但你需要至少能看懂Python代码。2. Agent的本质一个带工具调用能力的循环控制器2.1 剥掉框架外衣后Agent只剩三件事不管你用的是什么框架一个AI Agent在运行时本质上只做三件事接收输入、决定行动、执行行动。这个循环会一直持续直到Agent认为任务完成或者达到某个终止条件。“决定行动”这一步是整个Agent的灵魂。在纯文本对话模型里模型只能输出文字但在Agent场景下我们需要模型输出一个结构化的“行动指令”比如“调用天气查询工具参数是城市北京”。这个结构化输出的过程就是所谓的Function Calling或者Tool Use。手写Agent的第一步就是放弃框架提供的AgentExecutor自己写一个循环。下面是一个最简版本import json def simple_agent_loop(user_input, tools, model_client, max_turns10): messages [{role: user, content: user_input}] for turn in range(max_turns): response model_client.chat(messages, toolstools) if response.type tool_call: tool_name response.tool_name tool_args json.loads(response.tool_args) result tools[tool_name](**tool_args) messages.append({role: assistant, content: response.raw}) messages.append({role: tool, content: str(result)}) else: return response.content return 达到最大轮次限制这段代码不到20行但它已经包含了Agent的核心骨架。框架做的事情无非是在这个骨架上加了错误重试、并行工具调用、流式输出、记忆管理等功能。你先把这个循环跑通后面加什么都是在这个基础上做增量。2.2 工具描述的质量直接决定Agent的智商很多人手写Agent时最容易忽略的一点是工具的描述文本比工具本身的实现更重要。模型是根据你提供的工具描述来决定调用哪个工具的。如果你的描述写得含糊不清模型就会选错工具或者传错参数。举个例子你有一个查询天气的工具和一个查询空气质量指数的工具。如果你把天气工具描述成“获取环境信息”把空气质量工具也描述成“获取环境信息”模型大概率会随机选一个。正确的做法是让描述具有排他性tools [ { name: get_weather, description: 查询指定城市的当前天气状况包括温度、湿度、风力。不包含空气质量数据。, parameters: { city: {type: string, description: 城市名称如北京} } }, { name: get_aqi, description: 查询指定城市的空气质量指数和主要污染物。不包含温度湿度等气象数据。, parameters: { city: {type: string, description: 城市名称如北京} } } ]我在实际项目里做过对比测试同一套工具描述写得粗糙时模型选错工具的概率大约在15%到20%把描述改写成上面这种“包含什么、不包含什么”的格式后选错率降到了3%以下。这个改进成本极低但效果立竿见影。2.3 手写循环时必须处理的三个边界情况自己写Agent循环有三类边界情况是框架帮你处理了但你不知道的第一模型返回了不存在的工具名。有些模型在压力下会“幻觉”出一个工具名。你的代码必须捕获KeyError并给模型返回一个错误信息让它重新选择。直接崩溃是最差的做法。第二工具执行超时或抛异常。工具背后可能是一个HTTP请求可能超时。你需要给工具执行加上超时控制并把异常信息作为工具结果返回给模型让模型决定是重试还是换一个方案。第三模型连续多轮调用同一个工具且参数相同。这说明模型陷入了死循环。你需要在循环里加一个简单的去重检测如果连续两轮的工具调用完全一致就强制中断并返回提示。这三个边界情况处理好了你的手写Agent在稳定性上就不会比框架差太多。3. 从零实现工具调用不用框架怎么让模型“动手”3.1 Function Calling的底层其实就是提示词工程很多人以为Function Calling是模型的原生能力实际上它的底层机制是你在系统提示词里注入了一段关于可用工具的结构化描述模型在训练时见过大量“根据工具描述生成调用参数”的样本所以它能输出符合格式的JSON。这意味着两件事第一你可以不用任何框架纯靠提示词实现工具调用第二如果模型比较弱你可以通过优化提示词来提升调用准确率。下面是一个不依赖任何SDK的纯提示词版本SYSTEM_PROMPT 你是一个可以调用工具的助手。可用工具如下 工具名search_web 描述在互联网上搜索信息 参数{query: 搜索关键词} 工具名calculate 描述执行数学计算 参数{expression: 数学表达式如23*4} 当你需要调用工具时只输出一行JSON格式为 {tool: 工具名, args: {参数对象}} 当你不需要调用工具时直接输出回答文本。然后你在解析模型输出时先尝试用json.loads解析如果成功且包含tool字段就执行工具调用否则当作普通文本返回。这个方案在GPT-3.5级别的模型上就能跑通准确率取决于你的提示词质量和模型能力。3.2 参数校验模型给的参数不一定能用模型输出的工具参数经常会有小问题数字被包在字符串里、布尔值写成了true而不是true、必填参数缺失、参数名拼写错误。如果你直接把json.loads的结果传给工具函数轻则报错重则产生错误的行为。我建议在工具执行前加一层参数校验和清洗def validate_and_clean(tool_schema, raw_args): cleaned {} for param_name, param_spec in tool_schema[parameters].items(): if param_name not in raw_args: if param_spec.get(required, False): raise ValueError(f缺少必填参数: {param_name}) continue value raw_args[param_name] expected_type param_spec[type] if expected_type integer and isinstance(value, str): value int(value) elif expected_type number and isinstance(value, str): value float(value) elif expected_type boolean and isinstance(value, str): value value.lower() true cleaned[param_name] value return cleaned这段代码看起来不起眼但它能挡掉实际项目中大约一半的工具调用失败。尤其是当你的Agent面向普通用户时模型面对各种奇怪的输入参数格式出错的概率会明显上升。3.3 多工具并行调用手写版本怎么做当用户问“北京和上海今天天气怎么样”时理想的Agent应该同时调用两次天气查询工具而不是串行调用。框架通常支持并行工具调用手写版本也可以做到。核心思路是模型在一轮回复中可能返回多个工具调用请求。你需要把模型输出解析成一个列表然后并发执行所有工具最后把所有结果一起返回给模型。import concurrent.futures def execute_tools_parallel(tool_calls, tools): results [] with concurrent.futures.ThreadPoolExecutor(max_workers5) as executor: future_map {} for call in tool_calls: future executor.submit(tools[call[tool]], **call[args]) future_map[future] call for future in concurrent.futures.as_completed(future_map): call future_map[future] try: result future.result(timeout30) except Exception as e: result f工具执行失败: {str(e)} results.append({tool: call[tool], result: result}) return results并行调用能把多工具场景的响应时间从“各工具耗时之和”降到“最慢那个工具的耗时”。在工具涉及网络请求时这个优化效果非常明显。4. 记忆管理让Agent记住上下文而不是简单截断4.1 朴素截断为什么会让Agent变傻最简单的记忆管理就是保留最近N轮对话超出部分直接丢掉。这个方案在简单场景下能用但在Agent场景下会出大问题。原因在于Agent的对话历史里包含了大量的工具调用记录。一轮完整的工具调用可能产生三四条消息用户输入、模型决定调用工具、工具返回结果、模型总结。如果你按消息条数截断很可能把“模型决定调用工具”这条消息保留了但把“工具返回结果”截掉了。模型看到自己说要调用工具但没有结果就会陷入困惑。更合理的做法是按轮次截断把一次完整的“用户输入到模型最终回复”视为一轮保留最近K轮。这样能保证工具调用链的完整性。4.2 用摘要压缩历史信息按轮次截断的问题是如果一轮对话里包含了很长的工具返回结果比如搜索了十篇文章这一轮就会占用大量token。这时候就需要对历史轮次做摘要压缩。我的做法是保留最近2轮完整对话更早的轮次用模型生成一段摘要替换。摘要的提示词大概是这样的SUMMARY_PROMPT 请将以下对话历史压缩成一段简洁的摘要保留关键事实、用户偏好和已完成的行动。 不要遗漏任何用户明确提出的要求。 对话历史 {history} 摘要摘要长度控制在200字以内。这样即使对话进行了20轮历史部分的token占用也能控制在合理范围内。4.3 关键信息提取比摘要更精准的方案摘要方案有个缺点它是有损压缩可能丢掉一些细节。对于需要精确记忆的场景比如用户说“我上次说的那个订单号是12345”摘要可能会把订单号弄丢。更稳妥的方案是维护一个结构化的“关键信息槽位”。在每轮对话结束后用模型提取出值得记住的信息存到一个字典里EXTRACT_PROMPT 从以下对话中提取需要长期记住的关键信息。 以JSON格式输出key是信息类别value是具体内容。 如果没有新的关键信息输出空对象。 对话 {conversation} 关键信息然后在构建模型输入时把关键信息字典序列化后放在系统提示词里。这样既节省token又不会丢失重要细节。5. 错误处理与重试Agent稳定性的真正分水岭5.1 模型输出格式错误的分类处理手写Agent时模型输出格式错误是最常见的失败原因。错误可以分成几类每类需要不同的处理策略错误类型典型表现处理策略JSON解析失败输出包含多余文字或格式不对用正则提取JSON部分重试工具名不存在调用了未定义的工具返回错误信息让模型重选参数缺失必填参数没给返回缺失字段让模型补充参数类型错误字符串传给了数字参数自动类型转换后重试工具执行异常网络超时、API报错返回异常信息让模型决策关键原则是任何错误都不要直接抛给用户而是作为工具结果返回给模型让模型有机会自我修正。这就像给Agent加了一层“容错缓冲”大部分格式错误模型在第二轮就能自己改对。5.2 重试次数和退避策略重试不能无限进行。我的经验值是单个工具调用最多重试2次整个Agent循环最多15轮。超过限制就返回一个友好的错误提示。对于工具执行失败的重试建议加一个简单的退避第一次失败后等1秒重试第二次失败后等3秒。如果是网络类工具这个退避能显著提升成功率。import time def retry_tool_call(tool_func, args, max_retries2): for attempt in range(max_retries 1): try: return tool_func(**args) except Exception as e: if attempt max_retries: return f工具执行失败已重试{max_retries}次: {str(e)} time.sleep(2 ** attempt)5.3 日志记录排查Agent问题的唯一依靠Agent出问题时你面对的是一个多轮对话加多次工具调用的复杂链路。没有详细的日志你根本不知道是哪一步出了问题。我建议在Agent循环的每个关键节点都打日志模型输入、模型原始输出、解析后的工具调用、工具执行结果、最终回复。日志用结构化格式JSON Lines方便后续检索和分析。import logging import json logger logging.getLogger(agent) def log_step(step_type, data): logger.info(json.dumps({step: step_type, data: data}, ensure_asciiFalse))在实际排查中我经常发现问题的根源是某次工具返回了超长的结果把上下文撑爆了导致模型后续输出质量下降。这种问题没有日志根本发现不了。6. 从手写Demo到可用Agent还差哪些工程化改造6.1 流式输出用户体验的关键提升手写Agent跑通之后第一个要加的工程化能力就是流式输出。用户不希望等10秒才看到第一个字。流式输出需要你在模型客户端层面支持SSEServer-Sent Events然后在Agent循环里把模型的文本增量实时推送给前端。难点在于当模型决定调用工具时流式输出会先输出一段工具调用的JSON片段。你需要在前端做判断如果是工具调用就显示“正在查询...”的提示如果是普通文本就逐字显示。6.2 超时控制和并发限制生产环境的Agent必须设置多层超时单次模型调用超时建议30秒、单次工具执行超时建议15秒、整个Agent循环超时建议60秒。任何一层超时都要有优雅的降级处理。并发限制同样重要。如果你的Agent服务同时处理多个用户请求每个请求都可能触发多次模型调用和工具调用。不加限制的话很容易把下游API打挂。建议用信号量或令牌桶做限流。6.3 可观测性知道Agent在干什么除了日志你还需要指标监控每轮对话的平均轮次、工具调用成功率、模型调用延迟分布、token消耗量。这些指标能帮你发现性能瓶颈和异常模式。我习惯在Agent循环里埋几个计数器每次请求结束后上报。比如“本轮对话用了5轮调用了3次工具消耗了2000个token”。积累一段时间后你就能看出哪些类型的用户输入会导致Agent“绕远路”然后针对性优化提示词。7. 手写Agent之后再看框架哪些该用哪些该自己写7.1 框架帮你省掉的其实是脏活累活手写一遍之后你会发现框架的核心价值不在于“实现了Agent循环”这个逻辑本身而在于帮你处理了大量边界情况和工程细节重试、超时、日志、流式、并发、状态管理。这些才是真正耗时的地方。所以我的建议是理解原理用手写生产落地用框架。你先手写一遍知道每个环节可能出什么问题然后在实际项目里用框架但遇到问题时你知道该去框架的哪个模块找原因。7.2 什么情况下应该坚持手写有两种情况我建议坚持手写而不是用框架第一种是对延迟极度敏感的场景。框架的抽象层会带来额外的开销手写版本可以做到更精简的调用链。如果你的Agent需要在200毫秒内响应手写是更好的选择。第二种是需要深度定制的场景。比如你需要实现一种特殊的记忆压缩算法或者需要在工具调用前后插入自定义的权限校验逻辑。框架的扩展点可能不够灵活手写反而更省事。7.3 从手写版本迁移到框架的时机当你发现手写版本的代码里错误处理、日志、重试这些非核心逻辑的代码量已经超过了核心逻辑本身就是时候考虑迁移到框架了。框架把这些东西标准化了你只需要关注业务逻辑。但迁移不是重写。你可以把手写版本里的工具定义、提示词模板、参数校验逻辑直接搬到框架里只把循环控制和错误处理交给框架。这样迁移成本最低也不会丢失你在手写过程中积累的经验。8. 我踩过的几个坑和对应的解法第一个坑是工具描述里的参数名和实际函数参数名不一致。模型按照描述里的参数名生成调用但你的函数签名用的是另一个名字结果就是TypeError。解法很简单工具描述里的参数名必须和函数参数名完全一致最好用自动化测试来校验。第二个坑是模型在长对话中逐渐“忘记”工具的存在。对话轮次多了之后系统提示词里的工具描述被淹没在历史消息里模型开始直接用文本回答而不是调用工具。解法是把工具描述放在每轮消息的最前面或者用模型支持的“工具”字段而不是纯提示词。第三个坑是工具返回结果太长导致上下文爆炸。有一次我接了一个搜索工具返回了整页HTML直接把上下文撑到了模型上限。解法是在工具层面做结果截断和摘要只返回最相关的部分。第四个坑是并发调用时工具之间的状态冲突。两个工具调用同时修改同一个全局变量导致结果不可预测。解法是工具函数尽量设计成无状态的必须共享状态时加锁。这些坑在框架里可能已经被处理了但如果你不知道它们存在遇到问题时就会毫无头绪。手写一遍的价值就在于把这些坑都踩一遍以后用框架时心里有底。9. 进阶方向从单Agent到多Agent协作手写单Agent跑通之后下一步自然是多Agent协作。但我要提醒一句多Agent的复杂度不是线性增长而是指数增长。两个Agent之间的通信协议、任务分配、结果合并每一个环节都可能出问题。我的建议是先把单Agent做到足够稳定再考虑多Agent。如果确实需要多Agent从最简单的“主管-执行者”模式开始一个主管Agent负责拆解任务多个执行者Agent负责执行具体子任务。主管Agent根据执行者返回的结果决定下一步。手写多Agent的关键是设计好Agent之间的消息格式。我通常用一个统一的JSON结构{ from: agent_name, to: agent_name, type: task|result|query, content: ..., metadata: {} }这个格式简单但够用。所有Agent都按照这个格式收发消息通信层就不容易出乱子。最后分享一个我在实际项目里验证过的经验Agent的能力上限不取决于模型有多强而取决于工具设计得有多好。一个中等能力的模型配上精心设计的工具表现往往超过一个强模型配上粗糙的工具。所以与其花时间调模型参数不如多花时间打磨工具的描述、参数和返回格式。这个投入产出比是最高的。
返回列表