3. Agent 的循环:思考—行动—观察
这是什么:Agent 循环 = 「思考 → 行动 → 观察」反复转下去,直到模型不再要求调用工具。
为什么用:上一章的 Agent 只会调一个工具;真实任务常常要连做几步(先查天气,再决定穿什么),单次调用不够。
如何用:给第 2 章的代码套一个 while,加上步数上限和失败兜底,就得到一个能自己往下跑的 Agent。
3.1 循环的骨架
把第 2 章的代码包进一个 while,就是 Agent 循环:
while 还没完成 and 步数没超限:
模型思考 → 要调工具?
是 → 执行工具 → 结果写回上下文 → 继续循环
否 → 输出最终答案,结束
差别只有一处:上一章是「调一次就再问一次」,现在是「一直转到模型不再要工具」。
3.2 手写一个循环
import json
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
)
CHAT_MODEL = os.environ.get("CHAT_MODEL", "gpt-4o-mini")
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气。城市名用中文。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如 '北京'"}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_tip",
"description": "根据天气给出穿衣建议。",
"parameters": {
"type": "object",
"properties": {
"weather": {"type": "string", "description": "天气描述"}
},
"required": ["weather"]
}
}
},
]
def run_tool(name, args):
"""按名字分发到真正的函数"""
if name == "get_weather":
return get_weather(**args)
if name == "get_tip":
return get_tip(**args)
raise ValueError(f"未知工具:{name}")
def get_weather(city: str) -> str:
fake = {"北京": "晴,26℃", "上海": "多云,29℃"}
return fake.get(city, f"没查到 {city} 的天气")
def get_tip(weather: str) -> str:
if "雨" in weather:
return "记得带伞"
if "晴" in weather:
return "穿薄外套就行"
return "穿件外套吧"
def run_agent(question, max_steps=5):
"""Agent 主循环:思考 → 行动 → 观察,转到模型不再要工具为止"""
messages = [{"role": "user", "content": question}]
for step in range(max_steps):
resp = client.chat.completions.create(
model=CHAT_MODEL,
messages=messages,
tools=TOOLS,
)
msg = resp.choices[0].message
# 模型不再要工具了 → 任务完成
if not msg.tool_calls:
return msg.content, messages
# 模型可能要调多个工具,逐个执行
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = run_tool(call.function.name, args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
print(f"[第 {step + 1} 步] 调用 {call.function.name}({args}) → {result}")
return f"超过 {max_steps} 步仍未完成,放弃。", messages
answer, _ = run_agent("北京天气怎么样?需要穿什么?")
print(answer)
你会看到(示意):
[第 1 步] 调用 get_weather({'city': '北京'}) → 北京:晴,26℃
[第 2 步] 调用 get_tip({'weather': '晴,26℃'}) → 穿薄外套就行
北京现在晴,26℃,穿薄外套就行。
3.3 停止条件:三种「够了」
循环最怕停不下来。三种收尾方式,必须至少有一种兜底:
| 停止条件 | 说明 | 风险 |
|---|---|---|
| 任务完成 | 模型不再请求工具 | 模型赖着不结束 |
| 步数上限 | 超过 max_steps 就停 |
任务还没做完就断了 |
| 连续失败 | 连续 N 次工具报错就停 | 掩盖了真实错误 |
failures = 0
for step in range(max_steps):
...
try:
result = run_tool(call.function.name, args)
failures = 0 # 成功一次就清零
except Exception as e:
failures += 1
result = f"工具执行失败:{e}"
if failures >= 3:
return "工具连续失败,任务中止。", messages
步数上限是最重要的兜底。没有它,一个卡住的循环能把你的 API 额度烧穿。
3.4 ReAct:让它「边想边做」
Agent 的循环本质上是 ReAct(Reasoning + Acting)思路:让模型交替地推理和行动,而不是一次想到底。
思考:我需要知道北京天气才能给建议
行动:调用 get_weather(city="北京")
观察:结果返回「晴,26℃」
思考:现在我知道该建议穿什么了
行动:调用 get_tip(weather="晴,26℃")
观察:结果返回「穿薄外套就行」
思考:信息齐了,可以回答了
好处是每一步都有「真实的外部信息」进账,模型不容易凭空编;坏处是上下文会越来越长(第 9 章解决)。
本节只是把 ReAct 的形状先摆出来。它有正式的名字、论文出处和完整实现方式——下一章专门讲。
3.5 并行调用:一次要多个工具
模型有时会在一次回复里要多个工具(比如「查北京和上海的天气」):
for call in msg.tool_calls: # ← 注意是复数,循环执行
args = json.loads(call.function.arguments)
result = run_tool(call.function.name, args)
messages.append({
"role": "tool",
"tool_call_id": call.id, # 每个都要对上各自的 id
"content": result,
})
每个工具调用都要单独回一条 role: "tool" 消息,且 tool_call_id 必须和对应的 call.id 一一匹配。少回一条,模型就会一直等。
3.6 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
| 一直转停不下来 | 没有步数上限 | 加 max_steps |
| 工具结果对不上号 | 并行调用时 id 搞混 | 每个 call 单独回一条,用 call.id |
| 模型重复调同一个工具 | 工具返回值没信息量,它看不懂 | 让返回值更明确(如带上「已经查过了」) |
| 上下文越来越长 | 每步都全量追加 | 压缩历史(第 9 章) |
| 工具报异常直接把 Agent 掀翻 | 没 try/except | 包一层,异常信息回传给模型 |
3.7 动手改
- 给
run_agent加一个「连续失败 3 次就停」的逻辑,故意让一个工具抛异常试试 - 把
max_steps改成 1,看任务怎么被截断;再改成 10,看它会不会做多余的事 - 加一个「查完天气后顺便查空气质量」的工具,体会多步协作
3.8 小结
- Agent 循环 =
while包住「思考 → 行动 → 观察」 - 三种停止条件:任务完成、步数上限、连续失败,步数上限是必备兜底
- ReAct:交替推理与行动,每步都有真实信息进账
- 并行工具调用时,每个调用都要单独回一条
role: "tool",id 一一对应 - 工具异常要 catch 住,把错误信息回传给模型而不是让程序崩
下一章:ReAct 模式,这套循环有个正式名字和完整形态 🐸