第 1 章 · Part 1

最小 Agent

本章你将回答:

  • chatbot 和 agent 的本质区别是什么?
  • 为什么工具结果必须回灌给模型?
  • 一个 agent 最少需要哪几样东西?

reasoning-looppython labs/ch01/…

从 10 行 chatbot 说起

先看下面这段十行出头的代码。

labs/ch01/chatbot.py
"""10 行左右的 chatbot:一问一答。它不是 agent——控制流在人手里。"""
import os

from openai import OpenAI

client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("MODEL", "gpt-4o-mini")

history = [{"role": "system", "content": "你是一个乐于助人的助手。"}]
while True:
    history.append({"role": "user", "content": input("你: ")})
    reply = client.chat.completions.create(model=MODEL, messages=history).choices[0].message.content
    history.append({"role": "assistant", "content": reply})
    print("助手:", reply)

开头两行准备好一个 OpenAI 客户端,base_urlapi_key 都从环境变量取,这样你换一个兼容 OpenAI 协议的服务(本地模型、代理网关)时不用改一行代码。MODEL 默认 gpt-4o-mini,够便宜也够用。真正的主体是那个 while True 循环,它每一圈只做一件事:input("你: ") 停下来等你敲字,把你说的话追加进 history,发给模型拿回一句 reply,再把回复也追加进 history 并打印出来。history 这个列表就是全部记忆——它带着 system 提示和此前每一轮对话,所以模型能记住上下文,接得上你上一句在说什么。

它能跑、能聊天,但它不是 agent——原因藏在控制流里,而不是藏在模型或提示词里。关键在于:这个循环的每一步都由你驱动。程序在 input() 处彻底停住,你不敲回车它什么都不做;它没有”任务”的概念,只有你一句、它一句的轮流。模型在这里只是个被动的文本补全器,“下一步做什么”的决定权从头到尾攥在人手里。

不这么看你可能觉得”接个搜索、给个 system prompt,它不就成 agent 了吗”——不会。那些都是外挂,改变不了控制流的归属。真正的分界线是 谁掌控控制流:只要”下一步做什么”始终由人来发起,它就还是 chatbot。

停下来想想让这个 chatbot 帮你算”999 元打 7.5 折比打 8 折便宜多少”,它会怎么做?结果可靠吗?

先自己想一想,再展开参考思路

它只能用文字硬算。LLM 是概率文本生成器,多位数算术恰恰是它的弱项——它可能算对,也可能一本正经地算错,而且你无法从输出上区分这两种情况。 要可靠,就得让它调用外部工具;要调用工具,就得有一个”决定何时调用、拿到结果再继续”的循环。这正是 agent 与 chatbot 的分水岭。

60 行,把它变成 agent

要让它自己”动起来”,得补齐四样东西——what-is-an-agent 把它们概括为一个 agent 的最小构成:模型做决策、循环反复推进、工具触达外部世界、停止条件决定何时收手。那篇笔记还给了一段去掉框架糖衣的伪代码:while not done(state) 里,模型 decide 下一步,若是终态就 return,否则 run 工具拿到 observation,再 update 进上下文——如此循环。

对照上一节的 chatbot,你会发现缺的其实只有两样:一个能被模型主动调用的工具,和一个不等人输入、由模型自己决定要不要继续的循环。下面这六十行就把这两样补上了,四要素一个不少。

labs/ch01/agent.py
"""最小 Agent:模型 + 循环 + 一个工具 + 停止条件。

运行:python agent.py "价值 999 元的商品打 7.5 折后,比打 8 折便宜多少元?"
"""
import json
import math
import os
import sys

from openai import OpenAI

client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("MODEL", "gpt-4o-mini")
MAX_STEPS = 10  # 停止条件之二:防止无限循环

TOOLS = [{
    "type": "function",
    "function": {
        "name": "calculator",
        "description": "计算一个 Python 数学表达式,如 '3 * (4 + 5)' 或 'math.sqrt(2)'。",
        "parameters": {
            "type": "object",
            "properties": {"expression": {"type": "string", "description": "要计算的表达式"}},
            "required": ["expression"],
        },
    },
}]


def calculator(expression: str) -> str:
    try:
        return str(eval(expression, {"__builtins__": {}}, {"math": math}))  # 演示用;生产环境别用 eval
    except Exception as e:  # 错误也要回灌给模型,让它自我修正
        return f"计算出错: {e}"


