1. 为什么我劝你手写一个 AI Agent而不是直接调 API先说一个我观察到的现象现在市面上讲 AI Agent 的内容九成都在教你怎么用现成框架——拖几个节点、连几条线、填个 API Key一个“智能体”就跑起来了。这没错能快速出活。但问题在于一旦线上出了诡异问题比如工具调用死循环、上下文突然爆炸、多轮对话后模型开始胡言乱语你打开框架源码一看层层封装根本不知道从哪下手。我自己就踩过这个坑。早些年用某个主流 Agent 框架做一个客服场景测试环境一切正常上线第二天开始间歇性抽风——同一个用户问题有时候走工具查询有时候直接编答案。排查了整整两天最后发现是框架内部对工具描述做了自动截断导致模型在某些上下文长度下“看不见”那个工具。这种事你不手写一遍永远不知道底层发生了什么。所以这篇内容的核心思路很明确用最朴素的方式从零手写一个最小可用的 AI Agent不依赖任何重型框架把“会调用”变成“懂原理”。适合两类人一是刚入门 AI 应用开发、被各种框架名词绕晕的新手二是已经会用框架、但想搞清楚内部机制的进阶者。读完你至少能自己实现一个带工具调用、多轮记忆、错误重试的 Agent 骨架并且知道每一行代码为什么这么写。2. 手写之前先把 AI Agent 的骨架拆干净2.1 Agent 到底是什么一个循环加三样东西很多人把 Agent 想复杂了。剥掉所有包装一个 Agent 本质上就是一个循环循环里做三件事感知输入、决策下一步、执行动作。用大白话说就是“看情况、想一下、动手做”然后重复直到任务完成。那“三样东西”是什么第一是大模型负责“想”第二是工具集负责“做”第三是记忆负责“记住之前发生了什么”。这三样缺一不可。没有模型Agent 没有决策能力没有工具Agent 只能空谈没有记忆Agent 每轮都从零开始多轮任务直接崩。我见过不少新手一上来就研究 LangGraph 的状态图、AutoGPT 的任务分解结果连最基本的“模型怎么决定调用哪个工具”都没搞明白。这就像学编程先背设计模式却不会写 for 循环。手写的意义就在于你被迫面对每一个细节工具描述怎么给模型看、模型返回的调用请求怎么解析、执行结果怎么塞回上下文。2.2 为什么选“手写”而不是“调框架”框架当然有它的价值生产环境该用还是得用。但学习阶段框架是毒药。原因有三黑盒太多框架帮你做了 prompt 拼接、工具路由、上下文裁剪你不知道它怎么做的出了问题只能猜。抽象泄漏所有框架都有抽象泄漏用久了总会遇到“框架行为不符合预期”的时刻这时候底层知识就是你的救命稻草。调试困难手写代码你可以随时 print 中间状态框架里加个日志都得翻文档。我的建议是先手写一个 200 行左右的版本跑通完整流程再去看框架源码。你会发现框架做的事你全做过只是它做得更工程化。这个顺序不能反。2.3 最小可用 Agent 的四个模块我手写的版本拆成四个模块每个模块职责单一模块职责关键点模型接口层封装大模型调用统一输入输出格式方便换模型工具注册层管理可用工具工具描述要清晰参数要校验记忆管理层维护对话历史控制上下文长度避免爆炸循环控制层驱动 Agent 运转处理终止条件、错误重试这四个模块加起来不到 200 行 Python但麻雀虽小五脏俱全。下面逐个拆。3. 核心模块逐个手撕从模型接口到循环控制3.1 模型接口层别让模型调用散落各处模型接口层的作用是把“调用大模型”这件事封装成一个函数输入是消息列表输出是模型的回复。为什么要封装因为你需要换模型。今天用这个明天可能换那个如果调用代码散落在十几个地方换一次模型改到吐血。我用的接口长这样def call_llm(messages, toolsNone): # messages: [{role: user, content: ...}] # tools: 工具描述列表可选 response client.chat.completions.create( modelyour-model, messagesmessages, toolstools, temperature0 ) return response.choices[0].message这里有个关键决策temperature 设成 0。Agent 场景下你需要的是稳定、可复现的决策不是创意。温度高了同一个输入可能这次调工具 A下次调工具 B调试起来会疯。我实测下来Agent 场景温度超过 0.3 就开始不稳定0 是最稳的。另一个细节是tools参数。不同模型厂商的工具描述格式不一样有的用 JSON Schema有的用特定字段。接口层要做的就是抹平这个差异让上层代码不用关心底层用的是哪家模型。我一般会写一个适配函数把统一的工具描述转成各家格式。注意模型接口层一定要加超时和重试。大模型调用偶尔抽风是常态没有重试机制你的 Agent 会在网络抖动时直接挂掉。我一般设 30 秒超时重试 2 次指数退避。3.2 工具注册层工具描述写得好模型调用错不了工具注册层是很多人忽略的重灾区。新手往往随便写个函数就注册进去结果模型要么不调用要么调用时参数传错。问题出在工具描述上。模型决定调用哪个工具完全依赖你给的描述。描述写得含糊模型就瞎猜。我总结了一个工具描述的三要素模板功能说明一句话说清这个工具干什么用模型能理解的自然语言。参数说明每个参数的类型、含义、是否必填越具体越好。使用场景什么情况下该用这个工具什么情况下不该用。举个例子一个查询天气的工具描述可以这样写{ name: get_weather, description: 查询指定城市的当前天气。当用户询问天气、气温、是否下雨等问题时使用此工具。不要用于查询历史天气或未来预报。, parameters: { type: object, properties: { city: { type: string, description: 城市名称如北京、上海不要带市字 } }, required: [city] } }注意最后那句“不要用于查询历史天气或未来预报”——这就是使用场景的边界说明。没有这句话模型可能在用户问“明天天气”时也调用这个工具然后返回错误结果。我踩过这个坑加了边界说明后误调用率明显下降。工具注册层还要做参数校验。模型返回的参数不一定符合你的预期可能缺字段、类型不对、甚至多传字段。执行工具前必须校验校验失败就返回错误信息给模型让它重新决策。这比直接抛异常优雅得多。3.3 记忆管理层上下文不是越多越好记忆管理是 Agent 最容易出问题的地方。新手常犯的错是把所有对话历史一股脑塞给模型结果上下文越来越长token 费用飙升模型还开始“失忆”——因为关键信息被淹没在大量无关内容里。我的做法是分层记忆短期记忆最近 N 轮对话完整保留。长期记忆更早的对话压缩成摘要。工具记忆工具调用和结果只保留最近几次避免重复调用。具体实现上我设一个 token 阈值比如 4000 token。超过阈值就把最早的几轮对话拿出来让模型总结成一段话替换掉原始对话。这样上下文长度可控关键信息也不丢。def manage_memory(messages, max_tokens4000): if count_tokens(messages) max_tokens: return messages # 保留最近 5 轮更早的做摘要 recent messages[-10:] older messages[:-10] summary summarize(older) return [{role: system, content: f之前的对话摘要{summary}}] recent这里有个经验摘要要保留实体和决策。比如“用户想查北京天气已调用 get_weather 返回晴”而不是“用户问了天气相关的问题”。前者模型能接着用后者等于没说。提示工具调用的结果不要全部保留。比如查询数据库返回了 100 行数据塞进上下文纯属浪费。我一般只保留前几行加一个“共 N 条”的说明模型需要更多会再调工具。3.4 循环控制层Agent 的心脏循环控制层是整个 Agent 的心脏负责驱动“想-做-看”的循环。核心逻辑是一个 while 循环每轮做四件事把当前消息列表发给模型。检查模型返回是普通回复还是工具调用请求。如果是工具调用执行工具把结果追加到消息列表继续循环。如果是普通回复输出给用户结束循环。伪代码大概这样def run_agent(user_input, max_turns10): messages [{role: user, content: user_input}] for turn in range(max_turns): response call_llm(messages, toolstool_schemas) if response.tool_calls: for call in response.tool_calls: result execute_tool(call.name, call.arguments) messages.append({role: tool, content: result}) else: return response.content return 达到最大轮次任务未完成这里有几个关键参数max_turns最大循环轮次防止死循环。我一般设 10复杂任务可以放宽到 20。终止条件模型返回普通文本就终止。但要注意模型可能返回“我需要更多信息”这种这时候要判断是继续问用户还是继续循环。错误处理工具执行失败时把错误信息作为工具结果返回给模型让它决定下一步。不要直接抛异常终止。我踩过的一个坑是模型有时候会连续调用同一个工具参数还一样。这是典型的死循环前兆。我的处理方式是加一个重复调用检测如果连续两次调用相同工具且参数相同就强制终止并返回提示。4. 完整实操从零跑通一个能查天气和算数的 Agent4.1 环境准备与依赖选择先说环境。Python 3.10 以上这是底线。依赖就三个openai或对应厂商的 SDK、pydantic参数校验、tiktokentoken 计数。不需要 LangChain不需要任何 Agent 框架。pip install openai pydantic tiktoken为什么不用 LangChain因为我们要手写用了框架就失去意义了。而且这三个依赖足够支撑一个完整的 Agent没必要引入重型依赖。API Key 通过环境变量传入不要硬编码在代码里。这是基本的安全习惯我见过太多人把 Key 写死在代码里然后传到公开仓库。4.2 定义两个工具天气查询和四则运算工具不用复杂两个就够演示。一个是get_weather一个是calculate。def get_weather(city: str) - str: # 模拟数据实际应调用天气 API mock_data {北京: 晴25°C, 上海: 多云28°C} return mock_data.get(city, f未找到{city}的天气数据) def calculate(expression: str) - str: try: # 安全起见只允许数字和运算符 allowed set(0123456789-*/.() ) if not all(c in allowed for c in expression): return 表达式包含非法字符 return str(eval(expression)) except Exception as e: return f计算错误{e}注意calculate里的安全校验。直接用eval是危险的必须限制字符集。这是手写 Agent 时必须考虑的安全问题框架不会帮你做。工具描述按前面说的三要素写注册到一个字典里TOOLS { get_weather: { function: get_weather, schema: { name: get_weather, description: 查询指定城市的当前天气。用户询问天气时使用。, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } }, calculate: { function: calculate, schema: { name: calculate, description: 计算数学表达式。用户需要做算术时使用。, parameters: { type: object, properties: { expression: {type: string, description: 数学表达式如23*4} }, required: [expression] } } } }4.3 拼装主循环让 Agent 真正跑起来把前面几个模块拼起来主循环大概 50 行import json from openai import OpenAI client OpenAI() def run_agent(user_input, max_turns10): messages [ {role: system, content: 你是一个助手可以查天气和做计算。需要时调用工具。}, {role: user, content: user_input} ] tool_schemas [t[schema] for t in TOOLS.values()] for turn in range(max_turns): response client.chat.completions.create( modelyour-model, messagesmessages, toolstool_schemas, temperature0 ).choices[0].message if not response.tool_calls: return response.content messages.append(response) for call in response.tool_calls: name call.function.name args json.loads(call.function.arguments) if name in TOOLS: result TOOLS[name][function](**args) else: result f未知工具{name} messages.append({ role: tool, tool_call_id: call.id, content: str(result) }) return 达到最大轮次任务未完成跑一下print(run_agent(北京天气怎么样顺便帮我算一下 25*410))正常的话Agent 会先调get_weather查北京再调calculate算数最后综合两个结果给出回复。整个过程你能看到每一轮的消息列表变化这就是手写的好处——完全透明。4.4 参数计算max_turns 和 token 阈值怎么定这两个参数没有标准答案但有参考逻辑。max_turns取决于任务复杂度。单工具任务 3-5 轮足够多工具协作 10 轮复杂任务分解 20 轮。设太大浪费 token设太小任务做不完。我的经验是先设 10观察实际任务的轮次分布取 P95 值加 2。token 阈值取决于模型上下文窗口。假设模型支持 8K 上下文你要留出输出空间输入控制在 4K 左右。其中系统提示占 500工具描述占 500剩下 3K 给对话历史。按每轮 200 token 算大概保留 15 轮。超过就做摘要。这两个参数都要根据实际运行数据调整不要拍脑袋定死。5. 踩坑实录手写 Agent 最常见的五个问题5.1 模型不调用工具直接编答案这是最高频的问题。用户问天气模型不调工具直接说“北京今天晴”。原因通常是工具描述不够清晰或者系统提示没强调“必须调用工具获取实时信息”。解决办法在系统提示里明确写“涉及天气、计算等问题时必须先调用工具不要凭记忆回答”。同时检查工具描述是否说清了使用场景。我实测下来加了这句提示后工具调用率从 60% 提升到 95% 以上。5.2 工具调用参数格式错误模型返回的参数可能是字符串而不是对象或者字段名拼错。这是模型能力问题但你可以通过参数校验加错误反馈来兜底。校验失败时把错误信息返回给模型它下一轮通常会修正。def validate_args(schema, args): required schema[parameters].get(required, []) for field in required: if field not in args: return f缺少必填参数{field} return None5.3 上下文爆炸导致模型失忆前面提过所有历史一股脑塞进去模型反而记不住关键信息。解决办法就是分层记忆加摘要。我还会在系统提示里加一句“优先关注最近的对话和工具结果”引导模型注意力。5.4 工具执行超时或异常工具调用外部 API 时可能超时。必须给每个工具加超时控制超时后返回“工具执行超时”给模型让它决定重试还是换方案。不要让它无限等待。5.5 死循环同一个工具反复调用前面提过重复调用检测。具体实现是记录最近几次调用的工具名和参数哈希如果连续两次相同就强制终止。这个检测救过我很多次尤其是模型陷入“调用-失败-再调用”循环时。问题现象解决不调工具模型直接编答案系统提示强调必须调用参数错误工具执行报错参数校验加错误反馈上下文爆炸模型失忆、费用高分层记忆加摘要工具超时Agent 卡住加超时控制死循环反复调同一工具重复调用检测6. 从手写版到生产级下一步该补什么手写版跑通后你已经理解了 Agent 的核心机制。但生产环境还需要补几块并发处理。手写版是单线程的多个用户同时请求会排队。生产环境需要异步化把模型调用和工具执行都改成 async。这块改动不小但思路清晰每个请求独立的消息列表共享工具注册表。可观测性。加日志、加追踪记录每一轮的输入输出、token 消耗、工具调用耗时。出了问题能快速定位。我一般用结构化日志每条记录带 request_id方便串联。工具生态。手写版的工具是硬编码的生产环境需要动态注册、权限控制、限流。这块可以参考 MCP 协议的设计思路但不用全盘照搬。评测体系。怎么知道你的 Agent 好不好需要一套评测集覆盖常见场景和边界情况每次改动跑一遍看通过率变化。没有评测优化就是盲人摸象。我个人的体会是手写一遍最大的收获不是代码本身而是对 Agent 行为有了直觉。当模型不调工具时你知道该去看工具描述当上下文爆炸时你知道该去查记忆管理。这种直觉是调框架调不出来的。后续你再去看任何 Agent 框架的源码都会有“哦它这里是在做我手写过的那件事”的感觉。这个内容后续还可以这样扩展把手写版改造成支持多 Agent 协作或者接入真实的工具生态都是水到渠成的下一步。