从零手写AI Agent:不依赖LangChain的Python实战指南 1. 为什么我不建议你直接上手LangChain先说一个可能得罪人的观点如果你连一个AI Agent的完整请求链路都没亲手写过一遍直接上LangChain或者AutoGPT这类框架大概率是在给自己挖坑。我见过太多人装完依赖、跑通Demo觉得自己会了结果线上稍微出点问题——工具调用返回格式不对、多轮对话上下文丢失、Token消耗莫名其妙翻倍——完全不知道从哪查起。原因很简单框架帮你屏蔽了太多细节而这些细节恰恰是Agent能不能稳定跑起来的关键。所谓AI Agent说白了就是一个能自己决定下一步做什么的程序。普通的大模型调用是你问一句它答一句Agent则是在这个基础上加了三个东西工具调用能力让它能查天气、读文件、发请求、循环决策能力根据上一步结果决定下一步、记忆管理能力记住之前发生了什么。这三样东西拆开看都不复杂但组合在一起坑就来了。用Python从零搭一个Agent最大的价值不是省依赖而是让你对每一条消息的流向、每一次Token的消耗、每一个工具调用的边界都心里有数。这篇文章我会带你走一遍完整流程从最裸的API调用开始逐步加上工具、循环、记忆最后给出一个能实际跑起来的最小可用Agent。全程不依赖任何Agent框架只用Python标准库加一个大模型SDK。适合谁看有Python基础、调用过至少一次大模型API、想搞清楚Agent底层到底怎么运转的人。如果你连requests库都没用过建议先把Python基础过一遍再回来。2. 拆开看一个Agent最少需要哪几个零件2.1 大模型APIAgent的大脑但别神化它很多人把大模型当成Agent的全部这是个误解。大模型在Agent里的角色非常单一给定一段对话历史输出下一步该做什么。它不负责执行不负责存储甚至不负责判断结果对不对。它就是一个文本进、文本出的函数。我选OpenAI的接口做演示因为它的function calling工具调用格式最清晰文档也最全。但你完全可以把这一层换成任何兼容OpenAI格式的服务。核心就一个chat.completions.create调用传入messages列表和tools定义拿回一个response。这里有个新手最容易忽略的点大模型本身是无状态的。你每次调用都得把完整的对话历史传进去它才知道之前发生了什么。所谓记忆本质上就是你自己在维护一个列表每次调用前把该带的信息拼进去。理解这一点后面所有关于上下文管理的问题都好办了。from openai import OpenAI client OpenAI(api_key你的key, base_url你的接口地址) def call_llm(messages, toolsNone): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, temperature0 ) return resp.choices[0].message注意temperature0。Agent场景下我强烈建议把温度调到最低因为你需要的是稳定、可预测的决策而不是创意。温度高了同一个问题它可能这次调工具A、下次调工具B调试起来会让你怀疑人生。2.2 工具层Agent的手脚也是最容易出事的地方工具就是一个个普通的Python函数加上一段给大模型看的描述。大模型根据你的描述决定要不要调、传什么参数。这里的关键在于描述要写得像给一个新员工交代任务——说清楚这个工具干什么、什么时候用、参数是什么格式。def get_weather(city: str) - str: # 真实场景这里应该调天气API演示用假数据 fake_data {北京: 晴25度, 上海: 多云28度} return fake_data.get(city, f查不到{city}的天气) tools [{ type: function, function: { name: get_weather, description: 查询指定城市的当前天气。当用户询问天气时使用。, parameters: { type: object, properties: { city: {type: string, description: 城市名称如北京} }, required: [city] } } }]我踩过的一个坑工具描述里没写清楚参数格式结果大模型传了个{city: 北京市朝阳区}进来我的假数据字典里只有北京直接返回查不到。后来我在描述里明确写了只传城市名不要带区县问题就解决了。工具描述不是文档是给模型看的指令越具体越好。2.3 循环控制Agent的思考-行动节拍器这是Agent和普通对话机器人的分水岭。普通对话是一问一答就结束Agent是问-想-做-看结果-再想-再做直到任务完成。这个循环必须有个终止条件否则模型可能陷入死循环把你的Token烧光。def run_agent(user_input, max_steps5): messages [ {role: system, content: 你是一个助手可以使用工具来回答问题。}, {role: user, content: user_input} ] for step in range(max_steps): msg call_llm(messages, tools) messages.append(msg) # 没有工具调用说明模型给出了最终答案 if not msg.tool_calls: return msg.content # 有工具调用逐个执行 for tool_call in msg.tool_calls: func_name tool_call.function.name args json.loads(tool_call.function.arguments) result globals()[func_name](**args) messages.append({ role: tool, tool_call_id: tool_call.id, content: str(result) }) return 达到最大步数限制任务未完成max_steps这个参数看着不起眼但它是你的安全绳。我一般设5到8步具体看任务复杂度。设太小复杂任务做不完设太大万一模型抽风开始无限循环你的账单会很难看。3. 把零件拼起来一次完整的工具调用长什么样光看代码可能还是抽象我把一次真实的北京天气怎么样请求的完整消息流拆给你看。理解了这个流程后面加什么功能都是在这个骨架上挂东西。第一轮你发给模型的消息是[ {role: system, content: 你是一个助手可以使用工具来回答问题。}, {role: user, content: 北京天气怎么样} ]模型返回的不是文字而是一个tool_calls{ role: assistant, tool_calls: [{ id: call_abc123, function: {name: get_weather, arguments: {\city\:\北京\}} }] }第二轮你把模型这条消息原样追加进messages然后执行工具把结果作为role: tool的消息追加进去[ {role: system, content: ...}, {role: user, content: 北京天气怎么样}, {role: assistant, tool_calls: [...]}, {role: tool, tool_call_id: call_abc123, content: 晴25度} ]再调一次模型这次它拿到工具结果返回最终文字北京现在是晴天气温25度。整个流程里tool_call_id是必须严格对应的。我见过有人图省事自己编一个id结果接口直接报错。这个id是模型生成的你原样传回去就行。注意工具返回的内容一定要转成字符串。如果你返回一个字典某些接口会报序列化错误。str(result)是最稳妥的做法。还有一个细节工具执行如果抛异常了怎么办直接让程序崩掉肯定不行。我的做法是捕获异常把错误信息作为工具结果返回给模型让它自己决定是重试还是告诉用户失败了。try: result globals()[func_name](**args) except Exception as e: result f工具执行失败{e}这样模型看到工具执行失败可能会换个参数再试一次或者直接告诉用户抱歉查询失败了。比程序崩溃优雅得多。4. 记忆管理别让上下文变成一锅粥4.1 短期记忆就是那个messages列表前面说了大模型无状态所谓记忆就是你维护的messages列表。但这里有个现实问题对话轮次一多这个列表会越来越长Token消耗直线上升而且模型在超长上下文里容易忘记前面的关键信息。我的做法是分层处理。最近3到5轮对话完整保留更早的对话做摘要压缩。摘要本身也是一次模型调用让模型把前面的对话浓缩成几句话。def compress_history(messages, keep_recent6): if len(messages) keep_recent 1: return messages system_msg messages[0] old_messages messages[1:-keep_recent] recent_messages messages[-keep_recent:] summary_prompt 把以下对话浓缩成简短摘要保留关键事实和结论\n summary_prompt \n.join([f{m[role]}: {m.get(content,)} for m in old_messages]) summary call_llm([{role: user, content: summary_prompt}]).content return [system_msg, {role: system, content: f之前对话摘要{summary}}] recent_messages这个函数在每轮循环开始前调一次就行。实测下来20轮以上的对话压缩后Token能省60%以上而且模型对近期对话的注意力更集中回答质量反而更稳。4.2 长期记忆需要跨会话记住的东西短期记忆解决的是这次对话里发生了什么长期记忆解决的是上次用户说过他喜欢什么。这两件事性质不同存储方式也不一样。最简单的长期记忆就是一个JSON文件键是用户ID值是事实列表。每次对话开始前读出来拼进system prompt。import json, os MEM_FILE memory.json def load_memory(user_id): if not os.path.exists(MEM_FILE): return [] data json.load(open(MEM_FILE, encodingutf-8)) return data.get(user_id, []) def save_memory(user_id, fact): data {} if os.path.exists(MEM_FILE): data json.load(open(MEM_FILE, encodingutf-8)) data.setdefault(user_id, []).append(fact) json.dump(data, open(MEM_FILE, w, encodingutf-8), ensure_asciiFalse)什么时候写入长期记忆我的经验是不要让模型自己决定而是在特定工具调用后手动触发。比如用户说记住我喜欢喝美式你可以加一个save_user_preference工具模型调用它的时候你就往文件里写。这样可控性最强不会出现模型自作主张记一堆没用的东西。5. 从能跑到好用几个让Agent稳定的实战技巧5.1 工具数量别超过7个超了就分组这是我从实际项目里总结出来的。工具一多模型选错的概率明显上升。我做过一个测试给模型5个工具时选对率大概95%给到15个时掉到70%左右。原因很简单工具描述在prompt里占的篇幅变长模型注意力被分散了。解决方案是分组路由。先让模型判断用户意图属于哪个大类比如查询类操作类计算类再在那个大类下选具体工具。相当于把一次选择拆成两次每次的候选集都小准确率就上来了。5.2 给工具调用加超时和重试真实环境里工具背后往往是网络请求。网络请求就会超时、就会失败。如果你不处理Agent会卡在那里等用户体验极差。import signal def timeout_handler(signum, frame): raise TimeoutError(工具执行超时) def safe_call(func, args, timeout10): signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(timeout) try: return func(**args) except TimeoutError: return 工具执行超时请稍后重试 finally: signal.alarm(0)signal.alarm在Linux和Mac上都能用Windows下需要用threading模拟。这个超时机制配合前面的异常捕获基本能保证Agent不会因为单个工具卡死而整个挂掉。5.3 日志要记全不然出问题你查不到Agent的调试难度比普通程序高因为它的行为有随机性。同一个输入可能这次调工具A下次调工具B。所以每一次模型调用、每一次工具执行、每一次消息追加都要打日志。我一般记这几个字段时间戳、步骤序号、模型返回的原始内容、工具名和参数、工具返回结果、当前messages长度。出问题的时候把日志拉出来一看基本能定位到是哪一步决策出了问题。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(message)s) def log_step(step, msg): logging.info(f[Step {step}] role{msg.get(role)} content{str(msg.get(content))[:100]})别嫌日志多等你线上出问题的时候这些日志就是你的救命稻草。6. 一个能直接跑的最小Agent完整代码把前面所有东西串起来下面这份代码你可以直接复制去跑。我加了详细注释每一块对应前面讲的哪个部分都标清楚了。import json import logging from openai import OpenAI logging.basicConfig(levellogging.INFO, format%(asctime)s - %(message)s) client OpenAI(api_key你的key, base_url你的接口地址) # 工具定义 def get_weather(city: str) - str: fake {北京: 晴25度, 上海: 多云28度, 广州: 小雨30度} return fake.get(city, f暂时查不到{city}的天气) def calculate(expression: str) - str: try: return str(eval(expression, {__builtins__: {}}, {})) except Exception as e: return f计算失败{e} TOOLS [ { type: function, function: { name: get_weather, description: 查询城市天气。只传城市名不要带区县。, parameters: { type: object, properties: {city: {type: string}}, required: [city] } } }, { type: function, function: { name: calculate, description: 计算数学表达式如23*4。, parameters: { type: object, properties: {expression: {type: string}}, required: [expression] } } } ] FUNC_MAP {get_weather: get_weather, calculate: calculate} # 核心循环 def run_agent(user_input, max_steps6): messages [ {role: system, content: 你是一个助手可以调用工具来回答问题。回答用中文。}, {role: user, content: user_input} ] for step in range(max_steps): logging.info(f--- Step {step} ---) resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolsTOOLS, temperature0 ) msg resp.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for tc in msg.tool_calls: name tc.function.name args json.loads(tc.function.arguments) logging.info(f调用工具 {name} 参数 {args}) try: result FUNC_MAP[name](**args) except Exception as e: result f工具执行失败{e} logging.info(f工具返回 {result}) messages.append({ role: tool, tool_call_id: tc.id, content: str(result) }) return 任务步数超限未能完成 if __name__ __main__: print(run_agent(北京和上海哪个城市更热热多少度))跑这个例子你会看到Agent先调get_weather查北京再调get_weather查上海然后调calculate算温差最后给出答案。整个过程日志清清楚楚每一步为什么这么走你都能看到。7. 什么时候该上框架什么时候不该自己搭完这一遍之后你对Agent的理解会完全不一样。这时候再回头看LangChain这类框架你会发现它们帮你做的事无非就是封装了消息格式、提供了工具注册的语法糖、内置了一些记忆策略。这些在你亲手写过之后都是可以自己按需实现的。我的建议是原型验证阶段自己写生产环境再考虑框架。原型阶段你需要的是快速理解和调试自己写的代码每一行都透明。生产环境你需要的是稳定性和生态框架提供的重试、监控、多模型适配确实省事。但有个前提你得先知道框架在帮你做什么。不然出了问题你连从哪查都不知道。这也是我写这篇文章最想传达的东西——Agent不神秘它就是一个带工具调用和循环控制的对话程序。把这条链路走通一遍后面无论用什么框架你都能一眼看穿它在干什么。最后分享一个我调试Agent时的小习惯每次改完代码先用一个最简单的单工具场景跑通确认消息流没问题再加第二个工具、第三个工具。不要一次性把所有工具都挂上去然后祈祷它能跑对。Agent的调试是渐进的一步一步来比一次全上然后大海捞针式排查要快得多。