AI Agents in Action · 第五章

让模型「想一想」
的四种形状

前四章都在给智能体加能力——工具、协议、同伴、护栏。 第五章不加能力,只改一件事:让它在回答前多想一会儿。 办法有四种,区别在于「想」的形状不同:一条链、一个圈、一棵树、一个回路。 形状越复杂,效果越好,烧的钱也越多——所以这一章真正的问题不是「哪个最强」, 而是什么时候值得

点一下跳到对应小节。第五节讲的是把「想」这件事整个外包出去。

动手之前

环境照旧。这一章的代码比前几章都短(最长的文件才 92 行,好几个不到 40 行), 因为它讲的是提示词和流程编排,不是接管道。但它是坑最多的一章—— 我实测出三个真 bug,其中一个足以让整章的示例得出错误答案。先说最要命的那个。

这一章给智能体的尺子是坏的

04_time_travel_agent.py 给智能体两把工具:travel_back(回到过去) 和 travel_forward(去往未来)。但 travel_forward 的实现是这样的:

def travel_forward(year, years):
    # docstring 说:从起始年份往跳指定的年数
    return f"Current year in time: {year - years}"  ← 是减号!

「前进」做的是减法。从 2050 年前进 50 年,它告诉模型你到了 2000 年—— 正确答案是 2100,差了整整 100 年。这显然是从 travel_back 复制粘贴时忘了改运算符:同一个仓库里的 02_ReAct_agent.py 写的就是 year + years,是对的。

更糟的是:号称是「解答」的 04_time_travel_agent_soluriont.py 里, 这个 bug 原样还在。(对,文件名也拼错了,是 soluriont 不是 solution。)

为什么这件事特别讽刺?因为这一章的全部主题是让模型想得更周密。 可无论 CoT、ToT 还是 Reflexion 把推理做得多漂亮,它查一次工具就被带进沟里。 模型的推理再对,尺子是歪的,答案就是错的。下面我的代码块里把它改成了加号, 你跟着仓库敲的时候记得自己改。

这一章有三个 .md 文件,不是代码

清单 5.1、5.3、5.4 对应的是 01_demonstrating_CoT_reasoning.md01_planning_san_fran_trip.md01_planning_san_fran_trip_updated.md—— 它们是聊天记录,是作者在 ChatGPT 里手动试提示词留下的对话,用来给你看 「加不加『一步步想』这句话,输出差别有多大」。它们不是能跑的程序, 用编辑器打开看看就行。真正能跑的 Python 只有 6 个。

形状一 · 一条链

CoT:世界上最便宜的性能提升

Chain-of-Thought(思维链)说白了就是:让模型别急着答,先把过程写出来。 实现方式是——在提示词里加一句话。就这样。

22 行,全部重点在一句提示词里

对应清单 5.5

import asyncio

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

# 定义一个永远会一步步讲清自己推理过程的智能体
cot_agent = Agent(
    name="TimeTravelerCoT",
    instructions=(
        "你是一个时间旅行问题的解答者。"
        "请一步一步地推导出解法,然后再给出最终答案。"
    ),
)

# 一道时间旅行的例题
question = (
    "从 2025 年出发,你先回到 10 年前,然后又前进 5 年。"
    "你最终停在哪一年?"
)

# 运行智能体(异步环境里用 await,脚本里也可以用 Runner.run_sync)
result = asyncio.run(Runner.run(cot_agent, input=question))
print(result.final_output)

起作用的就是那半句:「请一步一步地推导出解法,然后再给出最终答案」。 没有工具、没有循环、没有第二个 agent。代价是多花几个 token,收益是算术题正确率明显上升。

为什么加一句话就管用

回想第二章那句「LLM 是概率性的 token 机器」——它是一个词一个词往外蹦的, 而且吐出来的词会成为它下一个词的输入。你让它直接给答案, 它就得在一个 token 里「一步到位」;你让它先写过程, 那些中间步骤就变成了它自己的草稿纸,后面的推理能读到前面的结论。

所以 CoT 不是什么玄学,它是在给模型争取「算草稿」的空间。 这也解释了它的边界:草稿本身错了,它照样一路错到底—— 因为它没有任何办法验证自己。这正是后面三种形状要解决的。

