Agent 循环——Think、Act、Observe,周而复始

图:Agent 循环——Think、Act、Observe,周而复始

《零基础 AI 入门》第 3 篇|作者:梯度下降的咖啡师 | 8 年大厂Java 后端(京东、字节)+ 2 年 AI 应用开发

上一篇我们写了一段 Function Calling 代码:模型说要调工具,你执行,把结果塞回去,模型给你一个最终答案,程序就结束了。

当时我在代码注释里写"这就是收到 RPC 请求自己 dispatch"。但写完之后我盯着它看了半天——这玩意儿跑一轮就退出,连不上啊。真实场景里,用户问一句"北京今天适合跑步吗",模型可能要先查天气、再查空气质量、再综合判断,中间要来回好几轮。

这一篇就干一件事:把那段代码外面套一个 while。套完,它就是一个 Agent 了。不装 LangChain,不装 LangGraph,核心循环就二十来行。

01 上一篇那段代码,其实只跑了"半程"

先回忆一下上一篇的流程:

  1. 你把用户问题发给模型
  2. 模型说"我要调 get_weather(北京)"
  3. 你执行,把结果塞回 messages
  4. 你再请求一次模型,模型给你最终答案
  5. 结束

问题在第 5 步:模型这一次只调了一个工具,万一它需要两个工具呢?万一它调完第一个工具,看了结果,觉得还得再调一个呢?上一篇的代码做不到——流程是你写死的"调一次工具就收口"。

用 Java 后端的话讲:你写了个 @Transactional 方法,里面只允许一次外部 RPC 调用就返回。但真实业务里,一个请求可能要串行调好几个下游服务,前一个的输出是后一个的输入。

Agent 跟这个一模一样:**你不知道模型这一轮要调几个工具、要调几轮。**唯一正确的做法,是把"请求→调工具→塞结果→再请求"这件事,交给一个循环。

02 加一个 while,就是 Agent 了

核心思路三句话:

  • 把"请求模型"放进 while True 里
  • 模型返回 不带tool_calls,说明它觉得任务完成了,打印答案、break
  • 模型返回 带tool_calls,你就把工具全执行了、结果塞回 messages,然后让循环再转一圈

就这么简单。这个循环有个名字,叫 ReAct Loop(Reason + Act):模型先推理该干什么(Think),再调工具(Act),拿到观察结果(Observe),接着推理下一步。周而复始,直到它自己说我搞定了。

严格说,2022 年 ReAct 那篇论文是让模型在文本里输出 Thought/Action/Observation 标签,再用正则抠出来。我们这里用 OpenAI 原生 Function Calling,模型直接返回结构化的 tool_calls,比正则解析稳得多——这也是现在工业界的主流做法。

你现在应该明白为什么上一篇我反复强调"Function Calling 模型不会真的去调"了——循环的控制权在你手里。模型只负责说"我下一步想调什么",转几圈、什么时候停,全是你代码里这个 while 决定的。

03 完整代码,改个 key 就能跑

这次加两个工具:查天气、查空气质量。用户问"北京适合户外跑步吗",模型得自己决定先查哪个、要不要两个都查。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import json
from openai import OpenAI

client = OpenAI(base_url="https://api.deepseek.com", api_key="你的DeepSeek API Key")  # 去 platform.deepseek.com 注册获取

# --- 两个工具函数 ---
def get_weather(city: str) -> str:
    """查询指定城市的当前天气"""
    db = {"北京": "晴 28°C", "上海": "多云 25°C", "深圳": "雷阵雨 31°C"}
    return db.get(city, f"暂无{city}的天气数据")

def get_aqi(city: str) -> str:
    """查询指定城市的空气质量指数(AQI)"""
    db = {"北京": "85(轻度污染)", "上海": "42(优)", "深圳": "30(优)"}
    return db.get(city, f"暂无{city}的 AQI 数据")

# --- 工具的 JSON Schema ---
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的当前天气",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string", "description": "城市名"}},
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_aqi",
            "description": "查询指定城市的空气质量指数 AQI",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string", "description": "城市名"}},
                "required": ["city"],
            },
        },
    },
]

available_functions = {"get_weather": get_weather, "get_aqi": get_aqi}

# --- 对话历史:system 先定调,再上用户问题 ---
messages = [
    {"role": "system", "content": "你是一个帮用户做决策的助手。需要数据时自己调工具,信息够了就直接给结论,不要废话。"},
    {"role": "user", "content": "北京今天适合户外跑步吗?需要什么数据你自己查。"},
]

