从API调用到Agent:核心差异是那一个循环
1. 会调 API 和会写 Agent 的分界线不在模型能力1.1 一次成功的 API 调用不等于解决了一个任务之前在一个技术讨论群里看到一条提问发帖的人说“我已经会调大模型 API 了为什么还是写不出一个像样的 Agent”底下的回复五花八门有人让他去套框架有人让他背提示词模板但真正让我印象深刻的只有一句“你缺一个循环。”当时我也没太理解这句话。直到自己把一个又一个 if-else 判断改成循环之后才意识到“会调 API”和“会写 Agent”之间的那条分界线恰恰就是这个循环。说难听一点多数人写的所谓 Agent本质上只是把用户的问题丢给大模型再把模型的回答打印出来。这个行为和用搜索引擎没什么区别只不过搜索引擎返回的是网页大模型返回的是文字。举个最常见的场景。你想让程序“帮我查一下某只股票今天的收盘价再结合近期走势给个判断”。如果你只是做一次 API 调用模型大概率会回答“我没有实时行情数据”或者更糟它会基于训练数据里的旧信息给你编一个数字。这时候你可能会想那我就把行情接口的返回结果拼进提示词再问一次模型不就行了确实可以但那已经是两个循环了第一次拿行情第二次做判断。你会发现只要任务稍微复杂一点就需要“多次调用模型 多次执行代码”交错进行。这个交错过程就是 Agent 循环。所谓 Agent不是某个特定的模型也不是某个神秘的框架它只是把“模型生成判断、程序执行动作、观察结果、再把结果交给模型判断”这个闭环跑起来。会调 API 的人也能做到一次“动作”但只有会写循环的人才能把一个多步骤任务完整地跑完。1.2 “循环”不是比喻是代码里实实在在的 for“差的就是这一个循环”这句话如果换成代码看其实特别直白。普通调用大概是这个样子messages [{role: user, content: 帮我处理一下这份材料}] result call_llm(messages) print(result)Agent 的骨架则是这个样子messages [{role: system, content: 你是任务助手}] for step in range(max_steps): result call_llm(messages) if result.is_final: return result.text observation execute_tool(result.tool_calls) messages.append(observation)两者唯一的区别就是多了一个 for 循环。但这个循环一旦加进去整个程序的控制流就变了模型不再只是“被问一次、回答一次”的接口而是变成了一个会在循环里反复思考、行动、看结果、再思考的执行主体。你写的代码也不再是一条直线而是一个不断把模型输出喂回给模型自己的闭环。很多人写 Agent 时觉得困难不是因为大模型 API 不会用而是因为他们的思维还停留在“请求-响应”模式。只要把思维方式换成“循环判断-行动-观察-再判断”很多卡住的问题会一下子通顺。2. 这一个循环到底在循环什么拆解 Agent 的最小闭环2.1 四步闭环模型提方案程序去执行观察回填再决策Agent 循环看起来简单但它内部其实有四个固定动作缺一个都不成立。第一步是模型生成决策。模型读完当前的消息列表后要么输出一个“最终答案”要么输出一个“工具调用请求”。这个工具请求可能像query_stock(symbol某代码)也可能像send_email(to..., content...)。在普通 API 调用里我们只会取模型的文字回答在 Agent 循环里我们还要判断它是否想调用工具。第二步是程序执行动作。模型自己不会去查行情也不会真的发邮件它只是“提议”做这件事。真正执行动作的是你的代码。这也是很多人容易搞混的地方工具不是模型调的是你根据模型输出调的。第三步是观察回填。工具执行完会有一个返回值比如行情接口返回了价格、邮件接口返回了发送状态。这个返回值要被转成文字或结构化数据重新塞回消息列表里让模型能看到“上一步发生了什么”。第四步是再次决策。模型看完观察结果后继续输出下一个动作或者输出最终答案。如此循环直到它认为任务完成。这四个动作合在一起就是 Agent 的最小闭环。你可以不用框架不用复杂的抽象只用几行 Python 就能实现。真正复杂的是怎么把每一步的输出处理干净怎么让模型理解观察结果以及怎么避免它在一个坑里反复打转。2.2 普通 API 调用和 Agent 循环到底差在哪用一个表格把这层区别摊开看会更清楚维度普通 API 调用Agent 循环输入输出一次提问一次回答多条消息串 多轮工具观察结果流程控制用户或脚本决定下一步程序根据模型输出决定下一步终止条件模型回答完就结束模型明确给出最终答案或达到轮数上限工具使用依赖提示词里写“请你调用”模型输出结构化工具请求由程序分发执行典型场景翻译、摘要、单轮问答多步骤任务、实时信息查询、需要执行操作的助手请注意我并不是说普通 API 调用不好。很多场景下一次调用就够了硬上 Agent 循环反而是浪费时间和 token。但如果你想做的是一个能自己规划步骤、能调用外部工具、能根据执行结果调整方案的助手那就绕不开这个循环。理解了这个区别后面所有代码写起来都有方向。3. 手写一个能跑的最小 Agent 循环代码比你想的更短3.1 最小循环骨架用消息列表当“工作记忆”Agent 循环的核心数据只有一个消息列表。模型的所有上下文包括用户任务、历史判断、工具观察结果都以消息的形式存在这个列表里。每次循环开始前我们把完整列表发送给模型模型返回后我们把它的输出追加进去如果它调用了工具我们再把工具的观察结果也追加进去。下面是一个最小实现我故意没用任何框架import json def call_llm(messages, toolsNone): # 这里替换成你自己的模型接口封装 # 假设返回对象有两个字段 # text: 模型输出的文字内容 # tool_calls: 模型要调用的工具列表没有则为空 return model_client.chat(messagesmessages, toolstools) def run_agent(task, max_steps10): messages [ {role: system, content: 你是任务助手。需要时可以调用工具任务完成时直接输出最终答案。}, {role: user, content: task}, ] for step in range(max_steps): resp call_llm(messages, toolstool_schemas) # 先把模型这次输出追加进消息列表 messages.append({ role: assistant, content: resp.text, tool_calls: resp.tool_calls, }) # 如果模型没有请求调用工具说明它认为任务结束了 if not resp.tool_calls: return resp.text # 否则逐个执行模型请求的工具 for call in resp.tool_calls: observation run_tool(call.name, call.arguments) messages.append({ role: tool, name: call.name, content: json.dumps(observation, ensure_asciiFalse), }) return 达到轮数上限任务未能完成这段代码短到让人觉得“就这”但它确实已经具备了一个 Agent 的全部核心要素。当然真实生产环境里还有很多细节要处理比如工具注册、异常捕获、上下文裁剪、权限控制。不过这些都是在同一个循环骨架之上做加法不是另起炉灶。3.2 轮数上限不是摆设是 Agent 的保险带我见过不少人写 Agent 时完全不设置轮数上限结果模型在一个错误判断里反复调用同一个工具直到把预算耗尽。轮数上限的作用不是限制模型的能力而是给循环一个明确的退出条件。任务“完成”的标准不能只靠模型自己说。很多模型在上下文信息不足时会倾向于输出一个“看似合理但实际上并不可靠”的答案。比如它查不到数据就直接编一个数字当作最终答案。所以更稳妥的做法是允许模型提前结束但你要有一套自己的校验规则。比如工具调用结果里明确出现了错误信息就不应该直接返回模型声称任务完成但关键步骤没有执行就要让它继续循环。同时轮数上限也给了你一个兜底信号如果循环达到上限说明这个任务要么超出了当前工具集的能力要么模型的规划被某个错误信息带偏了。出现这种情况时不要让它闷头继续跑而是可以返回一段提示给用户或者进入人工处理流程。3.3 没有结构化输出循环就只能是玩具这个环节是最容易踩坑的。很多模型接口现在支持“工具调用”或者“结构化输出”但如果你用的服务不支持或者你只是想快速验证一个想法那就得自己设计一套让模型输出 JSON 的约定。我的建议是让模型输出一个固定结构的 JSON{ tool: query_stock, parameters: { symbol: 某代码 } }或者在没有工具调用时输出{ final_answer: 任务已完成结果是…… }解析的时候先看有没有final_answer有就直接返回没有就把tool和parameters提出来执行。这里有个很实际的坑模型偶尔会在 JSON 前后夹带解释性文字比如“好的我将调用以下工具{...}”。解析之前最好先做一次清洗从文本里截取出第一对最外层的大括号再交给json.loads。如果你用的模型接口原生支持工具调用那就直接用它返回的结构化字段不用自己解析文字。但即使如此你也需要清楚这个解析层是 Agent 循环里最核心的胶水代码因为它决定了“模型想法”能不能变成“程序动作”。4. 把工具塞进循环模型负责决策代码负责执行4.1 Function Calling 并非魔法它只是在模型输出里多了一种“请求”很多人第一次接触“工具调用”这个概念时会以为模型真的能执行代码。实际上模型只是输出一个结构化的“请求”比如“我想调用 compute_score参数是 92 和 87”。真正执行这个函数的是你的程序。模型不知道函数内部怎么实现也不知道函数返回值具体长什么样它只看到你返回给它的观察结果。理解这一点非常关键。工具调用的本质是把“让模型在文字层面描述一个动作”这件事规范化了。如果不用结构化字段你也可以在提示词里写“请输出要调用的函数名和参数”但那样解析不稳定模型很容易输出一些模棱两可的话。Function Calling / Tool Use 帮你解决的问题就是让模型在一个独立的字段里输出机器可读的动作指令而不用混在大段自然语言里。所以工具系统设计的第一步不是写工具函数而是定义好“模型的意图表达格式”。4.2 工具注册表与异常回填写 Agent 的时候工具一多最忌讳的就是用一堆 if-else 去分发。更好的做法是一个注册表TOOL_REGISTRY {} def register_tool(name): def wrapper(func): TOOL_REGISTRY[name] func return func return wrapper register_tool(query_stock) def query_stock(symbol): # 这里接你的行情接口 return {symbol: symbol, price: 12.34, currency: CNY} register_tool(send_email) def send_email(to, subject, body): # 这里接你的邮件服务 return {status: sent, to: to} def run_tool(name, args): if name not in TOOL_REGISTRY: return {error: funknown tool: {name}} try: return TOOL_REGISTRY[name](**args) except Exception as e: return {error: str(e)}注册表的好处是每加一个新工具只需要新写一个函数并打上装饰器不需要改动循环主体。这也是 Agent 工程化里很重要的一个原则循环是稳定的工具是增长的。同时注意run_tool里我把所有异常都捕获了并把异常信息转成字典返回。这是因为异常不应该直接打断 Agent 循环而应该作为一种观察结果回填给模型。模型看到“这个工具调用报错了”它才知道换一条路走。如果你把异常直接抛出去整个程序崩溃那 Agent 和普通脚本就没有区别了。4.3 把工具的“说明书”写给模型而不是让它猜注册了工具函数之后你还需要把工具的描述信息传给模型。这个描述信息通常是 JSON Schema 格式里面包含工具名称、用途、参数类型和必填项。tool_schemas [ { name: query_stock, description: 查询某只股票的最新价格。当用户问到实时行情时使用。, parameters: { type: object, properties: { symbol: {type: string, description: 股票代码} }, required: [symbol], }, }, { name: send_email, description: 发送邮件。当用户要求给别人发信时使用。, parameters: { type: object, properties: { to: {type: string, description: 收件邮箱}, subject: {type: string, description: 邮件主题}, body: {type: string, description: 邮件正文}, }, required: [to, subject, body], }, }, ]这里最容易被忽略的是description字段。它不是你写给同事看的注释而是模型决定“要不要使用这个工具”的依据。写得太泛比如“查询股票”模型可能在该查天气的时候也去查股票写得太细比如直接复制接口文档里的参数说明模型又会因为信息量太大而忽略关键条件。我自己的习惯是每个工具的描述里至少写清楚两件事这个工具能做什么以及用户表达了什么意图时才该用它。工具返回的观察结果也要遵循同样的原则。不要把一个几十 KB 的原始接口响应原封不动塞回给模型那会浪费 token也会稀释模型的注意力。先做裁剪只保留 key 信息和状态再回填。5. 这个循环最磨人的三个坑重复调用、上下文爆炸、假卡死5.1 同一个工具反复调用先别甩锅给模型如果你发现 Agent 翻来覆去调用同一个工具第一反应不要是“模型真笨”。先去看日志确认模型是不是真的看到了上一次的工具结果。有一个我踩过好几次的坑代码里把工具结果追加到了messages但追加的消息格式缺少工具调用标识导致模型不认账以为自己没查到任何信息于是又调了一次。正确的排查顺序是先看模型“看到”了什么再看模型“输出”了什么最后看工具“返回”了什么。常见的重复调用原因有这么几种模型确实没看到工具结果因为消息结构不对工具返回了空值或者长长的报错堆栈模型判断“没查到”工具描述里没写“查询成功后不要再重复查询”导致模型每一步都习惯性再查一次模型试图用同一个工具的不同参数做多次尝试但你的工具没有返回足够明确的错误原因。针对这些原因最简单的缓解方式有两个。一是在系统提示词里明确写“如果已有工具返回结果优先基于已有结果继续只有结果不完整时才再次调用同一工具”二是在工具返回内容中增加状态字段比如“查询成功结果是……”“查询失败原因是……”让模型一下就能看懂。5.2 上下文越滚越大每轮都要给 Observation 做减肥Agent 循环跑得越久消息列表就越长。工具返回的原始结果、中间步骤的思考过程、旧的任务描述全都会累计进入下一次模型调用。token 成本会随轮数直线上升更麻烦的是太长的上下文会让模型忽略掉早期的关键信息。我见过一个比较夸张的例子一个查询任务跑了 12 轮最后一轮的messages里光工具结果就占了十几万 token很多内容还是重复的。最后模型反而回答得比第 3 轮更差这就是上下文污染。控制上下文的几个实用手法工具返回结果做截断单条观察结果尽量不超过几百字每轮结束后把已经完成的关键信息压缩成一行摘要放进下一轮消息如果任务流程很长考虑把早期原始消息换成“历史摘要 当前状态”的写法给每轮调用加上预算上限达到上限宁可让你自己写的兜底逻辑接管也不要放任循环不管。这里的核心思路是让消息列表里始终保留“任务目标、当前状态、最近观察”这三部分而不是把每一步的历史流水账全部背着走。5.3 排查“循环卡死”时我按这个顺序看日志有一次我调试一个模拟项目Agent 在同一个工具上反复卡住连续跑了 5 轮都是同一个调用。当时我没有改提示词而是先把每轮的消息打了出来。日志显示模型在第 2 轮确实收到了工具的返回结果但返回内容是这样的{error: null, data: null}这个结果是我写工具函数时偷懒接口没查到数据就直接返回了{error: null, data: null}。模型看到error是空data也是空于是判断“没查到”继续调用同一工具。你看问题根本不在模型而在我的工具返回把“接口没数据”表达成了一个模糊的状态。修复方式是在返回里加一个明确的status字段{status: no_data, message: 没有查询到该代码对应的行情}模型看到status是no_data就会明白这次调用没有成功进而选择换一个工具或者直接告诉用户而不是傻傻地再查一遍。完整的排查链路可以总结成四步确认循环确实在执行不是卡在网络请求或工具函数内部打印每一轮的messages长度和最后一条消息确认模型上一步的输出有没有被正确回填单独执行一次出问题的工具函数看它返回的原始数据是什么把原始数据翻译成模型能理解的观察结果重点检查status、error这类状态字段是否清晰。按这个顺序排查大多数“假卡死”都能找到根因。真正无解的情况很少多数是某个环节的信息表达不清。6. 从“能跑”到“能交付”日志、状态机与成本控制6.1 给循环装上日志和回放不然你永远在猜Agent 程序最大的特点是同一个任务跑两次可能得到两条不同的路径。模型有随机性外部接口返回也可能变化。这时候如果只管跑不看日志出问题会非常难定位。我的做法是在循环里的每一步都记录一份结构化日志records [] # 在 run_agent 的循环内部 records.append({ step: step, messages: messages, response_text: resp.text, tool_calls: resp.tool_calls, observations: observations, })跑完之后把records整体写进 JSON 文件。调试时不需要从头再跑一遍直接看这些记录就能还原出完整过程。如果只是模型输出层面出了问题你甚至可以拿着这份记录做回放把之前记录的工具返回当作固定结果重新调用模型复现它的决策过程。这里有一个小技巧回放时要把外部工具调用替换成“假实现”返回记录里的旧结果避免重复消耗真实接口也能让问题稳定复现。没有日志和回放能力Agent 项目越改越玄学。6.2 显式状态机比散装 for 循环更抗造最小循环用 for 就够了但项目一旦复杂起来for 循环里塞了太多业务逻辑代码会越来越难改。我后来更习惯把循环体改造成显式状态机。思路很简单定义几个状态比如THINKING、WAITING_TOOL、OBSERVING、DONE每个状态只做一件事。state THINKING while state ! DONE: if state THINKING: resp call_llm(messages, toolstool_schemas) if resp.tool_calls: state WAITING_TOOL current_tool_calls resp.tool_calls else: state DONE final_answer resp.text elif state WAITING_TOOL: observations [run_tool(c.name, c.arguments) for c in current_tool_calls] messages.extend(observations) state OBSERVING elif state OBSERVING: messages.append({role: assistant, content: 已拿到工具结果继续处理}) state THINKING显式状态机的好处是可以随时停在某个状态。比如某些工具执行前需要人工确认你可以让循环停在WAITING_TOOL等确认后再继续或者某次调用失败后直接从WAITING_TOOL重试。这些能力散装的 for 循环很难优雅地支持。当然不一定所有项目都要上状态机。只有一个工具、两轮就能完成的任务for 循环完全够用。判断标准很简单当你发现“同一个动作要在好几个地方判断”的时候就该考虑状态机了。6.3 成本、时延、可用性Agent 循环也是一笔工程账Agent 循环看着简单但跑起来之后消耗是实打实的。每多一轮就要多一次模型调用多一次工具执行多一批 token。设计时需要想清楚几笔账。第一笔是 token 成本。假设平均一轮消耗 2000 token一个任务平均跑 5 轮那就是 10000 token。如果做的是高频任务这个成本会非常可观。缓解方式包括上下文裁剪、工具返回精简化、尽量在小模型能完成时用小模型、设置单任务预算上限。第二笔是时延。一次模型调用快则一两秒慢则十几秒工具调用再花费一些时间。Agent 任务的端到端耗时会比普通 API 调用高很多。如果你的用户能接受异步任务那就别做成同步等待如果必须同步返回要提前设计好超时时间和轮数限制。第三笔是可用性。工具接口会挂模型服务会不稳定第三方数据源会有延迟。Agent 循环要把这些不可靠因素都接住方法是统一用观察结果的方式回填给模型而不是让异常直接跳出循环。工具报错后模型可以决定换个工具、换一组参数或者向用户解释失败原因。这些都比你写死的容错逻辑要灵活。把这三笔账算清楚Agent 才算从“能跑”变成“能交付”。我自己写 Agent 时最常提醒自己的一句话是循环本身不复杂复杂的是让每一次循环都有意义。模型输出的内容要看工具返回的结果要筛历史信息要控遇到摩擦要留痕。当你把这些都做到位之后你和“会调 API”之间的距离也就是那一个循环。