形状二 · 一个圈

ReAct:想一步,动一下,看一眼

CoT 只会想。ReAct(Reason + Act)让它想一步、调个工具、看看结果、再接着想。 这个圈可以转很多轮。

两把工具,一段提示词把圈描述出来

对应清单 5.6

import asyncio

from agents import (Agent, Runner, function_tool,
                    set_default_openai_api, set_tracing_disabled)
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)


# 定义两个做时间计算的工具
@function_tool
def travel_back(year: int, years: int) -> int:
    """从起始年份往回跳指定的年数。"""
    return year - years


@function_tool
def travel_forward(year: int, years: int) -> int:
    """从起始年份往后跳指定的年数。"""
    return year + years


# 建一个配好工具的智能体
react_agent = Agent(
    name="TimeTravelerReAct",
    instructions=(
        "你是一个时间旅行助手。你有 'travel_back' 和 'travel_forward' 两个工具可以做时间跳跃。"
        "首先,一步一步地思考这个问题。如果需要,就用工具来计算年份。"
        "用完工具之后,回顾一下结果,然后继续推理。"
        "收集到足够信息后,给出最终答案。"
    ),
    tools=[travel_back, travel_forward],
)

# 一道必须动用工具才能算清的时间旅行题
problem = (
    "我现在在 2050 年。"
    "我先回到 25 年前,然后前进 10 年,"
    "最后又回退 5 年。现在是哪一年?"
)

result = asyncio.run(Runner.run(react_agent, input=problem))
print(result.final_output)

提示词里那三句——「先思考」「用工具算」「回顾结果再继续推理」—— 就是 ReAct 的三个动作:Reason、Act、Observe。你不用写循环, 循环是 SDK 帮你转的:模型说要调工具 → SDK 调 → 把结果塞回对话 → 模型接着想。 第二章第 07 步那个「一次运行其实是两次模型调用」,在这儿变成了转好几圈。

ReAct 相比 CoT 真正多出来的东西

CoT 的草稿纸只有模型自己写的字;ReAct 的草稿纸上有外部世界的回音。 算错了 2050 - 25?工具会给出真实结果。 这是 CoT 做不到的——它没法验证自己。

但这也是它的新弱点:它现在完全信任工具。 工具说什么它信什么。本章那个 travel_forward 减法 bug, 杀死的正是 ReAct 这个圈——回音是错的,转再多圈也只会更自信地错下去。

形状三 · 一棵树

ToT:多开几条路,再挑一条

CoT 和 ReAct 都只走一条路——第一步想歪了就全歪。 Tree-of-Thought 的思路是:先生成几个不同的开头,找个评委挑挑,只把有戏的那条往下走。

两个 agent:一个负责想,一个负责挑

对应清单 5.7

注意这一版没有任何新 API——就是拿两个普通 agent,用 Python 的 for 循环手动搭出一棵(很浅的)树。ToT 是个流程模式,不是 SDK 功能。

import asyncio

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

# 负责生成「下一步想法」的智能体
generator = Agent(
    name="ToT-Generator",
    instructions="给定当前的情况,头脑风暴出一个可能的下一步动作,用来达成目标。",
)
# 负责评估「半成品方案」的智能体
evaluator = Agent(
    name="ToT-Evaluator",
    instructions="评估这个方案解决问题的可能性有多大。只回答「有希望」或者「没戏」。",
)

problem = """
你要用一台时光机从 2025 年回到 1800 年,
这台机器每次只能跳 -100 年或者 -30 年。
"""


async def main():
    # 先生成 3 个初始想法作为候选
    initial_thoughts = []
    for i in range(3):
        resp = await Runner.run(
            generator, input=f"问题:{problem}\n想一个第一步。"
        )
        initial_thoughts.append(resp.final_output.strip())

    # 逐个评估并展开(相当于广度优先搜索展开一层)
    promising_branches = []
    for thought in initial_thoughts:
        eval_resp = await Runner.run(
            evaluator, input=f"方案:{thought}\n这个有希望吗?"
        )
        # 注意:这里的子串判断是仓库原文的写法,它有 bug —— 见下方说明
        if "有希望" in eval_resp.final_output:
            # 这条有戏,再往下展开一步
            next_step = await Runner.run(
                generator, input=f"当前想法:{thought}\n下一步是什么?"
            )
            promising_branches.append(f"{thought} -> {next_step.final_output.strip()}")

    print("初始候选想法:", initial_thoughts)
    print(
        "展开后有希望的分支:",
        promising_branches[0] if promising_branches else "无",
    )


