AI Agents in Action · 第九章

让智能体
自己决定跑几轮

前八章的智能体都是一次性的:你问一句,它答一句,结束。 这一章让它自己决定要不要再来一轮——查完一遍觉得不够, 就自己列出新问题,接着查,直到它认为够了为止。 而这就带出了整章唯一真正的难题:什么时候停。

整章的心脏就是这三道门(while state.should_continue):

status == "in_progress"
模型说「够了」就会把它改成 complete
AND
iteration_count < max_iterations
硬上限,防止它没完没了
AND
len(follow_up_questions) > 0
没有新问题可查了,自然停

三道门全开才继续。任何一道关上,循环立刻结束——第三道最容易被忽略。

动手之前 —— 这一章开箱就挂

致命:代码引用的 npm 包根本不存在

这一章全部 6 个用到搜索的文件都写着同一行:

"args": ["-y", "@anthropic/brave-search-mcp"]

这个包在 npm 上不存在。我三重确认过:

registry 直查      @anthropic/brave-search-mcpHTTP 404
@anthropic scope  → 该 scope 下没有任何公开包
实际 npx 拉取    → npm error 404 Not Found

这不是版本过期,是@anthropic 这个 npm scope 下压根没有公开包, 作者多半是凭印象写的。所以这一章的每个例子照抄都会失败。

怎么修:换成真实存在的包。我查了两个可用的:

@modelcontextprotocol/server-brave-search  0.6.2(官方系列,和前几章同源)
brave-search-mcp                              2.1.0(社区版,更新更勤)

好消息是只用改包名,提示词一个字都不用动。我检查过—— 这一章的提示词从没提到任何工具名,模型是通过 MCP 协议 自己问出来有哪些工具的。我实测了官方那个包,它提供两个工具: brave_web_searchbrave_local_search这正是 MCP 的价值:换个 server 实现,上层无感。

BRAVE_API_KEY 是隐藏必需项,而且写 .env 里没用

你还需要一个 Brave 搜索的 API key(brave.com/search/api 有免费额度)。但有两个坑叠在一起:

一、.env.example 里没提它。只有 OPENAI_API_KEY 一行, 你根本不知道还要这个。

二、chapter_09/ 里没有一个文件调 load_dotenv() 我数过:零。所以就算你把 BRAVE_API_KEY 写进 .env 也没用—— 没人去读它。必须 export 到真实环境变量。

而且 5 个文件用的是 os.environ["BRAVE_API_KEY"] 方括号访问—— 缺了直接 KeyError 崩溃,连个像样的报错都没有。 (只有 07_task_loop.py 用了 .get(..., ""), 它会静默传个空字符串,然后你会收到一堆搜索失败——这个更难查。)

# 前八章那样写进 .env 在这一章不管用,必须 export
export OPENAI_BASE_URL="https://你的服务商/v1"
export OPENAI_API_KEY="sk-你的key"
export OPENAI_DEFAULT_MODEL="你的模型名"
export BRAVE_API_KEY="你的brave key"

# 或者更省事:在代码顶部自己补一行 load_dotenv()
# 下面的代码块我都补上了

第一部分 · 地基

两个不能运行的文件

0103 是这一章的地基,但它们不是程序—— 它们里面一行可执行代码都没有

状态:循环之间靠什么传递

对应清单 9.1 · 01_research_state_plan.py

整个文件只有三个 Pydantic 类。没有 main()、没有 asyncio.run、没有 __main__—— python 01_research_state_plan.py 跑它,屏幕上什么都不会出现。 它是给后面的文件抄的骨架,也是给书里排版用的。

from pydantic import BaseModel, Field


class SubTopic(BaseModel):
    """研究计划里的一个子话题。"""
    name: str
    status: str = "pending"  # pending(待办)| in_progress(进行中)| complete(已完成)
    notes: str = ""


class ResearchPlan(BaseModel):
    """指导整个研究过程的战略计划。"""
    sub_topics: list[SubTopic] = Field(default_factory=list)
    strategy_notes: str = ""

    @property
    def progress_summary(self) -> str:
        if not self.sub_topics:
            return "还没有计划"
        total = len(self.sub_topics)
        complete = sum(1 for st in self.sub_topics
                       if st.status == "complete")
        in_progress = sum(1 for st in self.sub_topics
                          if st.status == "in_progress")
        return (f"{complete}/{total} 已完成,"
                f"{in_progress} 进行中")


class ResearchState(BaseModel):
    goal: str = ""
    findings: list[str] = Field(default_factory=list)
    sources_consulted: list[str] = Field(default_factory=list)
    follow_up_questions: list[str] = Field(default_factory=list)
    plan: ResearchPlan = Field(default_factory=ResearchPlan)
    iteration_count: int = 0
    max_iterations: int = 10
    status: str = "in_progress"

    @property
    def should_continue(self) -> bool:
        # ↓ 这就是整章的终止门:三个条件缺一不可
        return (
            self.status == "in_progress"
            and self.iteration_count < self.max_iterations
            and len(self.follow_up_questions) > 0
        )
