前阵子有个朋友问我:Agent开发是不是必须得上LangChain这种重型框架?我说不是。如果你只是想把Agent的原理搞明白,100行Python就够了,甚至能做出一个会查备忘录、会算数、能连续思考再行动的实用Agent。这份极简教程就是我当时给他演示用的代码和一些心得,今天整理成文字分享出来,建议先收藏,动手跑一遍比看十遍教程都管用。
这篇内容覆盖什么?全程用一段约100行的Python代码,实现一个最基础的Agent核心循环。它不依赖任何Agent框架,只依赖一个大模型API和标准库。适合三类人:刚接触Agent开发想弄明白底层机制的初学者;被LangChain、LlamaIndex等框架绕晕了想回到本质的开发者;以及需要快速做Agent原型验证的从业者。读完你应该能自己写出一个Agent,而且知道后续哪些地方需要补课。
1. 为什么建议从100行裸代码开始学Agent
1.1 大多数人学Agent走弯路的根源
我见过太多人一上来就打开LangChain的文档,看到AgentExecutor、Tool、Memory、CallbackHandler这些概念直接懵了。说实话,这不能怪大家。框架为了适配生产环境,做了大量抽象和封装,这些封装对老手是好东西,对新手却是高墙。
我自己的学习路线刚好相反。早期做Agent原型时,我试过直接用大模型API接口裸写循环,不借助任何Agent框架,反而一下子把核心机制看透了。后来再去看框架,发现里面80%的代码都是在处理边界情况:重试、缓存、日志、安全校验、多模型适配。Agent真正的骨架,其实就是一个“模型转圈调用工具”的循环。这个循环用纯代码写,100行完全够。
所以我的建议很直接:想学Agent,先把裸代码跑通,再上框架。这样遇到框架里的报错,你能准确判断是模型问题、工具问题还是循环逻辑问题,而不是对着黑盒乱猜。
1.2 100行代码能覆盖的边界:学什么、不学什么
有人看到“100行代码实战”会质疑:这么点代码能干什么?我诚实地讲清楚边界。
这100行代码能演示的:
- 模型自主决定是否调用工具
- 多轮调用(先查资料再计算,再回答)
- 工具不存在时的错误纠正
- 防止死循环的最大轮数控制
- 最简单的对话记忆(消息列表累积)
这100行代码不覆盖的:
- 流式输出、并发调用、批量任务
- 向量检索、长期记忆、多Agent协作
- 复杂的工具参数校验、权限隔离
- 生产级日志和监控体系
先看清骨架,再谈骨架上的肌肉和组织。这是这篇文章的核心思路。
1.3 环境准备:10分钟搞定
先说技术选型:Python 3.10以上,安装openai库用于调用兼容OpenAI接口的大模型服务,再装一个python-dotenv用来读环境变量(不装也行,直接写死在代码里跑通后记得删掉)。
pip install openai python-dotenv大模型部分,你手上任何支持OpenAI兼容Chat接口的模型服务都可以,不需要额外支持“function calling”特性。因为后面你会发现,在这个极简架构里,我故意不用平台原生的函数调用协议,而是让模型直接输出JSON字符串来做工具调用。这样代码量最少,而且换模型平台时不用改核心逻辑。
准备工作就这么多,接下来进入正题。
2. Agent的核心循环:先想清楚这四步再动手
2.1 Agent和普通Chat调用的本质区别
普通聊天是一次问答:用户提问,模型回答,结束。
Agent是一个闭环:用户提需求,模型先判断“我现在缺什么信息、需要做什么动作”,然后输出一个工具调用指令,代码执行工具,把结果拼到对话里,再丢回给模型,模型基于新信息继续决策,直到它认为任务完成,输出最终答复。
用人话讲:普通聊天是模型只用嘴回答,Agent是模型指挥手脚干活。这个差别看着小,实际是质变。工具让模型的“认知”能触达真实世界:能查数据库、能发请求、能算数、能操作文件。模型的推理能力加上工具的扩展能力,才是Agent真正的价值。
2.2 工具注册表:Agent的手脚
为了让模型知道有哪些工具可用,我们必须把每个工具的描述、参数格式告诉模型。这就是工具注册表。
TOOLS = { "get_time": get_time, "calculate": calculate, "lookup_note": lookup_note, }同时,把每个工具的自然语言描述拼进系统提示词。模型看到描述后,在需要时会输出类似{"action": "calculate", "params": {"expr": "(3+5)*2"}}的JSON,代码解析这个JSON,查表执行对应函数,再把结果回填给模型。
这里有网友讨论:为什么不让模型输出“函数名+参数列表”的格式呢?用JSON的好处是结构明确、容易解析、可冗余额外字段。JSON是模型最熟悉的结构化文本格式之一,你用自然语言让模型输出“按|分隔”的格式也能跑,但兼容性和可读性都会差不少。稳定使用JSON是我的一个习惯。
2.3 循环的终点:不可解析即最终答案
这是整个极简设计里最巧妙的一步:当模型输出无法被JSON解析时,代码就认为它给出了最终答案。
这省去了“要不要加一个结束标记”的设计。模型发现自己不再需要调用工具时,直接输出人类可读的文本,代码尝试解析JSON失败后,就把文本返回给用户。这个约定虽然简单,但非常可靠。实践下来,只要系统提示词里写清楚规则,模型基本都能遵守。
整个循环可以用一个计数器保护,超过最大轮数强制退出,防止Agent陷入无限调用。
3. 100行代码拆解:一个能查资料、会计算的Agent
3.1 完整代码:收藏这一份就够了
import json import os from datetime import datetime from dotenv import load_dotenv from openai import OpenAI load_dotenv() # ---------- 1. 大模型客户端配置 ---------- client = OpenAI( api_key=os.getenv("LLM_API_KEY"), # 你的大模型API密钥 base_url=os.getenv("LLM_BASE_URL"), # 兼容OpenAI接口的服务地址 ) MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") # ---------- 2. 定义Agent可使用的工具 ---------- NOTES = { "水果": "苹果、香蕉、橘子", "会议": "周五下午3点产品评审会", } def get_time(): """获取当前日期和时间""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def calculate(expr: str): """计算简单数学表达式,例如 (3+5)*2""" try: # 演示用,生产环境建议使用 ast.literal_eval 或专门计算引擎 return str(eval(expr, {"__builtins__": {}}, {})) except Exception as e: return f"表达式错误: {e}" def lookup_note(key: str): """在本地备忘录中查询指定内容""" return NOTES.get(key, f"没有找到“{key}”的相关记录") TOOLS = { "get_time": get_time, "calculate": calculate, "lookup_note": lookup_note, } # ---------- 3. 工具使用说明(拼进系统提示词) ---------- TOOL_DESCRIPTIONS = """ 你是一个可靠的AI助手。当遇到以下情况时,调用对应工具获取信息: 1. 需要当前时间:调用 get_time,参数为空 2. 需要数学计算:调用 calculate,参数为 expr(数学表达式) 3. 需要查备忘录:调用 lookup_note,参数为 key(查询关键词) 调用工具时,只输出如下JSON格式: {"action": "工具名", "params": {"参数名": "参数值"}} 当任务已经完成时,直接输出最终回答文本,不要输出JSON。 """ # ---------- 4. 调用大模型 ---------- def call_llm(messages): resp = client.chat.completions.create( model=MODEL_NAME, messages=messages, temperature=0, # 降低随机性,提高输出稳定性 ) return resp.choices[0].message.content # ---------- 5. Agent主循环 ---------- def run_agent(task: str, max_rounds: int = 5): messages = [ {"role": "system", "content": TOOL_DESCRIPTIONS}, {"role": "user", "content": task}, ] for round_idx in range(1, max_rounds + 1): print(f"\n===== 第 {round_idx} 轮 =====") reply = call_llm(messages).strip() # 尝试解析为JSON工具调用 try: payload = json.loads(reply) except json.JSONDecodeError: # 解析失败:模型认为任务完成,直接返回最终答案 return reply action = payload.get("action") params = payload.get("params", {}) if action not in TOOLS: messages.append({"role": "assistant", "content": reply}) messages.append({ "role": "user", "content": f"工具“{action}”不存在,请选择已有的工具,或直接输出最终回答。", }) continue # 执行工具 try: result = TOOLS[action](**params) except Exception as e: result = f"工具执行异常: {e}" print(f"调用工具: {action}({params}) -> {result}") # 把调用记录和工具结果放回会话,让模型继续推理 messages.append({"role": "assistant", "content": reply}) messages.append({"role": "user", "content": f"工具返回: {result}"}) return "已达最大运行轮数,无法得到最终结论。请简化任务或补充信息。" # ---------- 6. 入口 ---------- if __name__ == "__main__": user_task = "帮我看一下备忘录里关于水果的内容,然后把水果种类数量乘以3告诉我" print(run_agent(user_task))3.2 逐块拆解:每一行都不是白写的
第一块是客户端配置,注意我特意用LLM_BASE_URL这个环境变量承接任意兼容接口的服务地址。你在跑的时候,填你拿到的大模型服务信息即可,模型选一个稳定支持JSON输出的Chat模型就行。
第二块工具定义是整个Agent的“能力来源”。三个工具分别对应:获取时间(外部信息)、数学计算(内在推理辅助)、备忘录查询(本地知识)。别小看这三个工具的组合,它们已经能串出一个有实际价值的流程:查备忘录→做计算→给结论。
第三块系统提示词是Agent的“行为准则”。模型之所以知道什么时候该输出JSON、什么时候该直接回答,全靠这几十个字的约定。提示词里我明确写了“只输出如下JSON格式”和“直接输出最终回答”两条规则,一正一反,边界非常清晰。
第四块模型调用函数,只做了一件事:把消息列表发给模型,取回回复文本。温度调成0这一点很多人会忽略——Agent的推理过程需要确定性,温度高会导致同样的问题每次答案不同,排查问题时会很痛苦。
第五块主循环是整个Agent的心脏。代码逻辑归纳为四步:
- 调模型,拿回复
- 尝试解析JSON,解析失败就直接当最终答案返回
- 按action查工具表,执行工具,捕获异常
- 把“模型调用记录”和“工具返回结果”追加进消息列表,进入下一轮
这里有个关键设计:工具调用记录也以角色assistant身份放回消息里,工具结果以角色user身份放回。这样做的好处是对话历史自然连贯,模型看到自己的上一次输出和工具的真实返回,能够理解“我刚才调用了工具,拿到结果是这个,接下来继续”。
3.3 如何跑通和验证
把代码保存为agent.py,在项目根目录建一个.env文件:
LLM_API_KEY=你的密钥 LLM_BASE_URL=你的接口地址 MODEL_NAME=你的模型名然后运行:
python agent.py正常情况你会看到类似输出:
===== 第 1 轮 ===== 调用工具: lookup_note({'key': '水果'}) -> 苹果、香蕉、橘子 ===== 第 2 轮 ===== 调用工具: calculate({'expr': '3'}) -> 3 ===== 第 3 轮 ===== 备忘录里记录了三种水果:苹果、香蕉、橘子。水果种类数量为3,乘以3得到9。模型第一轮查备忘录,第二轮算数,第三轮发现任务完成、直接输出文本。整个链路清晰可见。如果模型输出格式不稳定,你可以在循环里加打印,把每一轮的reply原始内容打出来,定位是模型没按格式输出、参数解析出错还是工具本身报错。
4. 让Agent稳定输出的三个关键细节
4.1 温度设为0,解析JSON前先清理
温度参数影响模型输出的随机性。Agent工具调用路径上,任何一次随机输出都可能把关键参数写错,所以temperature=0是我跑Agent测试时的标配。但这里要提醒:即使温度是0,大模型也不是完全确定的。所以代码里拿到reply后先做一个strip()去掉首尾空白,再json.loads。模型偶尔会输出带Markdown代码块标记的JSON,比如:
```json {"action": "get_time", "params": {}}这种情况你的`json.loads`会直接炸掉。可以加一个简单的清洗函数,把`"```json"`和`"```"`去掉再解析。实话说,这个极简Demo里可以不加,但我知道很多人会直接拿去用,先提个醒。 ### 4.2 最大迭代数:Agent死循环的保险丝 `max_rounds=5`是防止Agent进入无意义的循环。我实测过一种情况:模型反复调用同一个工具,每次都把相同结果写回对话,然后说“我需要再确认一次”。如果没有轮数上限,这个循环会消费大量token直到报错。加一个计数器是成本最低的保护手段。 真实项目中,我还见过Agent在两个工具之间来回横跳:先查时间,再算数,又查时间,又算数,完全不知道自己在干嘛。这种情况通常是工具描述写得不够明确,模型混淆了工具职责。调试手段就是看日志里的工具调用序列,一旦发现“来回横跳”,优先检查工具描述是否产生了歧义。 ### 4.3 工具结果截断:防止上下文爆炸 这个坑我最初没意识到。直到有一次工具返回了一个超长的日志文本,模型读完立刻开始胡言乱语,甚至编造工具结果。原因是长文本塞进上下文后,模型注意力被无关信息干扰了。 现在的做法是:在把工具结果写入消息之前,先判断长度。超过2000字符就截断,并在末尾加一行`...内容过长已截断,请基于已知信息继续`。注意,**这个截断逻辑要在主循环里做,而不是在工具函数里做**,因为不同场景下你需要不同的长度限制。 ```python MAX_RESULT_LENGTH = 2000 if len(result) > MAX_RESULT_LENGTH: result = result[:MAX_RESULT_LENGTH] + "\n...内容过长已截断,请基于已有信息继续。"工具调用是Agent最锋利的刀,也最容易划伤自己。把上下文控制好,Agent的稳定性会立刻上一个台阶。
5. 实测中常见的坑与排查链路
5.1 模型输出的JSON挤在一段话里怎么办
我的测试中,模型偶尔会输出:“好的,我来帮您查一下。{"action": "get_time", "params": {}} 请稍等。”这种混合文本会让json.loads直接抛错。
排查链路是:在解析JSON之前先把原始回复打印出来,看看模型到底输出了什么。确认是混合文本后,用正则提取JSON部分:
import re match = re.search(r"\{.*\}", reply, re.DOTALL) if match: payload = json.loads(match.group())这里有个更省事的思路:在系统提示词里加一句“只输出JSON,不要任何解释”,90%的情况能解决。遇到顽固情况再用正则兜底。
5.2 模型非要调用不存在的工具
模型出现了幻觉,调了一个工具注册表里根本没有的函数。代码里对应的处理是:把模型输出原样放回会话,然后追加一条用户消息“工具不存在,请选择已有的工具或直接作答”。这个设计利用了模型自己的纠错能力。
为什么有效?因为模型看到自己的调用记录被标记为失败,通常会意识到问题并且换一个工具或直接回答。实测中,大多数情况一轮就能纠正。如果连续两轮都调不存在的工具,检查一下工具描述和系统提示词,大概率是描述里某个词汇有歧义。
5.3 Agent陷入“假循环”的排查实例
有一次我跑Agent,它不断调用get_time,然后自言自语“时间已经获取到了”,然后再调用一次。日志看起来像这样:
第1轮: 调用 get_time -> 2025-01-10 10:23:01 第2轮: 调用 get_time -> 2025-01-10 10:23:02 第3轮: 调用 get_time -> 2025-01-10 10:23:04一开始我以为是模型犯蠢,后来检查消息历史才发现,系统提示词里写的是“需要当前时间就调用get_time”,而任务描述里混入了“查看当前时间”这类模糊指令,模型误以为用户需要多次确认。改法很简单:把任务写成“查看当前时间,然后直接告诉我就行”,同时在提示词里明确“每个工具只在必要时调用一次”。这提醒我们:排查Agent问题不要只盯模型,先检查提示词与工具的匹配关系。
5.4 一套通用的Agent排查链路
用多了你会有自己的套路,我分享一个四步排查链路:
- 打印每一轮的完整消息列表,包含system、assistant、user、tool返回
- 确认模型输出的是否为合法JSON,是的话参数值是否符合预期
- 确认工具返回结果是否符合描述,有没有异常或过长内容
- 确认下一轮模型是否基于工具结果正确回应
这套链路适用于绝大多数Agent异常。把它固化成一个debug()函数,每次出问题先跑一遍,比盲目改提示词高效得多。
6. 从100行到生产级:这些地方必须补课
6.1 从单工具到多工具并行
极简版里Agent每轮只调用一个工具。真实场景中,一个任务可能需要同时查多个数据源,例如“对比昨天和今天的销售额”。生产级Agent通常支持一个回复里携带多个工具调用请求。实现思路是让模型输出一个JSON数组,代码逐个执行同一轮内的工具,把结果批量放回会话。
[ {"action": "query_sales", "params": {"date": "2025-01-09"}}, {"action": "query_sales", "params": {"date": "2025-01-10"}} ]主循环里的解析逻辑改成先判断是不是数组,再逐个处理,就能打通多工具并行。
6.2 补上记忆能力:向量检索
极简版的消息列表只覆盖单次任务的短期记忆。生产级Agent需要跨会话记忆,常见方案是把历史对话切块后做嵌入向量存储,每次任务开始时检索与当前问题最相关的几段记录,拼进系统提示词。这块内容很多,核心点在于:不是所有历史都值得记住,检索精度比存储量更重要。
6.3 做好安全隔离和权限控制
工具是Agent连接世界的通道,也是最危险的部分。生产环境必须做到:
- 工具函数内部做白名单校验,明确哪些路径、哪些接口允许访问
- 涉及删除、写入、支付等高风险操作,强制人工审批
- 对工具执行的超时时间做限制,防止外部API卡死整个循环
我在Demo里用了eval来计算表达式,这只能用于本地演示。生产环境请换用ast.literal_eval,或者引入专门的计算库,避免任意代码执行的风险。
6.4 建立评估集,用数据说话
最后补一课:Agent开发不能靠“我觉得效果不错”。准备一组任务清单,例如20个标准问题,每次改动后跑一遍完整流程,统计任务完成率、平均工具调用轮数、错误类型分布。有个基线对照组,你才能判断某个改动是变好了还是变坏了。我的习惯是把评估结果输出成表格,如果完成任务率低于90%,就继续回来调提示词或工具描述。
从100行裸代码到一个真正能上线的Agent系统,中间还隔着很多距离,但骨架始终是那个循环:思考、行动、观察、再思考。把这个循环吃透了,上什么框架都不是问题。
最后再分享一点个人体会:写这个极简Demo时最有价值的收获,不是代码本身,而是让我明白了Agent的复杂度其实分为“核心机制”和“工程治理”两层。很多人卡在入门阶段,是因为把这两层混在一起学。先用这份100行代码把核心机制磨透,遇到工程问题时再逐个击破,这条路我亲测有效。