if __name__ == "__main__":
    asyncio.run(main())
这个筛选条件是坏的 —— 中英文都一样坏

仓库原文这么筛:if "promising" in eval_resp.final_output.lower()。 评委被要求只回答 promisingunlikely,看起来没问题。 但模型是不听话的,它很爱说完整句子。我实测了几种它可能的回答:

模型回 'promising'             → 判定通过? True  ✓
模型回 'unlikely'              → 判定通过? False  ✓
模型回 'This is not promising'  → 判定通过? True  ← 完全反了

"not promising" 里面含有 "promising", 子串一匹配就通过了。评委明明否了,代码却当它点头。 翻成中文一模一样坏:「不太有希望」里含有「有希望」。

这和第四章那个输入护栏是同一类错误in 去解析模型的自然语言输出。 正确做法是让评委走 output_type 返回一个结构化的布尔值 (第二章教过),而不是赌它只吐一个词。

这棵「树」其实只有一层半

真正的 ToT 要反复展开、回溯、剪枝。这个例子只生成 3 个候选、评一次、往下走一步就停了—— 代码里那句注释 one iteration of BFS expansion 说得很老实。 它是给你看骨架的,别当成能用的实现。

另外算一下账:3 次生成 + 3 次评估 + 最多 3 次展开 = 最多 9 次模型调用, 就为了走出两步。这就是「形状越复杂越烧钱」的具体含义。

形状四 · 一个回路

Reflexion:做错了,挨顿批,再来一次

ToT 是事前多想几条路。Reflexion 是事后——先做,做错了让另一个 agent 指出错在哪,把这个批评塞回去重做。

解题的和挑刺的,来回踢五轮

对应清单 5.8 · 书里把文件名拼成了 03_relexion_agents.py

def get_reflexion_solver_instructions(
    run_context: RunContextWrapper[str], agent: Agent[str]
) -> str:
    """给 reflexion 解题智能体生成指令。"""
    instructions = (
        "你是一个时间旅行专家。请一步一步地解题,"
        "并且小心不要出错。"
    )
    return instructions + "\n提示:\n" + run_context.context


solver = Agent(
    name="TimeTravelerReflexion", instructions=get_reflexion_solver_instructions
)
critic = Agent(
    name="TimeTravelCritic",
    instructions=(
        "你是一位专家导师。如果解法是错的,指出错在哪里,"
        "并给出一条简明的改进提示。"
    ),
)

TARGET_DAYS = "26"      # 期望的最终答案
MAX_ATTEMPTS = 5        # 防止无限重试的上限


async def main():
    feedback_hint = ""
    for attempt_no in range(1, MAX_ATTEMPTS + 1):
        # 跑解题智能体,把上一轮的批评当 context 传进去
        result = await Runner.run(solver, input=problem, context=feedback_hint)
        answer = result.final_output.strip()
        print(f"\n第 {attempt_no} 次尝试:\n{answer}")

        # --- 一个很朴素的正确性检查 ---
        has_correct_days = TARGET_DAYS in answer
        says_claim_correct = "yes" in answer.lower() or "correct" in answer.lower()
        solved = has_correct_days and says_claim_correct

        if solved:
            print("✅ 解答通过。")
            break

        # --- 没做对:生成批评,然后重来 ---
        feedback_prompt = (
            f"给出的解法:\n{answer}\n\n"
            f"期望的最终天数:{TARGET_DAYS}\n"
            "简要说明错在哪里,并给一条有帮助的提示。"
        )
        feedback_resp = await Runner.run(critic, input=feedback_prompt)
        hint = feedback_resp.final_output.strip()

        print(f"批评意见:\n{hint}")
        feedback_hint = hint
    else:
        print("\n⚠️  达到最大尝试次数,仍未得到正确解答。")