def run(task: str) -> str:
    messages = [
        {"role": "system", "content": "你是一个会用工具的助手。需要计算时调用 calculator,得到最终答案后直接回答。"},
        {"role": "user", "content": task},
    ]
    for step in range(1, MAX_STEPS + 1):
        msg = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS).choices[0].message
        if not msg.tool_calls:  # 停止条件之一:模型不再要求调工具,说明它认为任务完成了
            return msg.content
        messages.append(msg)  # 把"模型的决定"留在上下文里
        for call in msg.tool_calls:  # 行动
            args = json.loads(call.function.arguments)
            result = calculator(**args)
            print(f"  [step {step}] calculator({args['expression']}) -> {result}")
            messages.append({"role": "tool", "tool_call_id": call.id, "content": result})  # 观察:结果回灌
    return "(超出最大步数,强制停止)"


if __name__ == "__main__":
    task = sys.argv[1] if len(sys.argv) > 1 else "价值 999 元的商品打 7.5 折后,比打 8 折便宜多少元?"
    print("最终答案:", run(task))

TOOLS:给模型的一份”说明书”

TOOLS 是一个 JSON schema 列表,它是模型认识 calculator唯一途径。模型看不到你的 Python 函数体,它能读到的只有 schema 里的三样东西:函数名 calculator、一段自然语言 description(“计算一个 Python 数学表达式,如 ‘3 * (4 + 5)’…”)、以及参数结构 parameters(一个必填的字符串 expression)。模型正是靠读这段 description,来判断”这个任务该不该用这个工具、该往 expression 里填什么”。

所以 description 不是给人看的注释,它是 prompt 的一部分。如果你把它含糊地写成”一个计算器”,模型很可能在该调用时按兵不动、或者把参数格式填错。反过来,schema 写得越准,模型用得越对——这是 工具使用 的第一课:工具的表达质量,直接决定模型的调用质量。

循环体:决策 → 行动 → 观察

run() 里的 for step in range(1, MAX_STEPS + 1) 就是 agent 的心跳。每一圈都在重复同样的三个动作。

先是决策——client.chat.completions.create(..., tools=TOOLS) 把当前 messages 连同工具清单一起发给模型,模型返回的 msg 要么带 tool_calls(“我要调工具”),要么只有 content(“我可以直接回答了”)。接着是行动——若有 tool_calls,就 json.loads(call.function.arguments) 解出参数,再真正执行 calculator(**args)。最后是观察——把执行结果用一条 role: "tool" 的消息追加回 messages,并带上对应的 tool_call_id。到了下一圈 create(),模型就能看到自己刚算出了什么,据此再决定下一步。

这个”决策→行动→观察”三拍,就是 推理循环,是所有 agent 的执行骨架。而 messages.append(msg) 那一行看似不起眼,却把”模型的决定”本身也留在了上下文里——它有多要紧,下面的思考题会让你亲眼看到。

两个停止条件:谁来喊停

一个会自己转圈的循环,必须留出口,否则它要么永不结束,要么烧光你的 API 额度。这里布了两道闸。

第一道是模型自报完成if not msg.tool_calls: return msg.content。当模型这一轮不再要求调工具、直接给出文字答案,就说明它认为任务已经做完了,循环正常退出。这是理想出口,喊停的是模型。第二道是硬上限 MAX_STEPS = 10。万一模型陷入反复调用、始终不肯给最终答案的怪圈,for 循环走到第 10 步也会强制结束,返回”(超出最大步数,强制停止)“。这道闸是代码替模型兜底的保险丝。

两道缺一不可:只留前者,一个想不明白的模型能转到天荒地老、账单爆表;只留后者,正常任务也要白白跑满 10 步才收工。真实框架对这两道闸的取舍各不相同,本章末尾的对比会让你看到那道 MAX_STEPS 背后藏着多大的分歧。

错误也要回灌

留意 calculator 里的 except:表达式算错了(比如模型传了非法语法),它不抛异常终止程序,而是把 f"计算出错: {e}" 当作结果返回。这条错误文本会像正常结果一样被回灌进 messages

