最近被问得最多的一个问题,不是"大模型能干什么",而是"Agent到底怎么写才能跑起来"。市面上的概念文章确实不少,讲到架构一套一套的,可一到代码就断片了。我自己刚接触AI Agent那阵子也是这个状态:能跟人聊清楚什么是工具调用,什么是Agent循环,但真让我写一个能自己去查天气、调接口、把多步任务跑完的小程序,我卡了整整一个周末。所以这篇东西不聊虚的,就从概念到可运行代码完整走一遍,适合那些已经写过Python、想亲手做一个Agent Demo的开发者。你会发现,一个最小可运行的Agent,代码量比你想的少得多。
1. 先分清Agent和Chatbot:差的不只是名字
1.1 聊天接口只负责"说",Agent负责"做"
很多第一次动手的人会把Agent理解成"换了套提示词的聊天机器人",这其实是最大的误解。你往聊天接口丢一句话,它回一段话,无论这段话说得多漂亮,在工程上它只完成了一次HTTP请求,不产生任何外部副作用。它可能告诉你"明天会下雨,建议带伞",但它不会去帮你查实时天气,更不会帮你做任何操作。
Agent的区别在于,模型的输出会被程序当作"指令"来解析和执行。模型说"查一下云州市的天气",程序不是把这句JSON打印出来,而是真的去调用天气服务,把返回结果再塞回给模型,让模型基于新信息继续推理。这意味着模型输出从"给人看的话"变成了"给程序执行的指令",这一步跨越,才是Agent化的分界线。
1.2 一个闭环长什么样
Agent的典型工作方式可以概括成一个循环:思考、行动、观察、再思考。这句话不是空话,拆开看就是代码里最朴素的结构。
假设你的任务是"帮我安排明天下午两点的会议室",一次完整的Agent运行大概长这样:
- 模型思考:需要先查一下哪些会议室空闲,所以调用
query_rooms("明天", "14:00")这个工具。 - 程序执行工具,返回一个空闲列表,比如
[{"room": "A201", "status": "空闲"}]。 - 模型看到这个结果,继续思考:A201空闲,那就订它,于是调用
book_room("A201", "明天", "14:00")。 - 程序执行预订,返回
{"success": true}。 - 模型最后整理出一句话告诉用户:"已帮你预订A201,明天下午两点。"
这个"模型提要求、程序去执行、结果回传、模型再决定"的闭环,才是Agent和普通聊天接口最本质的差别。Chatbot做的是单轮问答,Agent做的是多步决策加行动。
不过我也想说一句公道话:不是所有大模型应用都要上Agent。如果任务只是单轮问答、不需要查外部信息、不需要执行动作,那就别强行套循环,模型直接回答一句就可以结束了。Agent架构是手段,不是目的,这个判断标准在你设计任何系统时都会反复用到。
2. 最小可运行Agent的四块积木:大脑、手、循环、记忆
我见过不少开发者一上来就引入开源Agent框架,结果光理解框架的抽象概念就花了两周,出了问题也不知道是框架的Bug还是自己配置不对。我的建议是,第一个Demo一定自己手写,代码量真的不大,等摸清原理再上框架,出问题也能准确定位是哪一层的事。
一个最小Agent,拆到底就四个部分。
2.1 大脑:大模型调用层
这是最外层也是最简单的部分,本质上就是封装一次模型API调用。你需要一个函数,接收消息列表,返回模型文本:
import httpx API_URL = "https://your-model-endpoint.example/v1/chat/completions" API_KEY = "sk-your-key" MODEL_NAME = "your-model-id" def call_llm(messages, temperature=0): payload = { "model": MODEL_NAME, "messages": messages, "temperature": temperature, } resp = httpx.post( API_URL, headers={"Authorization": "Bearer " + API_KEY}, json=payload, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]有几个参数要特别留意。temperature在Agent循环里一定要设成0。原因很简单:工具调用需要确定性,如果同样的上下文每次输出不一样,模型一会儿想调工具一会儿不想调,你的循环逻辑根本没法稳定。另外一个容易被忽略的是timeout,工具结果回填后模型要重新读一遍,这一步通常不慢,但碰上长上下文或服务端波动,60秒不算夸张。
底模选择上,现在主流商用模型接口基本都支持工具调用,开源模型也有不少可用版本。选型时主要看一点:它对"结构化输出"的服从性好不好。这直接决定了后面你会不会频繁遇到"让它输出JSON它偏要说话"的情况。
2.2 手:工具注册表
模型看不到你的Python函数,它只看得见你对工具的描述。所以每个工具至少要包含三样东西:函数本体、参数schema、一段写给模型看的说明。
from datetime import datetime def get_current_time(): """获取当前日期和时间,精确到秒。""" return {"result": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} def get_weather(city: str): """查询指定城市的当前天气。""" mock_weather = { "云州市": {"温度": 18, "天气": "晴", "风力": "3级"}, "海州市": {"温度": 22, "天气": "多云", "风力": "2级"}, } data = mock_weather.get(city, {"温度": "未知", "天气": "未知", "风力": "未知"}) return {"city": city, **data}然后把这些工具集中注册起来,同时给模型一份schema清单:
TOOLS = { "get_current_time": get_current_time, "get_weather": get_weather, } TOOL_SCHEMAS = [ { "name": "get_current_time", "description": "获取当前时间,不需要参数。", "parameters": {}, }, { "name": "get_weather", "description": "查询指定城市的当前天气,参数city为城市名。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如'云州市'"} }, "required": ["city"], }, }, ]这段描述写得好不好,直接决定模型会不会调、调得准不准。我自己踩过的坑是:一开始偷懒把描述写成"天气查询",结果模型经常不给参数,或者把城市名拼错。后来改成"查询指定城市的当前天气,参数city为城市名,如'云州市'",调用准确率立刻上来了。描述里带上参数示例,是成本最低的优化手段。
2.3 循环:Agent主循环是灵魂
主循环就是把"思考-行动-观察"翻译成代码。打一个比方,整个过程像你跟模型打乒乓球:模型提出一个工具调用要求,你执行并返回结果,模型看到结果再决定下一步。直到模型认为问题已经解决,或者达到最大步数。
def run_agent(user_input, max_steps=6): messages = [ {"role": "system", "content": SYSTEM_PROMPT.format( schemas=json.dumps(TOOL_SCHEMAS, ensure_ascii=False) )}, {"role": "user", "content": user_input}, ] for step in range(max_steps): model_text = call_llm(messages) messages.append({"role": "assistant", "content": model_text}) tool_call = parse_tool_call(model_text) if tool_call is None: # 模型没有输出工具指令,视为最终回答 return model_text tool_name = tool_call.get("tool_name") args = tool_call.get("args") or {} if tool_name not in TOOLS: messages.append({ "role": "user", "content": f"工具 {tool_name} 不存在,请从可用工具中重新选择。", }) continue try: result = TOOLS[tool_name](**args) except Exception as e: result = {"error": str(e)} # 工具结果回填,注意这里用独立的角色 messages.append({ "role": "tool", "name": tool_name, "content": json.dumps(result, ensure_ascii=False), }) return "已达最大步骤数,任务终止。"max_steps是必须有的。没有它,一旦模型陷入"查了再查"的循环,你的程序就永远跑不完。具体设多大看任务复杂度,普通查询任务6步足够,带多步规划的一般10到15步也能收住。
2.4 记忆:消息列表就是短期记忆
Agent在对话过程中必须记住之前发生了什么,这个记忆在工程上就是不断累积的消息列表。每一轮模型输出、每一个工具执行结果,都按顺序追加进去,下次调用API时全部带上。这就是短期内最朴素的记忆实现。
工具结果回填时,必须使用独立的会话角色,也就是"role": "tool",而不是简单塞成"role": "user"。这样设计的原因很实际:模型需要区分"用户原本说的话"和"程序执行工具返回的系统性结果",两种信息在后续推理中的权重完全不同。如果你把所有内容都混成用户消息,多数模型会开始糊涂,分不清哪些是它自己调用得来的观察结果。
第一版Agent不需要考虑向量记忆、摘要记忆这些东西,完整消息列表完全够用。等后面的工具多了、对话轮数长了,再考虑记忆优化不迟。
3. 第一版代码:从零写一个能查天气的Agent
下面给出一个完整可跑的Demo。假设任务目标是:"查一下云州市现在的天气,顺便告诉我当前时间。"跑通这个例子之后,你基本就掌握了Agent的最小闭环。
3.1 组装System Prompt
把工具schema和调用规则写进System Prompt,是整个Demo里最关键的提示词工程:
SYSTEM_PROMPT = """ 你是一个会把任务拆成步骤并调用工具的助手。 你有以下工具可用,工具名和参数必须严格匹配: {schemas} 当你需要调用工具时,只输出一个JSON对象,格式如下: {{"tool_name": "工具名", "args": {{"参数名": "参数值"}}}} 当你不需要调用工具时,直接输出普通回答文本。 不要输出JSON以外的任何内容,不要加解释。 """.strip()注意这里的措辞。我给模型三条硬约束:工具名和参数严格匹配、需要工具时只输出JSON、不需要工具时直接说话。少任何一条,模型就可能开始自由发挥。
3.2 解析模型输出
模型的输出不总是干净的JSON,所以解析函数要有容错能力:
import json def parse_tool_call(text): text = text.strip() # 有些模型会输出代码块包裹的JSON,先剥掉 if text.startswith("```"): text = text.strip("`") if text.startswith("json"): text = text[4:].strip() # 从文本中截取第一个 { 到最后一个 } 之间的内容 start, end = text.find("{"), text.rfind("}") if start == -1 or end == -1: return None try: obj = json.loads(text[start:end + 1]) except json.JSONDecodeError: return None if "tool_name" in obj and "args" in obj: return obj return None这段代码判断标准是:能解析出JSON,并且包含tool_name和args两个字段,才认为是工具调用。否则一律当成普通回答。这个兜底逻辑很重要,它防止你把模型的闲聊误判成指令。
3.3 跑起来看什么现象
把前面工具函数、主循环、解析函数拼在一起,用下面的入口测试:
if __name__ == "__main__": print(run_agent("查一下云州市现在的天气,顺便告诉我当前时间。"))如果你的模型和API配置正常,运行过程应该是这样的:
- 第一轮模型大概率先输出
{"tool_name": "get_current_time", "args": {}}。 - 程序执行后回填当前时间。
- 模型看到时间,可能再输出一个查天气的工具调用。
- 程序继续回填天气结果。
- 模型最后不再输出JSON,而是整理成一句话,比如"云州市当前18度,晴,3级风;当前时间是……"。
判断Agent是否真正跑通,就看一个链条是否完整:模型输出JSON、程序执行、结果回填、模型基于结果继续推理。只要这个闭环能走通,恭喜你,你已经有一个真正会"做事"的Agent了。
如果模型第一轮就直接输出了一段话而不是JSON,说明要么System Prompt没写好,要么你用的模型工具调用能力偏弱。别急着改代码,先检查这两点。
4. 从Demo到能用:工具解析、回填与停下来的时机
第一版能跑只是起点。从"能跑"到"能用",中间隔着一堆细节,这一节全是我实际调试时遇到过的。
4.1 工具调用的解析要够"脏"
现实世界里的模型输出比你想象的乱。它可能在JSON前面加一句"好的,我来查一下",也可能在JSON后面补一句"已为您查询"。更不能忽略的是,有些模型会输出Markdown代码块把JSON包起来。这些都要求解析函数有足够的容错处理。
我建议的解析策略是三级回退:先尝试整体json.loads;失败就从文本里截取从第一个{到最后一个}的内容再解析;再不行就找tool_name和args两个关键字段做正则提取。宁可解析失败返回None,让程序把输出当成普通回答,也不要硬性解析导致执行了错误的工具。
另外要注意args字段的类型。有些模型会输出字符串形式的args,比如"args": "get_current_time",直接**args会炸。所以解析完最好做一次类型校验,isinstance(args, dict)不过就直接回填错误信息,让模型重新给参数。这种脏数据防御在真实环境里几乎天天碰到。
4.2 工具结果回填的细节
工具执行完,结果要序列化成JSON字符串回填给模型。这里有两个细节值得说。
第一,保持结构化。工具返回Python字典后,用json.dumps序列化,不要转成"查询成功,温度为18度"这种自然语言。原因很简单,结构化数据信息密度高、歧义少,模型处理起来更稳。你甚至可以测试对比一下:把结果换成自然语言后,模型的理解准确率普遍会下降。
第二,结果要做截断。一个查询工具可能返回上千条记录,全部塞进上下文,几轮之后窗口就被撑爆了。我习惯给工具结果设一个字符上限,超出部分截断,并在末尾加一句提示,让模型知道"结果被截断,如需更多数据请使用分页参数"。让工具支持分页或聚合操作,是治本的做法。
消息结构上,工具结果回填的格式必须是{"role": "tool", "name": "工具名", "content": "结果JSON字符串"}。不同模型对这段的字段名可能有细微差别,有的用tool_call_id,有的用name,接入新模型时第一时间查文档确认,别想当然。
4.3 停下来的时机
主循环的停止条件只有三个,逻辑非常简单:
- 模型本次输出没有解析出工具调用,直接返回它的话作为最终回答。
- 到达
max_steps上限,强制终止并提示。 - 检测到"连续两次调用相同工具、相同参数",判定为死循环,提前终止。
第三个条件是进阶做法,我强烈建议加上。实测中模型偶尔会陷入"查一下、再查一下"的怪圈,明明结果没变,它还重复调同一个工具。这时候算法上做个简单的重复检测,比单纯等max_steps耗完要体面得多。
4.4 工具异常时的回填策略
工具执行过程中抛异常,不要直接让整个程序崩溃。正确做法是把错误信息封装成JSON回填给模型,让它自己调整策略。比如查天气接口超时,回填{"error": "timeout"},模型可能会说"天气查询暂不可用,建议你过会儿再试"。
这里的隐含逻辑是,把"程序异常"转化为"模型可理解的信息"。Agent的容错能力,很大程度取决于你喂给它的反馈质量,而不是模型本身有多聪明。
5. 实测最容易翻车的四个坑及排查思路
这一节我不直接给结论,而是把排查链路完整走一遍,因为下一次你遇到类似问题,照着这个思路走会更快找到根因。
5.1 模型死活不调用工具
现象是模型直接答非所问,比如问"云州市天气",它回答"云州市天气不错",根本没走工具。
排查链路是这样的:先看System Prompt里工具schema是否真的传进去了,有的框架会丢参数;再看模型本身支不支持工具调用,换一个大点的模型试试;最后检查temperature,如果调成了0.7甚至更高,模型可能"越想自由发挥越不遵守格式"。把temperature设0,同时给一个few-shot示例,绝大多数情况都能解决。
5.2 死循环和重复调用
现象是日志里出现连续多轮相同的工具调用,或者模型反复说"让我再查一次确认"。
我遇到过的根因有两个。一是解析逻辑把普通回答误判成了工具调用,比如模型回答里恰好提到了一个JSON片段,程序就傻乎乎地执行了。二是工具返回的结果跟模型预期不符,比如查询结果为空,模型总觉得是自己参数给错了,于是一直换参数重试。
第一个根因靠改进解析容错解决,第二个根因得在工具侧兜底:返回空数据时明确写{"result": [], "message": "当前无匹配记录"},并建议模型"不要重试,如实告知用户"。加上重复调用检测,这个坑基本能堵住。
5.3 上下文膨胀
现象是越跑越慢,突然开始报"请求超过模型上下文长度"。
原因很直观:每轮工具调用都要塞进消息列表,几十轮下来上下文就爆了。处理策略按优先级排列:第一,只保留最近N轮消息;第二,把早期对话压缩成一段摘要;第三,长期信息存到外部存储,按需检索回来。第一版Demo用第一种就够了,后面再考虑摘要和向量检索。
一个实用的小技巧:工具结果如果是列表,只保留前几条加一个总数描述,而不是全量回填。比如"共找到327条记录,前5条如下……",模型完全能据此工作,上下文却省下一大截。
5.4 工具结果质量差,幻觉被放大
现象是模型一本正经地编数据,比如工具返回"温度未知",模型却回答"温度约20度,适合出行"。
这句话背后的逻辑值得想清楚:Agent的推理基于工具结果,工具给的是脏数据,模型就会把脏数据包装成漂亮话。所以工具输出侧的清洗比模型侧更重要。所有字段都要有明确的缺省约定,查不到就写"未知"或"记录不存在",并且明确告诉模型"未知数据不要猜测"。
我把这类问题整理成一张速查表,方便后面排查参考:
| 现象 | 最常见原因 | 优先处理 |
|---|---|---|
| 模型不调工具 | Prompt没写清/schema缺失/模型能力弱 | 检查System Prompt,加few-shot,temperature设0 |
| 重复调用同一工具 | 结果与预期不符/解析误判 | 工具结果给明确缺省值,加重复调用检测 |
| 上下文超限 | 消息列表无限累积 | 裁剪历史,截断工具结果 |
| 结论编造数据 | 工具返回脏数据 | 工具输出清洗,明确"未知"约定 |
6. 下一站:框架、多Agent和记忆的衔接点
6.1 什么时候该引入Agent框架
手写Demo跑通之后,你会很快遇到这样的问题:工具数量超过十个,需要一个统一的工具注册和权限管理;任务开始拆成多步子任务,需要规划器;还要流式输出、断点续跑、人工审核节点。这时候引入开源Agent框架就变得合理了。框架帮你封装了编排、记忆、插件体系,但代价是排查问题的链路变长。
我的建议是,先手写、后框架,这个顺序别颠倒。手写让你理解每一步发生的原理,框架只是把这套原理工程化。直接上框架,出了问题你连"是模型的问题还是框架的问题"都分不清。
6.2 三个值得继续加码的方向
第一个方向是规划能力。当前Demo是"模型自己决定下一步",这属于单Agent自由发挥。下一步可以做"规划器加执行器":规划器先拆解任务为若干子目标,再逐个执行,最后汇总结果。这种结构化规划在任务步骤多、流程固定的场景里可靠得多。
第二个方向是记忆体系。短期记忆用滑动窗口,中长期记忆用向量检索加摘要压缩。当对话跨多天、用户频繁回到同一任务时,没有长期记忆的Agent会表现得像失忆一样。
第三个方向是安全与预算。工具调用要有白名单,执行敏感操作要加人工确认,每一轮调用要统计成本,代码执行类工具要跑在沙箱里。这些问题初期不明显,一旦Agent跑到生产环境,就是必须跨过的门槛。
我自己的体会是,Agent的工程难点从来不在"让模型说出要调哪个工具",而在于你为这个循环准备了什么样的工具、反馈和边界。把工具做好、把回填做规范、把停止条件设计严谨,一个最小Agent能处理的事情远超你的预期。
最后再分享一个小习惯,从第一次写Agent到现在我都在用:所有工具调用统一走JSON输入、JSON输出,不要让工具直接print,也不要返回随意格式的字符串。因为后面无论你是接日志、接记忆、接框架,结构化数据几乎是所有后续功能共同的地基。现在多花一分钟守住的规范,后面能帮你省下几个小时。