AI Agents in Action · 第二章

把一台概率机器
拴成一个能干活的智能体

第二章有 8 个 Python 代码实例,它们不是 8 个孤立的例子,而是同一个「研究规划智能体」被改了 8 次。 每一步只加一件事,约束一步步收紧:从随口输出一段话,到吐出你敢直接喂给下游程序的强类型 JSON。 其中第 4 步是作者故意让你跑崩的——那是本章最值钱的一节。

约束最松约束最紧

动手之前

这章的代码在仓库的 chapter_02/ 目录里。你需要 Python 3.11 以上、一个 OpenAI API key, 以及一件很容易忽略的事:从仓库根目录运行

书里用的是 python -m venv + pip,这儿换成 uv——同一个 Astral 团队做的 Rust 版包管理器,装这套依赖比 pip 快一个数量级,而且能顺手帮你把 Python 3.11 装好, 不用自己操心系统里那个 Python 是什么版本。

# 1. 装 uv(三选一)
curl -LsSf https://astral.sh/uv/install.sh | sh    # macOS / Linux
brew install uv                                    # macOS 用 Homebrew 也行
# Windows(PowerShell): irm https://astral.sh/uv/install.ps1 | iex

# 2. 到仓库根目录,建虚拟环境
#    没装 3.11 的话 uv 会自己下一个,不碰你系统里的 Python
cd AI-Agent-Workflows-main
uv venv --python 3.11

# 3. 激活
source .venv/bin/activate         # Windows: .venv\Scripts\activate

# 4. 装依赖(这步是 uv 最爽的地方,几秒钟的事)
uv pip install -r requirements.txt

然后在仓库根目录建一个 .env 文件。书里只让你写一行 API key, 但我们用的是兼容 OpenAI 格式的第三方模型服务,所以要写三行:

# 你的服务商地址,注意一般要带 /v1 结尾
OPENAI_BASE_URL=https://你的服务商地址/v1

# 你的服务商给的 key(不是 OpenAI 官方的)
OPENAI_API_KEY=sk-你的key

# 你的服务商支持的模型名,比如 qwen-max、deepseek-chat、glm-4 之类
OPENAI_DEFAULT_MODEL=你的模型名

这三个变量名不是随便起的,是 OpenAI Agents SDK 认的名字,写对了它会自动读走:

就可以跑了:

python chapter_02/01_first_agent.py

# 或者不激活环境,用 uv run 直接跑:
uv run python chapter_02/01_first_agent.py
两个关于 uv 的小提醒

目录名不一样。uv venv 建的是 .venv(带点), 书里 python -m venv venv 建的是 venv(不带点)。 激活命令跟着变,别照抄书里的。好消息是 .venv 已经在仓库的 .gitignore 里了。

别急着用 uv add这个仓库只有 requirements.txt,没有 pyproject.toml,不是 uv 意义上的「项目」。所以用 uv pip install -r 这种兼容模式,而不是 uv add——后者会给你凭空建一个 pyproject.toml, 反而把事情搞复杂。顺带一提,requirements.txt本身就列着 uv, 作者原本就打算让你用它。

光有 .env 还不够,每个例子都得加两行

用第三方模型服务时,只配 .env 会在两个地方翻车,所以下面 8 段代码里都多了这两行:

一、set_default_openai_api("chat_completions")——不加必崩。 SDK 默认走的是 OpenAI 的 Responses API,会去请求 /v1/responses; 而绝大多数第三方兼容服务只实现了老的 /v1/chat/completions。 我拿一个假服务实测过:不加这行,SDK 打的是 /v1/responses;加上之后才变成 /v1/chat/completions。这不是可有可无的优化,是必需的。