为什么要这么做?因为对模型而言,错误也是一种”观察”。它下一圈就能看到”上次那样写报错了”,从而换个表达式重试、或换条思路。要是这里直接 raise,循环当场崩溃,模型根本没机会自我修正,一次小小的语法笔误就让整个任务夭折。把错误留在环里、交给模型消化,是 agent 自主性的一个重要来源——这也是 推理循环 在”错误处理”这一维度上最常见的一种选择。

停下来想想messages.append(msg) 这一行删掉,会发生什么?

先自己想一想,再展开参考思路

两件事都会坏:一,API 层面 tool 消息必须对应上一条 assistant 消息里的 tool_call_id,缺了那条消息大多数服务端直接报错;二,就算不报错,模型也”失忆”了——它看不到自己上一步决定过什么,可能反复调用同一个工具。 上下文是循环的记忆。每一步的决定和观察都要留在 messages 里,模型才能”接着上一步想”。

你刚刚实现的,就是所有框架的内核

别看这六十行简陋,推理循环 那篇笔记里梳理的五个设计维度,你其实每一条都已经替它做了选择。

控制权——你选了模型驱动:是否继续、调哪个工具,都由模型每一轮的输出决定,代码只负责忠实执行,没有任何写死的分支。停止条件——你同时用了”模型自报完成”和”最大步数”两种。错误处理——你选了”回灌给模型自我修正”,而不是框架捕获重试或直接失败。循环形态——你用的是最朴素的命令式 for 循环,而非图遍历或事件驱动。步间状态——你把每一步的决定和观察都 messages.append 进同一个消息列表来承载,而不是塞进一个显式的 state 对象或黑板;上面那道思考题让你盯着看的 messages.append(msg),正是这个维度上”消息列表追加”这一选择的落点。

这五个选择,恰恰是几十个真实框架各自纠结的地方。它们和你写的循环没有本质区别,差别只在于把每一维推向了不同的极端、又加上了更多结构(更多这类底层取舍,见 设计权衡)。所以接下来的每一章,本质上都是往这个循环上挂一个组件:第 2 章把手写的 schema 和分发换成工具注册表,第 3 章把 for 循环换成不同的循环范式(ReAct / plan-execute…),第 4 章处理 messages 越滚越长的上下文问题……而内核,始终是你现在这段代码。

检查你的理解

agent 与 chatbot 的本质区别是?

查看解析

四个选项里只有控制流是本质。chatbot 也可以有 system prompt、也能接搜索,但”下一步做什么”始终由人驱动;agent 把这个决定权交给了模型。见 what-is-an-agent

工具执行出错时,为什么把错误文本回灌给模型,而不是直接抛异常终止?

查看解析

错误也是一种”观察”。回灌后模型往往能自己修正——这是 agent 自主性的重要来源。多数框架都这么做(HermesAgentScope…),只有少数选择框架层重试。

下列哪个 不是 最小 agent 的必要组成?

查看解析

向量数据库属于”记忆”组件的一种实现(第 5 章),是扩展件不是必需件。最小 agent = 模型 + 循环 + 工具 + 停止条件。

动手挑战

给 agent 加第二个工具 now()(返回当前日期时间),让它回答:“现在距离 2027 年元旦还有多少天?”

提示:往 TOOLS 里加一个无参数的 schema;在循环里按 call.function.name 分发到不同函数。做完你会发现分发逻辑开始变丑——这正是下一章要解决的问题。

小结

  • agent = 最小四件套:模型(决策)+ 循环(推进)+ 工具(触达外界)+ 停止条件(收手)。缺一样就不成 agent,多出来的都是扩展件。
  • 把控制流交给模型,才叫 agent。chatbot 的每一步都在等人输入;agent 让模型在运行时自己决定下一步。这是本质区别,与模型多大、能不能联网无关。
  • 上下文是循环的记忆,错误是循环的养料。每一步的决定和观察都要留在 messages 里,模型才能接着上一步想;错误回灌,模型才能自我修正。
  • 但这段代码埋着一个隐忧:工具的 schema 是手写的,工具的分发(calculator(**args))也是写死的。加一个工具就要动好几处,加十个工具呢?靠 if/else 按名字分发很快会糊成一团。下一章”工具系统”就来收拾它——用一个 工具注册表,让”定义一个函数”和”把它暴露给模型”变成同一件事。