智能体(Agent)这个词,这两年已经被念叨得快要免疫了。可你要是真的上手用 Python 写一个能稳定干活、出问题能排查、改一改还能当产品用的智能体,会发现网上的教程要么停在“调一次大模型 API”的层面,要么一上来就扔给你一套几百行封装的框架,demo 跑通了,底层到底发生了什么还是一头雾水。这篇《Python AI 智能体编程:实用指南(一)》,我想换个讲法——不绕弯子,直接带你从零写出一个最小可用的智能体核心循环,再把工具系统、记忆管理、常见翻车点一个一个拆开讲清楚。
这篇适合谁读?已经会用 Python 写脚本、调过一两次大模型接口、但还没系统搭过 Agent 的开发者。如果你已经被各种 Agent 框架绕晕了,想回到本质看看每一步到底是什么在起作用,这篇也能帮你把地基重新打一遍。这是系列第一篇,先把地基和主体结构讲透,后续再展开多智能体协作、规划器、评测这些进阶内容。
1. 先想明白:你要做的"智能体"到底是个什么东西
1.1 从"会聊天"到"能干活"的关键跃迁
普通的大模型对话应用,本质就是一次次"你问我答":用户输入,模型输出。它本身不产生任何外部动作,哪怕模型信誓旦旦地说"已经帮你订好机票了",实际上什么都没发生,它只是生成了几段像样的文字而已。
智能体的核心差别就在于:模型不仅能输出文本,还能通过工具调用去执行真实操作,比如查数据库、调接口、发消息、跑脚本,然后根据执行结果继续思考、继续行动,直到完成目标。这个"思考→行动→观察结果→再思考"的闭环,就是智能体最本质的工作方式。
我经常用这个类比:对话模型像一个只动嘴的顾问,你问他"这个需求怎么做",他给你一套方案;智能体则像一个动手的实习生,你给他一个目标,他会自己查文档、跑测试、翻数据库,遇到问题还会换个思路再试一次。关键差异不是"他会用工具",而是"他会在循环里反复做决策"。
1.2 智能体的四个核心组件
一个能正常工作的智能体,无论多复杂,底层都跑不出这四个组件:
- 模型(LLM):大脑。负责理解用户意图、拆解任务、决定下一步做什么。
- 工具(Tools):手脚。负责执行具体动作,比如查询天气、读写文件、调用业务接口。
- 记忆(Memory):负责保存上下文。既包括本轮对话的历史,也包括工具执行结果,更长远的还包括跨会话沉淀的知识。
- 编排循环(Orchestration Loop):中枢。负责把前三者串起来,决定什么时候调模型、什么时候调工具、什么时候把最终答案交给用户。
这四块缺一不可。我见过不少半成品 Agent,其实是"有模型、有工具,但没有循环"——模型只会按预设流程调用一次工具,拿到结果就直接结束,任务稍微复杂一点就崩。真正的智能体,循环是它的灵魂。
1.3 什么时候真需要智能体,什么时候不需要
判断一个需求该不该上 Agent,我通常看三条标准:任务是否需要多步决策、是否需要实时外部信息、是否可以容忍试错重试。
如果只是"翻译一段话""总结一篇文档""抽取几个字段",一次模型调用就够,硬套 Agent 只会增加延迟和失败概率。反过来,如果任务要查多个数据源、要根据中间结果做分支判断、出错后要能自我修正,比如"帮我对比这三家供应商的报价并给出推荐",那才是 Agent 的用武之地。
还有一点必须提前说清楚:Agent 不是万能钥匙,它不保证百分百成功。它更适合那种"可以在循环里重试、容错成本低"的任务。你要是拿它去做"失败一次就损失重大"的支付流程,那得先想好人在环里的兜底方案。
2. 动手之前:环境、选型与设计思路
2.1 开发环境与依赖准备
Python 版本建议 3.10 以上,类型注解、语法特性都更顺手。核心依赖其实非常少:一个模型调用的 SDK、标准库里的json,以及可选的pydantic用来做参数校验。很多人一上来就装一堆框架,实际最小实现只需要一个 SDK。
依赖管理这块,我的习惯是给每个项目单独开虚拟环境,用venv或者更新一些的uv都行,避免把全局环境搞乱。密钥这种敏感信息,一律走环境变量:
export LLM_API_KEY="你的密钥"代码里用os.getenv("LLM_API_KEY")去读,绝不硬编码。这个习惯我从一开始就养成了,后来做项目上线时才没吃大亏。
2.2 选型:自研最小循环,还是直接用编排框架
市面上常见的 Agent 编排框架,好处是封装了大量细节,坏处是黑盒太多、调试困难、版本升级频繁。同一个框架几个月不见,API 可能换了一茬。
我的建议很明确:第一次学,务必自研一个最小循环。代码量控制在 150 行以内,跑通之后你对智能体机制的理解会上一个台阶。等原理彻底通了,再考虑用框架解决生产级问题——到时候你看框架源码,也能一眼看懂它封装了什么、哪里可能有坑。
这就像学开车先开手动挡,虽然起步时折腾,但对机械结构的理解完全不一样。你见过哪个老司机抱怨手动挡难开的?理解了原理,工具就只是工具。
2.3 系统提示词:智能体的"人设和工作手册"
系统提示词的质量,直接决定工具调用的准确度。它不是随便写两句"你是一个有用的助手"就完事的,至少要覆盖四类信息:
- 身份和边界:明确告诉模型"你是一个可以调用工具的智能体,不知道的信息要通过工具获取,不要凭空捏造"。
- 工具使用规则:什么时候该调工具、什么时候不该调。比如"只有用户询问实时信息时才调用查询工具,日常寒暄不需要调用"。
- 行为约束:比如"工具执行失败时,要如实告知用户,不要假装成功"。
- 输出格式:比如"最终回答控制在 200 字以内,直接给结论"。
我见过一个典型翻车案例:系统提示词里没写"不要编造数据",结果用户问天气,模型直接编了个"晴,25度"。后面我会专门讲这个问题的排查思路。
3. 最小可行智能体:核心循环的完整实现
3.1 先理解 Function Calling 的机制
Function Calling 是大模型厂商提供的结构化工具调用能力。流程是这样的:你给模型一份 JSON Schema,描述清楚"有哪些工具、参数长什么样、什么时候用";模型在回答时,不是直接输出一段文本,而是输出一个结构化的调用请求,比如"调用 get_weather 工具,参数 city='北京'";你的代码收到这个请求后,真正去执行对应函数,再把结果以 tool 消息回传给模型。
这里有个关键认知:模型只负责"决定调哪个工具、传什么参数",真正执行的是你的代码。所以工具的执行结果可以完全真实,不会出现模型幻觉。把这一点想透,后面很多问题都好理解。
3.2 一个能跑的 Agent 循环代码
下面是最小实现的完整代码。我特意用接口示意的方式写,你换成自己习惯的模型 SDK 就能跑:
import json import os # 这里以你常用的模型 SDK 为例,client 需要先完成初始化 # 不同 SDK 的聊天补全接口大同小异,核心都是传 messages 和 tools client = init_llm_client(api_key=os.getenv("LLM_API_KEY")) # 第 1 步:定义两个最朴素的工具 def get_weather(city: str) -> str: """实际项目里替换成真实天气 API,这里先用本地表模拟""" table = {"北京": "晴 25℃", "上海": "小雨 22℃", "广州": "多云 30℃"} return table.get(city, f"没有{city}的天气数据") def get_current_time() -> str: from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M:%S") TOOL_FUNCS = { "get_weather": get_weather, "get_current_time": get_current_time, } # 第 2 步:用 JSON Schema 描述工具 TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的当前天气。当用户询问天气时使用。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如:北京", } }, "required": ["city"], }, }, }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前日期和时间,不需要参数。", "parameters": {"type": "object", "properties": {}}, }, }, ] def run_tool(name: str, args: dict) -> str: """安全执行工具调用,任何异常都返回错误信息而不是抛异常""" if name not in TOOL_FUNCS: return f"错误:未知工具 {name}" try: return str(TOOL_FUNCS[name](**args)) except Exception as e: return f"工具执行失败:{e}" # 第 3 步:Agent 核心循环 def run_agent(user_input: str, max_steps: int = 10) -> str: messages = [ { "role": "system", "content": ( "你是一个能调用工具的智能体。" "涉及实时信息时先调用工具,根据工具返回结果回答。" "不要编造数据,工具失败要如实告知。" ), }, {"role": "user", "content": user_input}, ] for step in range(max_steps): response = client.chat.completions.create( model="your-model-name", messages=messages, tools=TOOL_SCHEMAS, ) msg = response.choices[0].message # 如果模型没有请求工具调用,说明它准备输出最终答案 if not msg.tool_calls: return msg.content # 把模型的工具请求追加到历史中 messages.append(msg) # 逐个执行工具调用,并把结果以 tool 角色追加回去 for call in msg.tool_calls: fn_name = call.function.name try: args = json.loads(call.function.arguments or "{}") except json.JSONDecodeError: args = {} result = run_tool(fn_name, args) messages.append( { "role": "tool", "tool_call_id": call.id, "content": result, } ) return "已达最大步数,任务未完成。"这段代码的核心就三件事:把模型输出追加进历史、执行工具、把工具结果追加进历史,然后下一轮循环。max_steps是必须有的,没有它,遇到反复失败的任务,Agent 会一直烧 token 烧到天荒地老。
3.3 循环设计里的几个关键决策
第一,循环终止条件要有两个:一个是"模型不再请求调用工具",另一个是"达到最大步数"。前者是正常结束,后者是兜底保护。
第二,异常处理要细。工具执行可能抛各种异常,JSON 解析也可能失败。我的原则是:任何异常都转成一条错误信息回传给模型,让它自己决定下一步,而不是让整个程序崩溃。模型通常能理解"工具执行失败"并换个策略,这比直接把异常抛给用户体面得多。
第三,单轮工具调用可能不止一个。模型在一步里可能同时请求调用多个工具,比如"查北京天气"和"查上海天气"。代码里用循环逐个处理,每个结果都要有独立的tool_call_id对应关系。
4. 工具系统:让智能体真正"动起手来"
4.1 工具的本质,是给模型一组"外接肢体"
工具本质就是"函数 + 描述"。函数负责真正干活,描述负责让模型知道"这个工具什么时候该用、参数怎么传"。工具设计得好不好,直接决定 Agent 能不能把事办成。模型再聪明,你的工具描述写得含糊,它也会选错、传错参数。
我自己的经验是,工具描述要用"一个使用场景 + 参数样例"的写法。别只写"查询订单",要写"当用户询问'我的订单到哪了''帮我查一下订单'时使用,订单编号类似于 A001"。模型就是靠这些具体线索来决定是否调用工具的。
4.2 从零写一个订单查询工具
用一个电商客服场景举例。用户问"我的订单 A001 现在什么状态了",我们需要给 Agent 配一个订单查询工具:
ORDER_DB = { "A001": {"status": "已发货", "eta": "明天送达"}, "A002": {"status": "待付款", "eta": None}, } def query_order(order_id: str) -> str: info = ORDER_DB.get(order_id) if not info: return f"未找到订单 {order_id}" eta = info["eta"] or "待确认" return f"订单 {order_id} 状态:{info['status']},预计 {eta}"对应的工具 Schema 是:
{ "type": "function", "function": { "name": "query_order", "description": "查询订单的当前状态和预计送达时间。当用户询问订单进展时使用。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单编号,例如:A001" } }, "required": ["order_id"] } } }你看这个 Schema,核心就是三块:name是工具唯一标识,description要写清触发场景,properties里每个参数都要给类型和示例。required标出必填字段。
这里有个容易被忽略的点:工具的返回值,最好设计成"模型能直接拿去回答用户"的自然语言格式。如果我返回的是{"status": "SHIPPED"}这种原始结构,模型还要再理解一遍字段含义,增加了出错空间。直接返回"订单 A001 状态:已发货,预计明天送达",模型一句不改就能回给用户。
4.3 工具设计的经验与禁忌
- 一次别暴露太多工具。工具数量多了,模型选错的概率指数上升。一个 Agent 同时挂二三十个工具,效果往往不如挂五六个。我的做法是"按场景分组、按需挂载",用哪个场景才把对应那组工具传进去。
- 描述要写触发条件。只说"查询天气"是不够的,要写"当用户询问某地天气、气温、是否会下雨时使用"。
- 参数必须有描述和示例。
"city": "城市名,例如:北京"和"city": "城市名",对模型来说差别巨大,前者基本不会传错,后者经常传成乱七八糟的值。 - 工具要可重试、尽量幂等。执行结果相同、没有副作用,万一 Agent 重复调用,也不会造成重复扣款、重复发包这种事故。有副作用的操作,要在描述里明确警告模型。
- 返回值要控制长度。数据库查询可能返回几百行,全部塞进上下文会迅速爆掉 token 预算。工具内部先做好聚合、截断,只返回模型决策需要的那部分信息。
5. 记忆管理:别让智能体三秒就失忆
5.1 短期记忆与长期记忆,先管好短期
记忆分两层。短期记忆就是当前会话的 messages 列表,模型能不能记住前面的对话,全靠它。长期记忆则是跨会话沉淀的知识,通常用向量数据库或者普通数据库存下来,需要时检索出来塞回上下文。
最小 Agent 阶段,先把短期记忆管好就够了。你只要记住一个原则:messages 列表就是模型的全部记忆,你往里面放了什么,模型才知道什么。工具结果没有追加进去,模型就不知道工具干了什么;历史消息被截掉了,模型就会"失忆"。
5.2 上下文窗口的预算管理
大模型的上下文窗口是有限且昂贵的。每一轮循环,消息列表都在变长,其中开销最大的通常是工具返回结果——一次数据库全表扫描的结果可能就是几千 token。
我的预算是这么算的:假设模型上下文窗口是 8000 token,我会把 2000 留给系统提示词和用户输入,3000 留给本轮工具调用,剩下 3000 留给对话历史。任何超出预算的内容,都得先压缩再放进去。这个比例不一定适合所有场景,但"先规划、后使用"的态度是对的。
5.3 两个实用的记忆管理策略
策略一:滑动窗口截断。只保留最近的 N 轮对话,更早的直接丢掉。代码很简单:
MAX_HISTORY = 20 def trim_messages(messages, max_history=MAX_HISTORY): system_msgs = [m for m in messages if m["role"] == "system"] other_msgs = [m for m in messages if m["role"] != "system"] if len(other_msgs) <= max_history: return messages return system_msgs + other_msgs[-max_history:]策略二:旧消息摘要压缩。直接丢消息会让模型丢失重要信息,更精细的做法是把旧消息喂给模型生成一段摘要,用摘要替换掉原始内容。这个策略适合"跨很多轮还记得用户偏好"的场景。
def summarize_history(old_messages: list) -> str: """把指定历史消息压缩成一段摘要""" text = "\n".join( f"{m['role']}: {m['content']}" for m in old_messages if isinstance(m.get("content"), str) ) # 调用模型生成摘要,这里省略具体 SDK 调用 summary = llm_summarize(text) return f"[历史摘要] {summary}"工具结果特别长的时候,我还会单独做截断,只保留前 500 个字符加一个"(已截断)"标记。模型需要更多细节时,会自己再调工具去查,这个设计既省 token 又不丢能力。
6. 常见翻车现场与排查思路
6.1 工具参数解析失败
现象:json.loads(call.function.arguments)偶发报错,或者模型传的参数缺字段、类型不对。
原因:模型生成的 JSON 字符串偶尔会不合法,比如少个引号、多个逗号;有时则是 Schema 里的参数描述不清楚,模型猜错了字段名。
排查方法:代码里永远不要把arguments当成必合法 JSON。我习惯在外面包一层 try,解析失败就回传给模型一句"工具参数格式错误,请检查参数名和类型后重试"。实测下来,模型看到这个提示后通常会自己修正。同时,把参数描述写得越具体,这类错误越少。
6.2 Agent 陷入死循环
现象:同一轮工具被反复调用,每次结果一样,Agent 就是不结束。日志里能看到一模一样的 tool call 出现七八次。
原因:通常有两种。一是工具返回的错误信息太模糊,模型看不懂该怎么修正;二是系统提示词里没写"反复失败时及时止损"。
排查方法:除了靠max_steps硬性截断,更好的办法是让工具返回"带建议的错误信息"。比如查询订单失败时,不要只返回"查询失败",而是返回"未找到订单 A001,请确认订单号格式(例如 A001)后重试"。模型拿到这个提示,就能自己纠错。还要在系统提示词里加上一句:"如果同一个工具连续失败两次,停止尝试并如实告知用户。"
6.3 上下文窗口爆掉
现象:API 直接报context length exceeded,对话中断。
原因:工具结果太长、对话轮数太多,历史消息堆积超过了窗口上限。这在小规模测试时很难发现,等到接上真实数据库、一次返回几千行记录时,马上就炸。
排查方法:工具返回值先聚合截断;每条历史消息设长度上限;开启滑动窗口策略。还有一个技巧:给每轮循环的上下文总量设置一个估算函数,超了就主动触发压缩,别等 API 报错。
6.4 模型不调用工具,直接编造答案
现象:用户问"北京天气怎么样",模型直接回答"晴,25度"。看起来挺合理,但其实它根本没调工具,全凭训练时的记忆瞎编。这种"一本正经的胡说八道"在 Agent 场景里是致命的。
原因:三个排查方向——系统提示词没有强调"实时信息必须先调工具";工具描述的触发条件不够清晰;模型本身能力偏弱,理解不了复杂的工具使用规则。
排查方法:先在系统提示词里把规则写死:"涉及天气、时间、订单、价格等实时信息时,必须先调用对应工具,拿到结果后才能回答。"如果还不行,换一个推理能力更强的模型。我自己实测下来,同样一套工具定义,不同模型的表现差异非常明显,选模型时不能只看跑分,要看它在工具调用上的实际表现。
6.5 工具调用一多,消息顺序混乱
现象:Agent 偶尔报"历史消息不合法",或模型回答驴唇不对马嘴。
原因:tool消息没有紧跟对应的assistant工具请求,或者多个工具结果的顺序和请求顺序不对应。各家 SDK 对消息顺序有严格要求,tool消息必须紧跟产生它的那轮assistant消息,tool_call_id必须一一对应。
排查方法:把 messages 列表完整打印出来逐条检查。我写过一个校验函数,专门检查每条tool消息的tool_call_id是否能在前一条assistant消息里找到,发现异常就抛出来。这类问题越早暴露越好,等堆到几百轮再查就麻烦了。
下面是一张排查速查表,我平时调试就直接对着它找方向:
| 现象 | 可能原因 | 快速排查方法 |
|---|---|---|
| arguments 解析报错 | 模型生成非法 JSON | 打印原始字符串,包 try 并回传错误提示 |
| 同一个工具反复调用 | 返回信息模糊 / 无止损规则 | 改进错误信息,加"连续失败两次就停止"规则 |
| 上下文超长报错 | 历史堆积 / 工具结果过长 | 滑动窗口截断,工具结果聚合压缩 |
| 模型编造答案 | 提示词太弱 / 工具描述不清 / 模型能力不足 | 强化系统提示词,换更强模型 |
| 消息顺序报错 | tool_call_id 不对应 | 打印 messages 逐条核对 |
排查这类问题有个笨但好用的习惯:给每一轮循环打日志。记录模型输出了什么、调了哪个工具、传了什么参数、工具返回了什么。不要嫌日志烦,Agent 的调试难度和日志详细程度成反比。我见过不少同学调 Agent 调到崩溃,就是因为只盯着最终结果看,从不看中间过程。
写到这里,一个能跑的最小智能体已经完整落地了。我个人在这个方向折腾了一段时间,最大的体会是:真正决定 Agent 能不能用的,不是"调大模型"这一步,而是工具设计、循环控制、异常处理、记忆管理这些工程细节。你把这些细节挨个踩一遍,再回头看任何框架的源码,都会有一种"原来它封装的就是这些东西"的通透感。
最后再分享一个小经验:刚开始写 Agent,不要追求一次支持十个工具、复杂多轮规划。先把一个工具、一个循环跑得明明白白,再慢慢加复杂度。把最小循环手写一遍,比看十篇框架教程都管用。后面的系列里,我会接着聊多工具协同、规划器设计,还有怎么给 Agent 做靠谱的评测。