为什么「状态」突然成了主角

前八章你从来不用管这个——因为跑一次就结束了,没有「之间」。 一旦循环起来,每一轮的产出必须活到下一轮:查到了什么、 还有哪些问题没查、计划进行到哪了。

注意 to_context() 这个方法(上面省略了)—— 它把整个状态转成字符串塞回提示词。这就是循环的真相: 模型本身没有记忆,是你每轮把状态重新讲给它听。 所谓「智能体在推进研究」,实际是你在维护一个状态机, 每轮请模型看一眼、给点更新

输出契约:模型每轮必须交回什么

对应清单 9.3 · 03_iteration_output.py

同样是纯模型定义,21 行。它规定了模型每一轮必须交回的东西

class ResearchIteration(BaseModel):
    summary_of_findings: str      # 这一轮查到了什么
    sources_used: list[str] = []  # 用了哪些来源
    follow_up_questions: list[str] = []   # ← 下一轮查什么,也是终止门第三条
    goal_satisfied: bool = False  # ← 够了吗,这是模型自己按的停止键
    confidence: float = 0.0
    plan_updates: list[SubTopicUpdate] = []
    new_sub_topics: list[SubTopicUpdate] = []
    strategy_notes: str = ""
第二章的 output_type,到这里成了循环的方向盘

看这两个字段:goal_satisfiedfollow_up_questions—— 它们直接决定循环还转不转

goal_satisfied=True → 状态改成 complete → 第一道门关闭。
follow_up_questions=[] → 第三道门关闭。

所以「模型决定跑几轮」这件事,物理上就是靠 output_type 实现的。 第二章教结构化输出时它只是「方便解析」;到第七章它是「让判断能被代码消费」; 到这一章,它成了模型控制程序流的方向盘。

第二部分 · 主循环

深度研究循环

04_deep_research_loop.py,222 行,本章的主角。 它把 0103 的模型整段复制进来,加上循环本体。

一个 while,就是全部

对应清单 9.5

import asyncio
import os

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

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

# …… 中间是从 01 和 03 复制过来的模型定义,省略 ……

research_agent = Agent(
    name="Deep Research Agent",
    instructions="""
你是一个深度研究智能体。
你的目标是通过检索信息、阅读结果、综合发现,把一个话题研究透彻。

研究计划:
- 第一轮(还没有计划时),分析研究目标,通过 new_sub_topics 返回若干子话题
  来建立计划。每个子话题应该是目标的一个不同侧面。
  未开始的设为 "pending",当前正在研究的设为 "in_progress"。
- 后续每一轮,通过 plan_updates 更新已有的子话题(把状态改成 "in_progress"
  或 "complete",并在 notes 里总结学到了什么)。如果发现研究需要涉及计划外的
  领域,也可以通过 new_sub_topics 添加新的子话题。
- 用 strategy_notes 记录你对研究方法的高层观察,或者对策略的调整。

每一轮,回顾你的计划和已积累的状态,找出空白,检索新信息,更新你的发现。
follow_up_questions 里要放下一轮具体的检索查询。
计划提供战略方向,follow_up_questions 驱动战术执行。

当你认为已经有足够的信息来全面回答研究目标时,把 goal_satisfied 设为 true。
""",
    model=os.environ["OPENAI_DEFAULT_MODEL"],   # 书里写死的是 gpt-4o
    output_type=ResearchIteration,
)


async def run_research_loop(goal: str, max_iterations: int = 10):
    search_server = MCPServerStdio(
        name="brave-search",
        params={
            "command": "npx",
            # 仓库原文写的是 @anthropic/brave-search-mcp —— 这个包不存在!
            "args": ["-y", "@modelcontextprotocol/server-brave-search"],
            "env": {"BRAVE_API_KEY": os.environ["BRAVE_API_KEY"]},
        },
    )
    async with search_server:
        agent = research_agent.clone(mcp_servers=[search_server])
        state = ResearchState(
            goal=goal,
            max_iterations=max_iterations,
            follow_up_questions=[goal],   # ← 拿目标本身当第一个问题,把循环点着
        )

        while state.should_continue:
            state.iteration_count += 1
            question = state.follow_up_questions.pop(0)   # 从队头取一个
            input_context = dict(
                current_question=question,
                accumulated_state=state.to_context(),     # 把状态讲给模型听
                research_plan=state.plan.to_context(),
            )
            print(f"\n--- 第 {state.iteration_count} 轮 ---")
            print(f"正在调查:{question}")

            result = await Runner.run(agent, input=str(input_context))
            iteration = result.final_output

            # 把这一轮的产出并回状态
            state.findings.append(iteration.summary_of_findings)
            state.sources_consulted.extend(iteration.sources_used)
            state.follow_up_questions.extend(iteration.follow_up_questions)
            apply_plan_updates(state.plan, iteration)

            if iteration.goal_satisfied:
                state.status = "complete"       # ← 模型自己按下停止键
                print(f"目标已达成(置信度:{iteration.confidence:.0%})")

            print(f"计划进度:{state.plan.progress_summary}")

        return state