# --- 这就是 Agent 循环 ---
MAX_TURNS = 5  # 防止模型死循环,最多转 5 圈
for turn in range(MAX_TURNS):
    print(f"\n===== 第 {turn + 1} 轮 =====")

    resp = client.chat.completions.create(
        model="deepseek-v4-flash",
        messages=messages,
        tools=tools,
    )
    msg = resp.choices[0].message
    messages.append(msg.model_dump(exclude_none=True))

    # 模型不再要求调工具 = 任务完成
    if not msg.tool_calls:
        print(msg.content)
        break

    # 否则,把这一轮要求的工具全执行掉
    for call in msg.tool_calls:
        fn_name = call.function.name
        fn_args = json.loads(call.function.arguments)
        print(f"[tool] {fn_name}({fn_args})")
        try:
            result = available_functions[fn_name](**fn_args)
        except Exception as e:
            result = f"工具执行失败:{type(e).__name__}: {e}"
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": result,
        })
else:
    # for 循环正常结束 = 达到 MAX_TURNS 还没停
    print("(达到最大轮次,强制停止)")

手写 Agent 的 while 循环流程

有一行值得单独说:json.loads(call.function.arguments)。模型返回的 arguments 是个 JSON 字符串,不是 dict,你得自己解析。调工具多了之后,参数校验、字段格式经常出幺蛾子——我平时处理 JSON 格式化和校验都丢给 ForJSON(forjson.com),27 个小工具,公司内部都在用,不用本地起服务。

04 跑起来,你会看到什么

大概是这样。注意第一轮:模型 content 是空的——它没想跟你废话,直接告诉你我要调这两个工具,所以终端上第一轮只打印了两行 [tool]:

1
2
3
4
5
6
7
===== 第 1 轮 =====
[tool] get_weather({'city': '北京'})
[tool] get_aqi({'city': '北京'})

===== 第 2 轮 =====
今天北京晴,气温 28°C,适合户外跑步;但 AQI 85 属于轻度污染,
敏感人群建议改为室内运动。

注意发生了什么:

  • 第 1 轮:模型一看问题需要数据,自己同时要了天气和空气质量(两个 tool_call 一起发)
  • 你的代码:把两个工具都跑了,结果塞回 messages
  • 第 2 轮:模型拿到两个结果,不需要再调工具了,直接给你一段综合建议,循环 break

你没有写"先查天气再查 AQI"这种流程。流程是模型自己根据问题决定的。这就是 Agent 和传统代码的区别:控制流不是你写死的 if-else,是模型每一轮现决定的。

把用户问题换成"上海和深圳哪个更适合周末跑步?",你会看到模型自己安排查 4 次——两个城市各查天气和 AQI。它可能一轮发两个 tool_call,也可能分两轮查,总之你一行代码都不用改,循环会自己转到它觉得信息够了为止。

05 这个手写版,哪里会痛

能跑,但离"能用"还差得远。你现在写的这个 while 循环,马上会撞三个墙:

**第一,messages 越来越长。**每轮对话都 append 进去,跑十几轮就塞不进上下文窗口了。真实 Agent 需要记忆管理、摘要、剪枝——这是下一篇的主题。

**第二,崩了就全没了。**循环跑到第 3 轮你 Ctrl+C,或者进程挂了,前面几轮的上下文全丢。真实 Agent 需要把状态持久化,下次能接着跑。这是后面讲 LangGraph 时的核心卖点。

**第三,人没法插手。**模型要调一个"删除用户数据"的工具,你想先让人确认一下再放行?手写这个 while 你得自己加判断、加中断、加恢复。框架帮你把这些抽象成"人工审批节点"。

现在别去补这些。先把这个版本跑通、改改用户问题、加两个自己的工具,让身体记住这个循环。等你手写过两版、撞过这三个墙,再去看 LangGraph,你会瞬间理解它每个抽象是在解决什么痛点。反过来一上来就 LangGraph,你只会觉得 API 好多。

06 写在最后

Agent 这个词被讲得很玄乎。但你今天看到了:它不是什么新物种,就是上一篇那个 Function Calling,外面套了个 while。

模型负责想,你负责执行,循环负责把这俩粘起来。仅此而已。

旧炉未冷,新火已燃。我们下篇见。关注公众号「梯度下降的咖啡师」,或访问 gradcoffee.com。

下一篇预告:你现在这个 Agent 聊两轮还行,聊到第十轮 messages 列表就撑爆上下文窗口了。下一篇我们给它加记忆——messages 涨到几十条时怎么剪枝、怎么摘要、哪些不能丢,让它真能记住你十分钟前说过什么。


聊聊两个问题:

① 你想给这个手写 Agent 加个什么工具?查数据库、发邮件、跑 Shell?评论区说说,后面做进阶篇时我挑一个真实场景演示。

② 刚才说的三个痛点(记忆、持久化、人工审批),你觉得哪个在真实业务里最致命?

咖啡续命,梯度下降。| 作者:梯度下降的咖啡师