平时不少人问过我:“我都能写接口调用了,为什么一说到写 Agent 就感觉使不上劲?” 这个问题我琢磨了很久,答案其实就一句话:你缺的不是调用 API 的能力,而是缺少一个让模型“干活→拿结果→再干活”的循环。会调 API 是把单次对话交给模型,会写 Agent 是把一整串任务的决策权交给模型,这两件事之间差的,就是标题里说的“这一个循环”。
这篇文章会把这个循环掰开揉碎地讲清楚:它为什么是 Agent 的骨架,循环里每一步到底在做什么,以及怎么用几十行代码把它从零手搓出来。不管你是刚接触大模型应用开发,还是已经跟着框架文档写过几个 Demo,这篇文章都能帮你把“Agent”从玄学变成可以一条条讲清楚的技术方案。
1. 会调 API 与会写 Agent 的分水岭:一个循环
1.1 单次调用解决不了的事
先想一个问题:用户问“帮我查一下某城市现在的气温,换算成华氏度,再告诉我今天出门要不要带伞”。这个任务三个子问题互相依赖——不知道气温,就没法算华氏度;不知道天气状况,就没法判断要不要带伞。如果你只写一次模型调用,模型很可能直接回复“我无法获取实时天气信息”,任务就断在这里了。
这正是“会调 API”和“会写 Agent”的分水岭:前者是“一次请求一次响应”,后者是“一个任务多次请求,每轮根据上一轮的结果决定下一步”。哪怕你已经把查天气的接口封装得清清楚楚,只要没有循环,模型就只是一个能聊天的接口壳子,干不了需要多步推理和外部信息的活。
1.2 循环是什么:感知、思考、行动、观察
我习惯把 Agent 的循环拆成四个步骤:感知、思考、行动、观察。感知是把用户问题和历史对话整理成消息列表发给模型;思考是模型根据上下文决定“现在该直接回答,还是需要调用某个工具”;行动是执行模型要求调用的工具函数,拿到真实数据;观察是把工具结果回传给模型,让它在新的信息基础上继续推理。
这四个步骤构成了一个 while 循环:只要模型的决策是“调用工具”,循环就不停;一旦模型觉得信息够了,输出最终答案,循环就终止。这个模式在学术上有个朗朗上口的名字叫 ReAct(Reasoning + Acting),翻译成人话就是“边想边干”。你不需要背什么高深理论,只要记住:Agent 的本质就是“把决定权交给模型,然后在循环里替它跑腿拿数据”。
1.3 与工作流的区别
很多人会把 Agent 和工作流混为一谈,这里必须分清。工作流是把步骤预先编排死:先查天气,再算华氏度,再判断带伞,每一步都是代码写死的顺序。Agent 则不一样,模型自己决定“下一步调用哪个工具”,甚至可以不按你预设的顺序走。同样是“查天气再算温度”的任务,工作流是铁轨,Agent 是司机——铁轨固定了路线,司机可以根据路况临时换道。
这个区别决定了选型方向。步骤固定、路径清晰的场景,用工作流就够了,又稳又省 token;步骤不确定、需要模型临场发挥的场景,比如“帮我安排明天一天的活动,先看天气再看交通再看餐馆”,才轮到 Agent 上场。我见过不少团队第一版就把所有任务都做成 Agent,结果模型反复横跳,成本还高得离谱——后来改成“固定流程走工作流,开放场景走 Agent”,效果反而好得多。
2. Agent 循环的两个关键设计:工具与终止条件
2.1 把“工具”交给模型
循环里最核心的接口设计,是你给模型提供的“工具清单”。现在的 LLM 服务商基本都支持 function calling,也就是在请求里附带一份结构化的工具列表,模型只负责输出“我想调用哪个函数、参数是什么”,真正的执行由你的代码完成。
工具清单怎么写得专业,直接影响 Agent 的可用性。每个工具要说明三件事:函数名要一眼看懂,description 要写清楚“什么时候该用这个工具”,parameters 要把字段类型和取值范围标明白。举个例子,查天气工具的参数不能只写“city: string”,要写“city: string,城市名称,如‘北京’‘上海’,不要带‘市’字”。模型读的是文字,工具描述就是它的操作手册,手册写得越清楚,它就越少犯低级错误。
2.2 让模型自己决定下一步
循环能不能跑起来,还取决于你怎么设置“决策开关”。一般 tool calling 接口都有类似“是否强制调用工具”的参数,默认是 auto,意思是让模型自己判断“这轮该调用工具还是直接回答”。这个默认值对大多数 Agent 场景都合适,因为它把“下一步做什么”的自主权完全交给了模型。
但有个坑要注意:有些开发者为图省事,把 tool_choice 设成“必须调用工具”,结果模型被逼着每轮都调一次,最后任务完成了还在空转。反过来,如果你发现模型明明需要工具却不调用,先别急着改设置,大概率是工具描述写得太模糊,模型压根不知道有这号工具可用。设置只是兜底,工具描述才是决定模型行为的主要因素。
2.3 循环停在哪里
循环不能永远跑下去,得有终止条件。终止条件有两个:一个自然发生,模型不再返回工具调用,输出一段最终回答;另一个是程序兜底,设一个最大迭代次数,比如 8 轮,超过就直接返回“任务未完成”。
最大迭代次数怎么定,我建议看任务复杂度。单工具能解决的简单问题,5 轮足够;涉及多工具依赖的复杂任务,10 轮左右。不要为了省 token 设成 3 轮,我试过,模型稍微多思考一步就被强砍,体验极差。也不要设得太高,比如 20 轮以上,模型如果一直在调工具不收敛,每一轮都在烧钱。8 是一个比较稳的起步值,跑一段时间后再根据日志调整。另外,判断“模型是不是在空转”有一个经验信号:同一轮调用出现重复的工具名、参数来回变但不收敛,就要考虑在循环里加一个“如果和上一轮完全重复就中断”的检测。
3. 从零手搓一个最小 Agent 循环
3.1 准备工作:接口与工具函数
接入 Agent 循环之前,你需要一个大模型接口。现在各家服务商大多都支持 tool calling,接口风格大同小异,核心都是“传入消息列表和工具清单,返回模型决策结果”。下面代码里的llm_client.chat(messages, tools)就是这一调用的抽象,你换成自己接入的服务即可。
接下来准备两个示例工具:一个查天气,一个做四则运算。查天气为了方便演示,直接用写死的假数据,真实项目里替换成对应的天气服务接口即可;计算器我用正则限制了字符范围,只允许数字和四则运算符号,避免任何注入风险。
import json # ---- 工具函数 ---- def get_weather(city: str): # 演示用假数据,真实项目替换为天气服务接口 fake_data = { "北京": {"temperature": 26, "condition": "晴"}, "上海": {"temperature": 30, "condition": "多云"}, "广州": {"temperature": 33, "condition": "阵雨"}, } data = fake_data.get(city) if not data: return {"error": f"未找到城市 {city} 的天气数据"} return {"city": city, **data} def calculator(expression: str): import re # 只允许数字、四则运算符、括号和空格 if not re.fullmatch(r"[0-9+\-*/(). ]+", expression): return {"error": "表达式包含非法字符"} try: return {"result": eval(expression)} # 仅用于白名单校验后的计算 except Exception as e: return {"error": f"计算失败: {e}"} # ---- 工具清单(发给模型的结构化描述) ---- TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气,返回温度(摄氏度)和天气状况", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,如'北京',不要带'市'字"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculator", "description": "执行四则运算表达式,返回数值结果", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,如 '(26*9/5)+32'"} }, "required": ["expression"] } } } ]3.2 核心循环代码
下面这段就是 Agent 的心脏。整个循环的逻辑其实很简单:把消息发给模型;如果模型要求调用工具,就执行工具并追问;如果模型给出了最终回答,就返回。注意两个容易写错的细节:带工具调用的模型消息必须完整放回消息列表,工具执行结果要附带对应的调用 ID。
# 这里替换为你实际接入的大模型服务 def llm_chat(messages, tools=None): # 调用你的模型服务,返回对象包含: # content: 模型生成的自然语言内容 # tool_calls: 模型请求的工具调用列表(每项有 id, function.name, function.arguments) pass SYSTEM_PROMPT = ( "你是一个能调用工具的智能助手。" "当用户的问题需要外部数据或计算时,请调用合适的工具来获取结果;" "当所有信息都已齐备时,停止调用工具,直接用中文给用户完整、易读的最终回答。" ) TOOL_FUNCTIONS = { "get_weather": get_weather, "calculator": calculator, } def execute_tool(name: str, args: dict): if name not in TOOL_FUNCTIONS: return {"error": f"未知工具: {name}"} return TOOL_FUNCTIONS[name](**args) def run_agent(user_input: str, max_iterations: int = 8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for i in range(max_iterations): message = llm_chat(messages, tools=TOOLS) # 模型决定调用工具 if message.tool_calls: # 先把模型的工具调用消息放进历史,再逐条执行 messages.append({ "role": "assistant", "content": message.content, "tool_calls": [ { "id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments} } for tc in message.tool_calls ] }) for tc in message.tool_calls: try: args = json.loads(tc.function.arguments) result = execute_tool(tc.function.name, args) except Exception as e: result = {"error": str(e)} print(f"[调试] 第 {i+1} 轮调用工具: {tc.function.name}({tc.function.arguments})") messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False), }) continue # 模型不再调用工具,视为最终回答 return message.content return f"达到最大迭代次数 {max_iterations},任务未完成,请检查工具调用是否合理。" if __name__ == "__main__": print(run_agent("北京现在多少摄氏度?换算成华氏度是多少?今天出门需要带伞吗?"))3.3 代码逐段解读
这段代码最关键的不是“调用了模型”,而是消息列表的组装方式。模型每次决策都依赖完整的历史,所以必须把每一轮的工具调用请求都追加进messages。这里有个很多新手会犯的错:拿到模型返回后不保存原始消息,只把工具执行结果拼成普通文本塞回去,接口直接报错,因为你破坏了 tool calling 的回话格式要求。
工具结果的content我用json.dumps序列化,尽量保留结构化数据。模型读 JSON 比读散装的“温度26度天气晴”更容易对齐字段。如果你发现模型在后面的轮次里把工具结果里的数字看错,优先检查是不是 content 格式不够结构化。另外,tool 消息里的tool_call_id必须和模型返回的调用 ID 一一对应,这个 ID 是模型用来对号的,错一个接口就会拒绝。
3.4 一个完整运行示例
拿代码跑一遍“北京现在多少摄氏度?换算成华氏度是多少?今天出门需要带伞吗?”任务,你会看到这样的执行轨迹:
[调试] 第 1 轮调用工具: get_weather({"city": "北京"}) [调试] 第 2 轮调用工具: calculator({"expression": "(26*9/5)+32"})第一轮模型查天气,拿到“温度 26 摄氏度、晴天”;第二轮模型把 26 代入华氏度公式算出结果;第三轮模型发现天气是晴天,不再调用任何工具,直接输出“北京当前 26 摄氏度(华氏度),天气晴朗,不需要带伞”。整个过程没有任何一步是代码预先写死的,全凭模型在循环里自己串联——这就是“这一个循环”的威力。
4. 把循环跑稳的实战经验
4.1 工具描述决定 Agent 的上限
同一个循环,换一套工具描述,效果天差地别。我给这个系列总结了一条经验:工具描述不是写给代码看的,是写给模型看的。描述里要包含三层信息——这个工具解决什么问题、什么时候该用、参数怎么填才合法。比如计算器工具的 description 里,我特意写“执行四则运算表达式,返回数值结果”,后面接一个参数示例(26*9/5)+32,模型看到示例就知道该怎么格式化表达式,瞎传字符串的概率大幅下降。
另一个经验是工具命名要符合直觉。get_weather比query_weather_info_v2好使,calculator比math_eval好使。模型对语义清晰的函数名有天然的偏好,命名越像真实 API,它理解得越快。说到底,模型是用自然语言理解工具列表的,你给它看一堆a1_b2_c3这种函数名,它大概率会选择性忽略。
4.2 上下文膨胀与 token 管理
循环轮次多了,上下文必然膨胀。假设每个工具结果 200 token,8 轮就是 1600,再加上系统提示、用户问题和历史回话,三四千 token 轻轻松松。在长任务里,上下文超长还会影响模型对早期信息的注意力,它可能忘了用户最开始提的约束,开始自作主张。
这里我建议两条路。轻量方案是控制工具结果的大小:能返回摘要绝不返回原始数据,能返回 5 条绝不返回 50 条。进阶方案是给循环加“历史压缩”:当消息数超过阈值时,把前面的消息交给模型做一次总结,用一段摘要替换掉原始历史。需要注意,执行 history 压缩时,被压缩的消息里如果包含 tool_calls,必须小心处理,不能把不完整的历史片段留给模型——我踩过这个坑,压缩后模型开始胡言乱语,就是因为丢了工具调用的对应关系。
4.3 异常与空转识别
循环里最难受的问题不是报错,而是“模型在瞎转”。工具返回了错误,模型不换思路,反复用同样的参数调用同一个工具;或者模型已经拿到了答案,但还是继续调用工具。针对前者,我在execute_tool外面包了一层异常捕获,把错误信息作为正常文本回传给模型,让它自己读取错误并对症下药。针对后者,我建议加一个重复调用检测:记录上一轮的工具名和参数,如果本轮完全一致,直接中断并把上一轮的结果当作最终可用数据,同时提示“检测到重复调用,已终止循环”。
4.4 常见问题速查表
| 症状 | 可能原因 | 处理办法 |
|---|---|---|
| 模型不调用工具,直接硬答 | 工具描述不清,模型不知道有工具可用 | 检查 TOOLS 定义,把描述写成“何时该用” |
| 模型反复调用同一工具不收敛 | 缺少终止指令,或上下文里已有答案但模型没发现 | system prompt 增加“信息足够就停止”,并加重复调用检测 |
| 接口报错 tool_call_id 不匹配 | 消息顺序错乱,assistant 工具调用消息未完整保留 | 先追加 assistant 原始消息,再追加 tool 结果 |
| 工具参数解析失败 | 模型生成的 JSON 不合法,或字段名不符 | try/except 捕获,把“解析失败”返回给模型重试 |
| 上下文超长报错 | 循环轮数太多,工具结果太大 | 限制 max_iterations,精简工具返回,必要时做历史压缩 |
5. 一点个人体会
这个循环跑通之后,你会突然发现以前觉得“框架好神秘”的东西其实就那么回事:Agent 不是一个大型软件,它就是 while + 模型接口 + 工具函数这三块的组合。真要说有什么门槛,不是代码怎么写,而是你怎么替模型考虑——它的决策依赖什么信息、它会不会被模糊的描述带偏、它需要什么止损机制。我自己带人做这个系列时,第一步都要求他们把工具描述逐字逐句写清楚,写完了再谈其他优化。这步做好了,后面加记忆、加规划、加多工具协作都会顺手很多,下一篇系列我们就顺着这条路继续往下走。