这个循环真正的巧思:拿目标当第一个问题

follow_up_questions=[goal]——把研究目标本身塞进问题队列。 为什么必须这样?因为终止门第三条要求 len(follow_up_questions) > 0队列空的话, while 一次都不会进

所以这一行不是随手写的,它是在给循环点火

第三道门是个真实的陷阱

注意循环体:pop(0) 取走一个,然后 extend(iteration.follow_up_questions) 加回若干个。 如果模型这一轮返回了空的 follow_up_questions, 而队列里又刚好没有别的问题了——循环立刻结束, 哪怕 goal_satisfied 还是 False,研究根本没做完。

你会得到一份「完成状态是 in_progress」的半成品,而且没有任何报错。 这是自主循环最典型的失败模式:不是崩溃,是悄悄地提前收工。

反过来也危险:模型每轮吐 5 个新问题、只消耗 1 个,队列会越滚越大, 一直烧到撞上 max_iterationsmax_iterations 不是可选项,是你唯一的钱包保险丝。

agent.clone() —— 一个之前没见过的写法

research_agent.clone(mcp_servers=[search_server]): agent 在模块顶层就定义好了,但 MCP server 只在 async with 里才活着。clone() 复制一份 agent 并挂上 server,而不去改那个全局对象。

对比第四章那个 research_agent.mcp_servers = [research_srv]—— 那是直接改全局对象,clone() 干净得多, 尤其在第八章讲的并发场景下(改全局 = 请求之间串状态)。

第三部分 · 另外三种循环

合成、任务队列、协作

剩下四个文件是同一个骨架的变奏。代码高度重复,我只讲区别。

05:查完之后,得有人把它写成文章

对应清单 9.6 · 05_research_synthesis.py(274 行)

04 循环结束后,你手里是一堆零散的 findings05 在循环之后加了一步:再请一个智能体, 把这堆碎片写成一份连贯的报告

为什么合成必须单独一步

因为循环里的每一轮都只看得见局部——它在回答「这个具体问题」, 不是在写整篇文章。指望它边查边写,结果就是一堆各说各话的段落。

先发散(循环收集),再收敛(一次性合成)—— 这个两段式在真实的 deep research 产品里是标配。

07:不是研究,是把一堆活干完

对应清单 9.7 · 07_task_loop.py(185 行)+ 06_task_loop.mmd

换了个场景:队列里有一批任务,挨个做,失败的重新排队。 配套的 06_task_loop.mmd 是张 Mermaid 流程图, 画的正是 queue → agent → 成功?→ 要重试吗?→ 重新入队。

.mmd 文件的编号是错位的

这一章有两个 .mmd(Mermaid 图):05_research_synthesis.mmd 对应 05_research_synthesis.py,没问题;但 06_task_loop.mmd 对应的是 07_task_loop.py—— 编号对不上。另外没有任何 Python 代码读取 .mmd, 它们纯粹是文档,用来配书里的插图。

只有这个文件用了 .get(),而这更危险

07 是唯一一个写 os.environ.get("BRAVE_API_KEY", "") 的文件。 看起来比方括号「安全」,其实更坑缺 key 时它不崩,而是静默传一个空字符串进去, 然后你会看到搜索一直失败、任务一直重试、循环一直烧钱—— 却没有任何一条报错告诉你是 key 没配。

崩得响亮,好过挂得安静。这一处仓库的「防御性写法」反而是负分。

08 / 09:第三层 —— 循环套循环

对应 9.3 / 9.4 · 08_orchestrator_loop.py(305 行)、09_collaboration_loop.py(191 行)

书里把智能体循环分成三层,到这儿就完整了:

  • 第一层 · 内循环——感知、规划、行动、学习。 这层 SDK 已经替你转了(第二章那个「一次运行其实是两次模型调用」就是它)。
  • 第二层 · 任务循环——就是 0407 那个 while这层要你自己写。
  • 第三层 · 元循环——0809一个编排者在管好几个智能体,每个智能体自己又在转第二层的循环
这一章和第四章的分界线

第四章也讲多智能体,为什么不一样?第四章的移交是一条单行道—— 转出去,走完,结束。这一章是循环——编排者会反复调度、 看结果、再决定下一步派谁。

换句话说:第四章交出去的是「谁来做」,这一章交出去的是「做几遍」。 后者危险得多,因为它直接连着你的账单

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