这里唯一的新 API 是 动态 instructionsinstructions= 可以传一个函数而不是字符串,每次运行时调用它现算。 配合 Runner.run(..., context=feedback_hint), 就实现了「把上一轮的批评拼进这一轮的人设里」。

这个文件有三处不对劲

一、problem 被赋值了两次。第 30 行先定义了一道是非题 (「我说我现在在 1990 年,对吗?」),第 34 行立刻用另一道题覆盖了它。 第一道是死代码,永远不会被用到。

二、判定条件是从第一道题留下来的。 says_claim_correct = "yes" in answer or "correct" in answer—— 这对是非题是合理的。但实际在跑的第二道题问的是「多少天」,根本不是是非题, 模型没理由回答 yes。这个条件基本只能靠模型碰巧说出「correct」才能满足, 大概率会白白跑满 5 轮

三、题面本身有笔误。witness the end of the end—— 重复了 the end,应该是 the end of the event。 这句话是直接发给模型的,会让它困惑。

这三处叠在一起说明:Reflexion 的循环靠的是「你能判断对错」, 而这里的判断器是坏的。这恰恰点出了 Reflexion 的真正门槛—— 你得先有一个靠谱的评判标准,否则循环就是在空转烧钱TARGET_DAYS = "26" 这种「预先知道标准答案」的做法, 在真实场景里恰恰是最不成立的。

第五节

把「想」这件事整个外包出去

前四种形状都是你自己在提示词或 Python 里搭出来的。 第五种:用一个现成的 MCP server,让它替你管思考流程。

sequential-thinking:一行 npx,白捡一个思考框架

对应清单 5.9

第三章和第四章已经出现过它 —— @modelcontextprotocol/server-sequential-thinking。 这一节终于正面讲它是什么。这个文件干的事很朴素:连上它,然后问它「你有啥工具」

import asyncio

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from agents.mcp import MCPServerStdio
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)


async def main():
    # 先把 server 实例化出来……
    thinking_srv = MCPServerStdio(
        name="sequential-thinking",
        params={
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
        },
    )

    instructions = """
你是一个乐于助人的规划助手。
    """
    agent = Agent(
        name="Assistant",
        instructions=instructions,
        mcp_servers=[thinking_srv],
    )

    async with thinking_srv:
        tools = await thinking_srv.list_tools()
        print("可用的工具:", tools)
        goal = """
找出并输出你手上有哪些工具和功能。
"""
        print("运行中……", goal)
        result = await Runner.run(agent, goal)
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

await thinking_srv.list_tools() 是这里唯一的新东西—— 不经过模型,直接问 server 要工具清单。这其实是个很好的调试习惯, 和第三章的 mcp dev 一个道理:先确认 server 那边有什么,再去麻烦模型。

这个 server 只有一个工具,但它很怪

清单 5.10 单独把它的工具描述截出来讲,是有道理的——这个工具不干活。 它不算数、不查资料、不读文件。你调它,它就让你记录「这是第几步想法、还要想几步、 要不要推翻之前的某一步」,然后把这些还给你

说白了它是一个「强制你打草稿的表格」。价值不在于它算了什么, 而在于它逼着模型把思考过程外化成一步一步的结构,还允许回头修正。 这等于把 CoT(一条链)升级成了带回溯的链,而且是由 server 来维持状态, 不用你在提示词里教。

代价也很实在:模型每「想一步」都是一次工具调用。一道题想十步, 就是十个来回。这是本章最贵的形状。

合体:ReAct 的工具 + 外包的思考

对应清单 5.11 · 就是尺子坏掉的那个文件

这一节把前面的东西拼在一起:真的能算数的工具(ReAct) + sequential-thinking(外包思考),去解一道故意绕人的题。

import asyncio
import os

from agents import (Agent, Runner, function_tool,
                    set_default_openai_api, set_tracing_disabled)
from agents.mcp import MCPServerStdio
from dotenv import load_dotenv

# 仓库原文没有这三行,是为了接第三方模型加的
load_dotenv()
set_default_openai_api("chat_completions")
set_tracing_disabled(True)


