2023 年我做一个智能助手,想让它能回答"今天北京天气怎么样""某只股票现在多少钱"这类需要实时数据的问题。这类问题大模型自己答不了——它的知识停在训练截止那一刻,今天的天气、此刻的股价,它根本不知道。我知道得给它配"工具":一个查天气的函数、一个查股价的函数。第一版我的做法特别直接:把这些工具的说明,用文字写进 prompt,告诉模型"你有这些工具可以用",然后把用户问题拼在后面发给模型。本地一测,问"北京天气怎么样",模型回了一段:"北京今天晴,气温 20 摄氏度左右,适合出门。"我当时还挺高兴——看,它会用工具了。直到我多问几次才发现不对劲:同一个问题问三次,它给的气温一次比一次不一样;我把查天气的函数故意写崩,它返回的天气却依然"正常"。我这才惊出一身冷汗:模型根本没有调用我的函数——它在编。它把"回答天气问题"当成了一道普通的语言题,凭着训练时见过的无数天气描述,编了一个"看起来很合理"的答案糊弄我。我一开始以为是 prompt 写得不够清楚,把"你必须调用工具"加粗、重复、用大写……都没用。后来才彻底想明白,问题根本不在 prompt:大模型本身不能执行任何东西。它不会、也无法去运行我那个查天气的 Python 函数。它能做的只是"说"——说一句"我要调用 get_weather('北京')"。这句话,如果我的代码不去接住它、不去真的执行那个函数、不把执行结果再喂回给模型,那它就只是一串普通的文字,工具永远不会被调用。我缺的根本不是一段更好的 prompt,而是一套把模型的"决策"和我的代码的"执行"连起来的循环:模型说要调哪个工具,我的代码就去执行,把结果喂回模型,模型拿到真实结果再继续决策……如此往复,直到模型说"我已经有答案了"。这套循环,就是 AI Agent 的本质。我以为做 Agent 就是"给模型几个工具",结果真做下来坑一个接一个:循环怎么转、什么时候停、工具崩了怎么办、模型陷入死循环狂烧 token 怎么办……那次之后我才认真把 Agent 从头搞明白。这篇文章就把它梳理一遍:为什么模型自己不会"用"工具、Agent 的本质是什么循环、工具怎么定义和注册、主循环怎么写、多步推理怎么转,以及最大迭代次数、工具出错、可观测性这些把 Agent 真正做稳要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:做一个能回答实时数据问题的智能助手,第一版把工具说明写进 prompt,就指望模型自己去用工具。结果模型从不真正调用工具——它直接编造一个看似合理的答案;把工具函数故意写崩,模型返回的结果依然"正常",证明它压根没执行过那个函数。
我当时的错误认知:"把工具的说明写进 prompt,模型就会自己去用这些工具。"
真相:大模型本身不能执行任何代码。它只能输出文字——包括"我想调用某个工具"这样的意图。要让工具真正被执行,必须有一套你编写的循环:模型输出"要调用工具 X"的意图 → 你的代码解析这个意图、真正执行工具 X → 把工具结果喂回给模型 → 模型基于真实结果继续 → 直到模型给出最终答案。这套"模型决策、代码执行"反复交替的循环,就是 Agent。
要把 Agent 做好,需要几块认知:
- 为什么模型自己不会"用"工具,它到底能做什么、不能做什么;
- Agent 的本质是一个怎样的循环;
- 工具怎么定义、怎么注册,模型怎么知道有哪些工具;
- Agent 的主循环怎么写,什么时候该停;
- 多步推理、最大迭代次数、工具出错、可观测性这些工程坑怎么处理。
一、为什么模型自己不会"用"工具
先把这件最根本的事钉死:大模型只会"说",不会"做"。
模型是一个文本生成器——给它一段输入,它输出一段文本,仅此而已。它没有手,运行不了你的函数;它没有网络,访问不了任何接口。下面这段代码,就是我那个"把工具写进 prompt 就指望模型用"的第一版:
from openai import OpenAI
client = OpenAI()
def naive_agent(question: str) -> str:
# 反面教材:把工具"描述"写进 prompt,就指望模型自己去用。
prompt = f"""你可以使用这些工具:
- get_weather(city): 查询某城市天气
- get_stock(code): 查询某股票价格
请回答用户问题:{question}"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
# 问题:模型【不能执行任何代码】。它要么直接编一个天气糊弄你,
# 要么回一句"我将调用 get_weather('北京')"——这串文字
# 原样返回给用户,那个查天气的函数根本没有被运行。
这段代码的错,不在某一行语法,在它的根本假设:它假设"把工具告诉模型,模型就会去用"。可模型读到 get_weather 这个名字,它只是知道有这么个东西,它没有任何办法去触发它。模型面对"北京天气怎么样",它有两条路:一是老实说"我需要查一下";二是凭训练记忆编一个。而它常常选第二条——因为对模型来说,编一段通顺的天气描述,和回答任何别的语言问题没有区别。
所以,要让工具真正被用上,缺的不是"告诉模型有工具",而是要有人在模型和工具之间做中间人:听懂模型"我要调 get_weather"的意图,真的去跑这个函数,再把结果递回给模型。这个中间人,就是你要写的 Agent。
二、Agent 的本质:模型决策、代码执行的循环
把上一节那个"中间人"的职责想清楚,Agent 的本质就浮现了:它是一个循环,循环里有两个角色交替工作。
模型,是决策者:它看着当前的对话,决定"下一步该做什么"——是该调用某个工具(以及调哪个、传什么参数),还是信息已经够了、可以给出最终答案。你的代码,是执行者:模型说要调工具,你的代码就真正去执行那个函数,拿到结果,再把结果作为新的信息交还给模型。
于是循环就转起来了:模型决策 → 代码执行工具 → 结果喂回 → 模型再决策 → ……这个循环的出口只有一个:模型某一次决策时说"我不需要再调工具了,这是最终答案"。理解了这个循环,Agent 就不再神秘——它不是什么会"思考"的魔法,它就是一个 for 循环,循环体里一边是模型 API 调用,一边是你的工具函数。下面几节,就把这个循环的零件一个个造出来:先是工具,再是循环本身。
三、工具的定义与注册:让模型知道有什么可用
先造工具。一个工具,说到底就是一个普通的函数。这里写两个示意用的工具(为聚焦循环本身,内部用假数据):
def get_weather(city: str) -> str:
"""查询某城市今天的天气(这里用假数据示意)。"""
return f"{city}今天晴,气温 18 到 25 摄氏度"
def get_stock(code: str) -> str:
"""查询某股票的当前价格(这里用假数据示意)。"""
return f"{code} 当前价格 142.6 元"
光有函数还不够。模型怎么知道有这两个工具、每个工具要传什么参数?你得用一种模型能读懂的格式,把每个工具描述出来。OpenAI 的接口约定用一段 JSON Schema 来描述:
# 把工具按 OpenAI function calling 的格式描述出来,交给模型看
TOOLS_SCHEMA = [
{"type": "function", "function": {
"name": "get_weather",
"description": "查询某城市今天的天气",
"parameters": {"type": "object", "properties": {
"city": {"type": "string", "description": "城市名,如 北京"}},
"required": ["city"]}}},
{"type": "function", "function": {
"name": "get_stock",
"description": "查询某股票的当前价格",
"parameters": {"type": "object", "properties": {
"code": {"type": "string", "description": "股票代码"}},
"required": ["code"]}}},
]
有了描述,模型就能在决策时说出"我要调用 get_weather、参数 city 是北京"。但模型说的只是一个名字字符串,你的代码得能凭这个名字找到真正的函数并执行它。这就需要一张"名字到函数"的注册表,和一个按名字分发的执行器:
import json
# 注册表:工具名 -> 真正的函数对象
TOOL_REGISTRY = {
"get_weather": get_weather,
"get_stock": get_stock,
}
def dispatch_tool(name: str, arguments: str) -> str:
"""根据模型给的工具名和参数,真正执行对应的函数。"""
func = TOOL_REGISTRY.get(name)
if func is None:
return f"错误:不存在名为 {name} 的工具"
args = json.loads(arguments) # 模型给的参数是一段 JSON 字符串
return func(**args) # 把参数解包,真正调用函数
dispatch_tool 就是那个"中间人"最核心的动作:模型给一个名字和一串 JSON 参数,它查注册表找到函数、解析参数、真正执行。零件齐了,下一节把它们装进循环。
四、Agent 主循环:让模型连续决策直到给出答案
现在把第二节那个"循环"真正写出来。它的骨架是一个 for 循环,每一轮:调一次模型 → 看模型要不要调工具 → 要调就执行、把结果喂回 → 不调就说明它给出最终答案了,返回。
def run_agent(question: str, max_steps: int = 8) -> str:
"""Agent 主循环:模型决策 -> 代码执行工具 -> 结果喂回 -> 再决策。"""
messages = [{"role": "user", "content": question}]
for step in range(max_steps):
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS_SCHEMA, # 把工具描述一并交给模型
)
msg = resp.choices[0].message
# 模型没有要求调工具 —— 说明它已经能给出最终答案了
if not msg.tool_calls:
return msg.content
# 模型要求调工具:先把它这条"决策"记进对话历史
messages.append(msg)
# 逐个执行模型要求的每一个工具调用
for call in msg.tool_calls:
result = dispatch_tool(call.function.name,
call.function.arguments)
# 关键:把工具结果作为一条 role=tool 的消息喂回去
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
return "已达到最大步数,未能得出最终答案"
这段代码,就是文章开头那个问题的正解。它和 naive_agent 的根本区别,全在那两步:其一,resp 回来后,它检查 msg.tool_calls——这是模型表达"我要调工具"意图的地方;其二,它真的去 dispatch_tool 执行,并把结果作为一条 role=tool 的消息追加回 messages,再进入下一轮。正是这"喂回"的动作,让模型在下一轮能看到工具的真实返回值,基于它继续——而不是凭空编。循环的出口也很自然:某一轮模型不再要求调工具(tool_calls 为空),就说明它手里信息够了,msg.content 就是最终答案。
五、多步推理:把复杂任务拆成多次工具调用
上一节的循环,藏着一个很强的能力:它天然支持多步推理。
设想用户问"北京今天适合出门吗,顺便告诉我某股票多少钱"。这个问题需要两次工具调用。run_agent 的循环会自然地处理:第一轮模型决策调 get_weather,拿到天气喂回;第二轮模型一看还缺股价,决策调 get_stock,拿到喂回;第三轮模型发现信息齐了,把天气和股价综合成一段完整回答返回。循环转了几轮,就等于模型推理了几步——这个能力不用你额外写,它是循环结构白送的。
但要让模型在多步里不跑偏,需要一个好的 system 提示词,明确约束它的行为边界——尤其是那条"需要实时数据必须调工具、绝不许编",正是治文章开头那个"编天气"病根的:
SYSTEM_PROMPT = """你是一个能调用工具的助手。请遵守:
1. 凡是需要实时数据的问题,必须调用工具获取,绝不允许自己编造。
2. 一个复杂问题可能需要多次调用工具,一步一步来。
3. 工具返回错误时,如实告诉用户,不要假装成功。
4. 所有需要的信息都拿到后,再给出完整的最终回答。"""
def run_agent_v2(question: str, max_steps: int = 8) -> str:
"""带 system prompt 的 Agent:明确约束它的行为边界。"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
for step in range(max_steps):
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS_SCHEMA)
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for call in msg.tool_calls:
result = dispatch_tool(call.function.name,
call.function.arguments)
messages.append({"role": "tool", "tool_call_id": call.id,
"content": result})
return "已达到最大步数"
system 提示词,是你给 Agent 立的"行为规矩"。它不改变循环结构,但它显著影响模型每一步的决策质量:让它该调工具时就调、不该编时不编、拿不到结果时如实说。规矩立好了,主干就算通了。但要把 Agent 真正做稳,还有几个坑。
六、工程坑:迭代上限、工具出错与可观测性
主循环通了,但 Agent 有几个非做不可的工程防护。
坑 1:必须有最大迭代次数。你注意到没,run_agent 的 for 用的是 range(max_steps),而不是 while True。这绝不是小事。模型是有可能"想不通"的——它可能反复调同一个工具、可能在两个工具之间来回横跳,陷入死循环。每一轮循环都是一次模型 API 调用,都在烧钱烧 token。一个没有迭代上限的 Agent,一旦陷入死循环,就是一台停不下来的碎钞机。max_steps 就是那个强制的刹车——到点了,不管模型想没想通,都停。
坑 2:工具执行出错,不能让整个 Agent 崩。dispatch_tool 里 json.loads 可能因模型给的参数不合法而抛异常,工具函数自己也可能崩。如果让异常直接抛出去,整个 Agent 就挂了。正确的做法是:捕获这些错误,把它转成一段文字,当作工具的"返回值"喂回给模型——让模型自己看到"这个工具失败了",由它决定是换个工具、还是如实告诉用户。
def dispatch_tool_safe(name: str, arguments: str) -> str:
"""安全的工具分发:工具出错转成文字喂回模型,而不是抛异常崩掉。"""
func = TOOL_REGISTRY.get(name)
if func is None:
return f"错误:不存在名为 {name} 的工具"
try:
args = json.loads(arguments)
return func(**args)
except json.JSONDecodeError:
return f"错误:工具参数不是合法 JSON:{arguments}"
except Exception as e:
# 工具崩了,把异常转成文字喂回模型 —— 让模型自己决定怎么办
return f"工具 {name} 执行失败:{e}"
坑 3:Agent 一定要可观测。非流式接口出错,看一眼日志就知道哪行崩了。但 Agent 是个循环,它"想错了"往往不报错——它一步步地、看似合理地走向一个错误答案。事后你问"它怎么得出这个结论的",如果没有记录,你完全无从查起。所以 Agent 的每一步——模型决策了什么、调了哪个工具、传了什么参数、工具返回了什么——都必须记下来,形成一条可回溯的 trace。
def run_agent_traced(question: str, max_steps: int = 8):
"""带 trace 的 Agent:每一步决策和工具调用都记下来,方便事后排查。"""
messages = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question}]
trace = []
for step in range(max_steps):
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS_SCHEMA)
msg = resp.choices[0].message
if not msg.tool_calls:
trace.append(f"[第{step}步] 模型给出最终答案")
return msg.content, trace
messages.append(msg)
for call in msg.tool_calls:
result = dispatch_tool_safe(call.function.name,
call.function.arguments)
trace.append(f"[第{step}步] 调用 {call.function.name} "
f"参数 {call.function.arguments} 得到 {result}")
messages.append({"role": "tool", "tool_call_id": call.id,
"content": result})
return "已达到最大步数", trace
坑 4:留意 messages 的膨胀。循环每转一轮,都往 messages 里追加"模型决策 + 工具结果"。多步之后,messages 会越来越长,而每一轮都把整个 messages 发给模型——token 消耗随步数越滚越大。步数多的 Agent,要考虑裁剪历史:比如把很早的、已经用过的工具结果摘要掉。下面这张图,把 Agent 的完整循环串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 模型只会"说" | 大模型不能执行任何代码,它只能输出文字,包括"想调用某工具"的意图 |
| Agent 的本质 | 一个循环:模型决策 → 代码执行工具 → 结果喂回 → 模型再决策 |
| 工具描述 schema | 用 JSON Schema 描述每个工具的名字、用途、参数,交给模型 |
| 工具注册表 | 工具名到真实函数的映射,让代码能凭模型给的名字找到函数 |
| 工具分发执行 | 解析模型给的名字和 JSON 参数,真正调用对应函数并拿到返回值 |
| 结果喂回 | 把工具返回值作为 role=tool 的消息追加进对话,让模型看到真实结果 |
| 循环出口 | 某一轮模型不再要求调工具,说明信息已够,其回复即最终答案 |
| 多步推理 | 循环转几轮就等于推理几步,复杂任务被自然拆成多次工具调用 |
| 最大迭代次数 | 用 range(max_steps) 而非 while True,防模型陷入死循环狂烧 token |
| 工具出错处理 | 捕获异常转成文字喂回模型,让模型自己决定,而不是让 Agent 崩掉 |
避坑清单
- 大模型不能执行任何代码,把工具说明写进 prompt 它不会自己用,只会照着语言习惯编一个答案。
- Agent 的本质是循环:模型决策要调什么工具,你的代码真正执行,把结果喂回,模型再决策。
- 工具就是普通函数,但要额外用 JSON Schema 描述其名字用途参数,模型才知道有什么可用。
- 模型给的只是工具名字符串,要有"名字到函数"的注册表和分发器,才能把意图变成真正的执行。
- 必须把工具返回值作为 role=tool 消息喂回对话,模型下一轮才能看到真实结果而不是凭空编。
- 循环出口是模型某轮不再要求调工具,此时它的回复就是最终答案,不要硬性固定调用次数。
- 多步推理是循环结构白送的能力:循环转几轮就是推理几步,复杂任务被自然拆成多次调用。
- 主循环必须用 range(max_steps) 而非 while True,模型可能死循环,没有上限就是停不下的碎钞机。
- 工具执行出错要捕获并转成文字喂回模型,由模型决定怎么办,绝不能让异常抛出崩掉整个 Agent。
- Agent 想错往往不报错,每步决策和工具调用都要记 trace;并留意 messages 膨胀导致 token 暴涨。
总结
回头看那个"让大模型自己调工具、结果它编了个假天气"的智能助手,以及我后来在 Agent 上接连踩的坑,最该记住的不是某一段循环代码,而是我动手前那个想当然的判断——"把工具告诉模型,模型就会去用"。这句话错在它把大模型当成了一个能自己动手的执行者。可模型不是执行者,它是决策者——它能想清楚"现在该查天气了",但它没有手去查;查这个动作,永远得由你的代码来做。Agent 这个东西,本质就是给这个只会动脑、不会动手的决策者,配上一双手,再用一个循环,让"脑"和"手"轮流工作。
所以做 Agent,真正的工程量不在"调一次模型"那一下。给模型传 tools 参数、收到 tool_calls,这部分 Demo 里照着文档谁都能跑通。真正的工程量在那个循环的每一处:工具结果你喂回去了吗,还是模型那句决策被你当成最终答案返回了?循环什么时候停——是模型说停,还是它能无限地转下去?某个工具崩了,你的 Agent 是优雅地把错误告诉模型,还是整个挂掉?它一步步走向一个错误答案,事后你查得到它每一步在想什么吗?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚模型为什么不会自己用工具,再看 Agent 的本质是怎样一个循环,然后是工具的定义注册、主循环、多步推理这三段主干,最后是迭代上限、工具出错、可观测性这几个把 Agent 真正做稳的工程细节。
你会发现,Agent 的思路和我们指挥任何一个"会决策但要靠人执行"的协作场景都是相通的。一个总指挥,坐在指挥室里,他看着全局做判断——"派一队人去查 A、再派一队去查 B"。但他本人不会冲出去查,他下达指令,由具体的人去执行,执行完把结果报回指挥室,他根据新情报再下一个指令。模型就是那个总指挥,工具是被派出去的人,而你写的那个循环,就是指挥室和外界之间传递指令、收集情报的那套通信链路。指挥官再聪明,没有这套通信链路,他的判断也永远变不成行动。
最后想说,Agent 做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你问一个一步就能答的简单问题,模型调一次工具就结束,循环转一圈,跑起来漂亮极了。它只在真实的复杂任务、真实的多步推理、真实的工具故障面前才显形。那时候它会用最难堪的方式给你结账:一个本该三步答完的问题,模型在两个工具之间来回横跳了几十轮,直到你的 token 账单触目惊心;一个工具因为下游接口超时崩了,整个 Agent 跟着抛异常,用户收到一个五百错误;一个用户投诉"它给的答案完全错了",而你翻遍日志,找不到它究竟是哪一步、基于什么把自己带偏的。所以别等 Agent 在生产里失控了再去补这些防护,在你写下第一个循环的时候就该想清楚:它转几轮会停?某个工具崩了它扛得住吗?它每一步在想什么,我记下来了吗?messages 越滚越长,我的 token 还兜得住吗?这几个问题都有了答案,你的 Agent 才不只是 Demo 里那个简单问题跑得通的样子,而是一个无论任务多复杂、工具多不靠谱,都能一步步稳稳推进、且全程可回溯的可靠系统。
—— 别看了 · 2026