二、set_tracing_disabled(True)——不加会刷一屏报错。 SDK 的追踪数据只能发往 OpenAI 官方,端点在源码里是写死的 (https://api.openai.com/v1/traces/ingest)。你手里是第三方的 key, 传上去只会得到 401。这件事直接影响第 06 和 08 步——那两步整节都在讲追踪, 到时候我再细说怎么办。

关于这里的代码

下面 8 段代码的结构和仓库里的文件逐行对应,但我把提示词、注释和 docstring 都译成了中文, 方便你边读边懂。有几处翻译是有实际后果的,先说清楚:

一、智能体会改说中文。提示词换成中文之后,输出的任务列表也会是中文。这是好事, 但你对照书里的英文截图时会发现对不上——不是你错了。

二、「5 words or less」翻不了。原文限制的是英文单词数,中文里没有对应概念, 所以我改成了「每条不超过 10 个字」。提示词里的长度限制天生不能直译, 换语言就得重新定标准,这一点在你自己写中文提示词时同样成立。

三、Wikipedia / Google / YouTube 没翻。它们是专有名词, 而且在第 08 步里是字典的键——翻成中文的话,模型传回来的名字就对不上键了,直接 KeyError。

四、name="研究规划师" 在这章没问题,第 4 章要留神。 从第 4 章的移交(handoff)开始,SDK 会拿智能体的名字自动拼出工具名(形如 transfer_to_xxx)。到那时候用中文名可能拼出不合法的工具名, 所以后面几章建议把 name 留成英文。

为什么必须在根目录跑

代码里的 load_dotenv() 是从「当前工作目录」往上找 .env 的。 你要是 cd chapter_02 再跑,它找不到根目录那个 .env,就会报没有 API key。 顺带一提:整个仓库里只有第 2、6、7 章和 demo_project 调了 load_dotenv(), 第 3、4、5、9 章压根不读 .env,得自己 export 环境变量。这一点书里没讲清楚。

01

最小的智能体:只有人设,没有别的

新增:Agent + Runner · 对应清单 2.3

一个智能体最少需要两样东西:一段说明书(instructions,告诉它自己是谁、要干嘛), 和一个跑它的人(Runner)。就这么多。

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

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去,先关掉
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务(每条不超过 10 个字)。
"""

agent = Agent(
    name="研究规划师",
    instructions=instructions,
)

input = "学习 AI 智能体"

result = Runner.run_sync(
    agent,
    input=input,
)

print(result.final_output)

instructions 就是书里说的「智能体人设」(persona)——五大功能层里的第一层。 它不是普通的提示词,而是这个智能体每次被调用时都会带上的固定身份。 Runner.run_sync() 的意思是「同步跑一次」:卡在这儿等模型回话,拿到结果再往下走。

跑完你会看到一段自由格式的文本,大致是 5 条编号任务。注意「大致」—— 它是一段话,不是数据。你想拿第 3 条任务?只能自己去切字符串。这就是接下来几步要解决的问题。

一个已经埋下的雷

input = "学习 AI 智能体" 这行把 Python 内置的 input() 函数覆盖掉了。 这个文件里没出事,因为它后面不需要读键盘输入。但你要是照着这个风格往下写,某天想加一句 input("按回车继续"),就会莫名其妙报 TypeError: 'str' object is not callable。 建议自己改名叫 topic

02

拧旋钮:让它每次说的话都一样

新增:model + ModelSettings · 对应清单 2.5

上一步的 agent 没指定模型,用的是 SDK 默认值,而且每次运行结果都不太一样。 这一步把模型和一堆参数都写死。

import os

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

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去,先关掉
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务(每条不超过 10 个字)。
"""

agent = Agent(
    name="研究规划师",
    instructions=instructions,
    model=os.environ["OPENAI_DEFAULT_MODEL"],  # 书里写死的是 gpt-4.1,这里换成你服务商的模型
    model_settings=ModelSettings(
        temperature=0.0,  # 温度设为 0,让结果可复现
        max_tokens=150,  # 回复最多用多少 token
        top_p=1.0,  # top-p 采样参数,1.0 表示不裁剪
        frequency_penalty=0.5,  # 惩罚:同一个词翻来覆去说
        presence_penalty=0.5,  # 惩罚:老围着同一个话题打转
    )
)

input = "学习 AI 智能体"

result = Runner.run_sync(
    agent,
    input=input,
    )

print(result.final_output)

五个旋钮,用大白话说就是:

  • temperature(温度)——最重要的一个。0.0 意思是「每次都挑概率最高的词」, 所以同样的输入基本给同样的输出,适合要复现的场景。调到 1.0 它就开始放飞,同一个问题给你五个花样。
  • max_tokens——最多让它说多少。150 挺紧的,够 5 条短任务,但你要是让它写长文会被硬生生截断。
  • top_p——另一种控随机的方式:只从「累计概率排前面的那一小撮词」里挑。 1.0 = 不裁剪。它和 temperature 是两套刹车,一般只动一个,两个一起拧容易互相打架。
  • frequency_penalty——罚「同一个词翻来覆去说」。
  • presence_penalty——罚「老围着同一个话题打转」,鼓励它换点新东西。
试一下就懂了

temperature 设成 0.0 跑两遍,输出几乎逐字相同;改成 1.0 再跑, 就能看出差别。书里 2.5 的练习 2 就是让你干这个——这是全章最值得亲手做一遍的实验。

03

要结构:别给我一段话,给我一个列表

新增:output_type + Pydantic · 对应清单 2.7

到目前为止输出还是一坨文本。这一步用 output_type 告诉智能体: 你必须按我这个形状回话。形状用 Pydantic 的 BaseModel 来描述。

from typing import List

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

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去,先关掉
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务(每条不超过 10 个字)。
"""

class ResearchPlanModel(BaseModel):
    tasks: List[str]
    """研究要执行的任务列表。"""


agent = Agent(
    name="研究规划师",
    instructions=instructions,
    output_type=ResearchPlanModel,
    )

input = "学习 AI 智能体"

result = Runner.run_sync(
    agent,
    input=input,
    )

print(result.final_output)

现在 result.final_output 不再是字符串,而是一个真正的 ResearchPlanModel 对象。 你可以直接 result.final_output.tasks[2] 拿第 3 条任务。 底层发生的事是:SDK 把你的 Pydantic 类翻译成 JSON schema 发给模型,并要求模型严格照着填。

这一版能正常跑。记住这句话,因为下一步就跑不了了。

04

故意翻车:这一步跑起来会报错

新增:dict[int, str] → UserError · 对应清单 2.8

先说结论

这个文件是设计好要失败的。你跑它会看到一个关于严格 JSON 的 UserError。 这不是仓库的 bug,也不是你环境的问题——书里 2.5 的练习 3 明确要求你「运行脚本,观察这个 UserError」。 别去修它,看懂为什么崩就行,第 05 步会给你正确解法。

和上一步的唯一区别在一行:tasksList[str] 改成了 dict[int, str]。 想法很自然——我想要「编号 → 任务描述」的映射嘛。

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

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去,先关掉
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务(每条不超过 10 个字)。
"""

class ResearchPlanModel(BaseModel):
    tasks: dict[int, str]
    """研究要执行的任务列表。"""


agent = Agent(
    name="研究规划师",
    instructions=instructions,
    output_type=ResearchPlanModel,
    )

input = "学习 AI 智能体"

result = Runner.run_sync(
    agent,
    input=input,
    )

print(result.final_output)

为什么不行?OpenAI 的结构化输出走的是「严格模式」的 JSON schema,规矩比 Python 的类型系统窄得多。 dict[int, str] 翻译成 JSON 就是「一个对象,键是任意整数,值是字符串」—— 可是 JSON 对象的键永远是字符串,而且严格模式要求你把每个字段名都提前列清楚, 不接受「键是啥我也不知道,运行时才定」这种开放结构。所以 SDK 在把你的模型转成 schema 时就直接拒绝了, 请求根本没发出去。

这个坑值得单独设计一节,是因为它是新手最常撞的一堵墙: Python 里合法的类型,不一定是严格 JSON schema 里合法的类型。 凡是「键不固定」的结构(dictAny、可选的联合类型等等)都容易在这儿翻车。

05

正确解法:把「键不固定」改成「列表里装固定形状」

新增:TypedDict + extra='forbid' · 对应清单 2.9

解法的思路很简单:既然严格模式不接受「不知道键是啥」,那就别用键,改用列表。 原来想表达「1 → 干这个」,现在改成「列表里的一项,它有 id 和 description 两个固定字段」。

from agents import Agent, Runner, set_default_openai_api, set_tracing_disabled
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict
from typing_extensions import TypedDict

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去,先关掉
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务(每条不超过 10 个字)。
"""

class Task(TypedDict):
    id: int
    description: str

class ResearchPlanModel(BaseModel):
    tasks: list[Task]
    """带编号的研究任务。"""

    model_config = ConfigDict(extra='forbid')


agent = Agent(
    name="研究规划师",
    instructions=instructions,
    output_type=ResearchPlanModel,
    )

input = "学习 AI 智能体"

result = Runner.run_sync(
    agent,
    input=input,
    )

print(result.final_output)

两个新东西:

  • TypedDict——声明「这是个字典,但它的键我提前写死:iddescription」。 键固定了,严格 schema 就认了。
  • model_config = ConfigDict(extra='forbid')——翻译成 JSON schema 就是 additionalProperties: false,意思是「除了我列的字段,多一个都不许有」。 严格模式要的就是这个。

这个「把映射拍平成列表」的套路,你在后面几章还会反复见到。 遇到严格 schema 报错,第一反应就该是:我这儿是不是有个键不固定的结构?

06

装个行车记录仪

新增:trace() · 对应清单 2.10

代码和上一步一模一样,只多了一个 with 语句把 Runner.run_sync() 包住。

from agents import (Agent, Runner, set_default_openai_api,
                    set_tracing_disabled, trace)
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict
from typing_extensions import TypedDict

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去。
# 关掉之后下面的 trace() 就变成空转 —— 详见这一节的说明。
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务(每条不超过 10 个字)。
"""

class Task(TypedDict):
    id: int
    description: str

class ResearchPlanModel(BaseModel):
    tasks: list[Task]
    """带编号的研究任务。"""

    model_config = ConfigDict(extra='forbid')


agent = Agent(
    name="研究规划师",
    instructions=instructions,
    output_type=ResearchPlanModel,
    )

input = "学习 AI 智能体"

with trace("深度研究工作流"):
    result = Runner.run_sync(
        agent,
        input=input,
        )

print(result.final_output)

trace("深度研究工作流") 的本意是:把这一次运行的全过程录下来传到 OpenAI 后台, 然后你去 platform.openai.com/traces 看发了什么提示词、模型回了什么、花了多久、烧了多少 token。括号里的名字会成为后台里这条记录的标题。

用第三方模型的话,这一步看不到东西

我们前面写了 set_tracing_disabled(True),所以这里的 trace() 是空转的,后台不会出现任何记录。这不是配错了,是没得选: SDK 的追踪端点在源码里写死https://api.openai.com/v1/traces/ingest, 它只认 OpenAI 官方的 key。你要是不关掉,拿第三方 key 去传,只会换来一屏 401。

那还有什么办法看到追踪?两条路:

① 你手头正好还有一个 OpenAI 官方 key。那就别关追踪,改成 set_tracing_export_api_key("sk-官方的key")—— 推理走第三方、追踪单独上传官方,两边用不同的 key,互不干扰。

② 用 Phoenix(推荐)。第 7 章会讲 Arize Phoenix,一个 docker run 就能在本地跑起来的追踪面板,和你用谁家的模型完全无关。 对第三方用户来说这才是正经答案,OpenAI 那个后台你本来也用不上。

虽然现在看不到东西,这一步的概念还是得记住:只有一个 agent、一次调用时,看不看无所谓; 但从第 4 章开始,一个请求会在好几个 agent 之间转手、再调好几个工具—— 到那时候没有追踪你基本就是瞎的。所以这行 with trace(...) 先留着, 等第 7 章接上 Phoenix,它立刻就有用了。

07

给它一双手:工具

新增:@function_tool · 对应清单 2.11

前面 6 步,智能体只会「想」,全靠模型肚子里那点东西。这一步给它一个能真的去调的函数—— 这就是五大功能层里的第二层:工具与动作。

from agents import (Agent, Runner, function_tool,
                    set_default_openai_api, set_tracing_disabled)
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict
from typing_extensions import TypedDict

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去,先关掉
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 先调用工具 get_research_sources()
拿到可用的研究来源列表。
- 你的研究计划只能使用
这些可用的研究来源。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务,并为每条任务指明
用的是哪个研究来源。
"""

class Task(TypedDict):
    step: int
    """任务步骤。"""
    research_source: str
    """要检索的来源。"""
    description: str
    """任务描述。"""

class ResearchPlanModel(BaseModel):
    tasks: list[Task]
    """带编号的研究任务。"""
    model_config = ConfigDict(extra='forbid')


@function_tool
def get_research_sources() -> list[str]:
    """提供可用的研究来源列表。"""
    search_sources = [
        "Wikipedia",
        "Google",
        "YouTube",
    ]
    return search_sources

agent = Agent(
    name="研究规划师",
    instructions=instructions,
    output_type=ResearchPlanModel,
    tools=[get_research_sources],  # 把工具注册给智能体
    )

input = "学习 AI 智能体"

result = Runner.run_sync(
    agent,
    input=input,
    )

print(result.final_output)

@function_tool 这个装饰器干的事,说白了就是把一个普通 Python 函数翻译成模型看得懂的说明书。 它自动读三样东西:函数名(工具叫什么)、类型标注(参数和返回值长什么样)、 还有——这条最容易被忽略——docstring 会变成给模型看的工具描述

docstring 不是注释

"""提供可用的研究来源列表。""" 这句话会原样发给模型, 模型就靠它判断「什么时候该调这个工具」。所以工具的 docstring 要当提示词写,而不是当代码注释写。 写得含糊,模型就该调的时候不调。 同理,Task 里那几个 """任务步骤。""" 也不是给人看的—— 它们会变成 JSON schema 里的字段描述,一起发给模型。

另外注意 instructions 也改了,多了一句「先调 get_research_sources(), 然后只准用它返回的那几个来源」。光注册工具是不够的,你还得在人设里说清楚什么时候用它。 输出类型也顺势加了 research_source 字段,逼着模型为每条任务标明用哪个来源。

这一跑,背后其实是两次模型调用:第一次模型说「我要调 get_research_sources」, SDK 真去执行那个 Python 函数,把 ["Wikipedia", "Google", "YouTube"] 塞回对话, 第二次模型才拿着这个结果生成最终计划。这个来回就是所谓的 agent loop,第 9 章会把它拆开细讲。

08

两个工具串起来,顺便把记录仪也开上

新增:第二个工具 + 嵌套类型 + trace · 对应清单 2.12

终点站。加了第二个工具 get_resource_url(), 而且它依赖第一个工具的输出——模型得先拿到来源名单,才能拿名字去查网址。

from agents import (Agent, Runner, function_tool, set_default_openai_api,
                    set_tracing_disabled, trace)
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict
from typing_extensions import TypedDict

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()

# 第三方服务一般只支持 chat completions,不支持 OpenAI 的 Responses API
set_default_openai_api("chat_completions")
# 追踪只能发往 OpenAI 官方,用第三方 key 传不上去。
# 关掉之后下面的 trace() 就变成空转 —— 详见这一节的说明。
set_tracing_disabled(True)

# 智能体的说明书(人设)
instructions = """
你是一个研究规划助手。

**任务说明**
- 你会收到一个研究主题。
- 先调用工具 get_research_sources()
拿到可用的研究来源列表。
- 再用工具 get_resource_url() 查询研究来源对应的网址。
- 你的研究计划只能使用
这些可用的研究来源。
- 你的任务是给出研究这个主题的计划。
- 输出 5 条简洁的任务,并为每条任务指明
用的是哪个研究来源。
"""

class ResearchSource(TypedDict):
    name: str
    """来源名称。"""
    url: str
    """来源网址。"""


class Task(TypedDict):
    step: int
    """任务步骤。"""
    research_source: ResearchSource
    """要检索的来源。"""
    description: str
    """任务描述。"""

class ResearchPlanModel(BaseModel):
    tasks: list[Task]
    """带编号的研究任务。"""
    model_config = ConfigDict(extra='forbid')


@function_tool
def get_research_sources() -> list[str]:
    """提供可用的研究来源列表。"""
    search_sources = [
        "Wikipedia",
        "Google",
        "YouTube",
    ]
    return search_sources

@function_tool
def get_resource_url(research_soruce: str) -> str:
    """根据研究来源的名称,返回它的网址。"""
    search_sources = {
        "Wikipedia": "https://www.wikipedia.org",
        "Google": "https://www.google.com",
        "YouTube": "https://www.youtube.com",
    }
    return search_sources[research_soruce]

agent = Agent(
    name="研究规划师",
    instructions=instructions,
    output_type=ResearchPlanModel,
    tools=[get_research_sources, get_resource_url],
    )

input = "学习 AI 智能体"

with trace("深度研究工作流(带工具)"):
    result = Runner.run_sync(
        agent,
        input=input,
        )

print(result.final_output)

输出类型也跟着长了一层:research_source 从一个字符串变成了 ResearchSource 这个嵌套的 TypedDict,里面装 nameurl。 这说明嵌套结构在严格模式下是完全 OK 的——只要每一层的键都是写死的。回头看第 04 步, 问题从来不是「结构复杂」,而是「键不固定」。

这次的 trace 本来是最值得看的一个——模型调了几次工具、每次传了什么参数、工具返回了什么, 全在里面。可惜跟第 06 步一样,用第三方模型时它是空转的, 真想看得等第 7 章接上 Phoenix。在那之前,你的观测手段就是工具函数里那几个 print

8 步走完,这个智能体已经有了人设、参数、结构化输出、工具, 以及一处等着被接上的观测点——五大功能层里的前两层齐活了。

这个文件里有两个真 bug

一、参数名拼错了。get_resource_url(research_soruce: str)——是 soruce,不是 source。它能跑,因为 schema 是从函数签名自动生成的, 模型照着拼错的名字传参就行。但这个错别字会一路暴露给模型,你自己写的时候别学。

二、字典直接下标,没兜底。return search_sources[research_soruce] —— 模型要是传了个字典里没有的名字(比如它自作主张来个 "Bing"),当场 KeyError 崩掉。 真要写生产代码,这儿得用 .get() 加一个「没找到」的返回值。 工具函数是模型能直接触发的代码,永远要假设它会传进来奇怪的东西。

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

这些是对着 PDF 和仓库代码逐条核对出来的出入,能省你不少时间。