@function_tool
def travel_back(year: int, years: int) -> str:
    """
    从起始年份往回跳指定的年数。
    """
    print(f"时间回退 {years} 年")
    return f"当前所在年份:{year - years}"


@function_tool
def travel_forward(year: int, years: int) -> str:
    """从起始年份往后跳指定的年数。"""
    print(f"时间前进 {years} 年")
    # 仓库原文这里写的是 year - years,是个 bug,已改成加号
    return f"当前所在年份:{year + years}"


async def main():
    thinking_srv = MCPServerStdio(
        name="sequential-thinking",
        params={
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
        },
    )

    instructions = """
你是一个时间旅行助手。
你有 'travel_back' 和 'travel_forward' 两个工具可以做时间跳跃。
首先,一步一步地思考这个问题。
你必须使用工具来计算年份。
用完工具之后,回顾一下结果,然后继续推理。
收集到足够信息后,给出最终答案。
    """
    agent = Agent(
        model=os.environ["OPENAI_DEFAULT_MODEL"],  # 书里写死的是 gpt-4o
        name="Time Travel Agent",
        instructions=instructions,
        tools=[travel_back, travel_forward],
        mcp_servers=[thinking_srv],
    )
    async with thinking_srv:
        time_travel_problem = """
在一部科幻电影里,Alex 是一名时间旅行者,他决定回到过去,
去见证一场发生在 125 年前、持续了 10 天的著名历史事件。
他在事件开始前三天抵达。
但是,在过去待了六天之后,他向前跳了 50 年,
并在那里停留了 20 天。然后,他又回到过去,
去见证这场事件的结束。Alex 当前所在的年份是 2050 年。
在他看到事件结束之前,Alex 在过去总共待了多少天?
"""
        print("运行中……")
        result = await Runner.run(
            agent,
            time_travel_problem,
            max_turns=25,
        )
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())
这道题为什么这么绕

它把「年」和「天」两个尺度故意混在一起:跳跃是按年算的, 但问题问的是天数,而且只问「在过去待的天数」——中间那 20 天是在未来待的, 不算数。模型很容易把它们加到一起。

这就是为什么要上 sequential-thinking:逼模型把每一步单独记下来, 才不会把不同尺度的数字糊成一团。但前提是工具算得对—— 而仓库原版的 travel_forward 会把「前进 50 年」算成后退 50 年, 整道题从根上就歪了。上面这版我改成了加号。

这一章真正的问题

那到底该用哪个

书里 5.3.3 专门有一节讲这个,我觉得这是全章最该记住的部分—— 因为默认答案是「都不用」

形状成本什么时候用
CoT 几乎为零
多几个 token
默认就该加。任何需要多步算的任务,加那句「一步步想」几乎稳赚。
ReAct 每转一圈
一次调用
答案依赖外部事实时——要查数据、算数、读文件。光靠模型脑补会错。
ToT 候选数 × 2+
次调用
第一步就可能走错、且错了很难回头时。搜索、规划这类。日常问答用它是浪费。
Reflexion 每轮 2 次
× 轮数
你有办法判断对错时(有测试、有标准答案、有校验器)。判断不了就别用,只是烧钱。
Sequential
Thinking
每想一步
一次调用
步骤多、需要中途推翻重来的长任务。短任务上它纯属自找麻烦。
一条贯穿本章的规律

把这五种排一排你会发现:它们全都在花「更多次模型调用」来买「更少的错误」。 CoT 买得最划算(几乎白送),Sequential Thinking 买得最贵。

所以选型的问题从来不是「哪个最聪明」,而是:这个任务错一次的代价, 值不值得多花十倍的钱去防。写周报,CoT 就够了; 算药物剂量,Reflexion 都嫌不够。

还有个更实际的提醒:现在的推理模型(o 系列这类)已经把 CoT 内置了, 你再手动写「一步步想」多半是白写。这一章的价值不在于照抄这些提示词, 而在于理解这些形状——因为当你需要 ToT、Reflexion 这种模型内部做不到 的流程时,还是得自己搭。

跟着书走之前,先看这几条

这一章是目前为止 bug 最多的一章。前三条都是我实测确认的真问题。