《AI Agents In Action(第 2 版)》第 2 章:把"会说话的模型"变成"会干活的 Agent"
副标题 :配套代码
AI-Agent-Workflows/chapter_02全链路拆解对应章节 :第 2 章《核心组件:大语言模型、Prompt 与 Agent》
线索 :书本原理 → 示例代码 → 本地落地改造 → 为什么必须这样改
适用 :读过《AI Agents In Action(第 2 版)》第 2 章,或正准备把 OpenAI Agents SDK 接到非 OpenAI 后端的人
所属系列 :《AI Agents In Action(第 2 版)》代码学习实践系列 · 开篇
摘要 :本文拆解《AI Agents In Action(第 2 版)》第 2 章配套代码chapter_02的全链路模型本地化落地过程。核心观点是:后端能力缺失应在适配层llm_config.py补齐,模型行为不确定应在业务层用「白名单 + 校验器 + 重试」兜住,每一步是否真发生靠 trace 验证 。文章沿两条线展开------明线是 8 个示例组成的五级阶梯(能跑 → 能约束输出 → 能看见过程 → 能调工具 → 能编排),暗线是适配层三层补丁(请求侧降级、传输侧强制流式、响应侧文本工具解析)。重点覆盖四个实战坑:工具调用与结构化输出在同一 Agent 上互斥(issue #3709,推荐拆成两个 Agent)、模型编造来源(用单一事实源 + 校验器 + 错误回灌重试兜底)、思维链标签污染输出(清洗须在判空之前)、一轮多个工具调用被break截断(改为收集全部 + 去重)。文末给出复现环境、跑法、写新脚本的检查清单,以及从这一章带走的 8 条原则。
0. 这篇文档怎么读
0.1 先说结论
这一章表面上在教 OpenAI Agents SDK 的五个 API(Agent / ModelSettings / output_type / function_tool / trace),真正的难度全在另一条暗线上:
让一个不支持 function calling、不支持
json_schema、只接受流式请求、还会把思维链漏进正文的内网模型,表现得像标准 OpenAI 后端。
全书示例是照着 OpenAI 官方后端写的;而我们本地跑的是内网自建网关。于是每遇一处不兼容,就多一层补丁。整章的设计取舍可以浓缩成一句话:
后端能力缺失 → 在适配层 llm_config.py 补齐(示例一行不改)
模型行为不确定 → 在业务层用「白名单 + 校验器 + 重试」兜住
每一步是否真发生 → 用 trace 验证,不靠猜
0.2 三份材料的关系
| 材料 | 角色 | 回答的问题 |
|---|---|---|
| 《AI Agents In Action(第 2 版)》· 第 2 章《核心组件》 | 原理 | 为什么要类型化输出?为什么要工具?为什么要 trace? |
chapter_02/*.py + llm_config.py |
实现 | 代码长什么样?改了哪里? |
学习笔记.md / 技术指导.md |
踩坑记录 + 操作手册 | 哪个 bug 怎么修的?新脚本怎么写? |
| 本文档 | 来龙去脉 | 为什么会有这些改动?它们之间是什么关系? |
本文档不替代前三者,而是把它们串成一条线:问题 → 尝试 → 失败 → 定位 → 修复 → 验证。
0.3 阅读地图
| 如果你想知道 | 直接跳到 |
|---|---|
| 第 2 章到底在讲什么、为什么这样安排 | [§1](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
| 8 个示例是怎么递进的 | [§2](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
llm_config.py 为什么存在、三层补丁是什么 |
[§3](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
| 工具调用和结构化输出为什么会打架 | [§4](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
| 模型乱编怎么办 | [§5](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
| 跑起来出问题怎么查 | [§6](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
| 现在就要跑一遍 | [§8](#如果你想知道 直接跳到 第 2 章到底在讲什么、为什么这样安排 §1 8 个示例是怎么递进的 §2 llm_config.py 为什么存在、三层补丁是什么 §3 工具调用和结构化输出为什么会打架 §4 模型乱编怎么办 §5 跑起来出问题怎么查 §6 现在就要跑一遍 §8) |
1. 起点:第 2 章真正要你学会什么
1.1 三个组件,一个目标
《AI Agents In Action(第 2 版)》把 Agent 拆成三块:
| 组件 | 比喻 | 在本项目的对应物 |
|---|---|---|
| LLM(大语言模型) | 大脑 | 内网网关上的模型(MODEL_PROVIDER=local) |
| Prompt(提示工程) | 人格 + 工作手册 | 每个 .py 里的 instructions |
| OpenAI Agents SDK | 骨架 / 神经系统 | Agent + Runner + function_tool + trace |
目标是:把"能回答问题的助手"升级成"能完成真实任务的执行者"。
1.2 一切设计的根源:LLM 是概率机器
书 §2.1 反复强调一件事:LLM 本质是一台基于概率预测 Token 的机器。同一份输入,模型算出的概率分布是固定的;最终吐出什么,取决于采样策略。
这一句话,推导出了本章所有的工程手段:
| 概率性带来的麻烦 | 本章的应对 | 对应示例 |
|---|---|---|
| 输出格式每次可能变 | 类型化输出 output_type + Pydantic |
03 / 04 / 05 |
| 输出内容随机漂移 | 采样参数 temperature=0 等 |
02 |
| 长文本越写越散 | max_tokens + 正向具体指令 |
02 + 所有 instructions |
| 模型只知道训练数据里的事 | 工具 function_tool |
07 / 08 |
| 多步骤过程看不见、无法定位 | Tracing trace |
06* / 08 |
| 输出可能编造 | 白名单 + 校验器 + 重试(本书未涉及,本地补的) | 07x / 08 |
值得注意的缺口 :书里最后一行的"编造"问题,在 OpenAI 官方后端上不明显(模型能力强 +
strict json_schema兜底)。而在我们的内网模型上,它成了最主要的不稳定源。§5 整节都是本地补的课。
1.3 分水岭:从 Prompt Chaining 到 Agency
书 §2.4 有一句很关键的判断:
到目前为止我们只是写了一个 Prompt 让模型完成固定任务,这叫 Prompt Chaining ,还算不上真正的 Agent。真正的 Agent 要有 Agency(自主行动能力)------而最简单的赋权方式,就是给它工具。
这句话解释了示例为什么分两段:
01 ─ 02 ─ 03/04/05 ─ 06* ← 前半段:把模型"说清楚"(仍是 Prompt Chaining)
│
└─ 07 ─ 07x ─ 08 ← 后半段:把模型"放开手"(真正的 Agent)
而一旦给了工具,麻烦就开始了------因为工具调用和结构化输出在同一个后端上会互相打架。这是 §4 的主题,也是本地改造中改动最集中的地方。
2. 明线:8 个示例组成的五级阶梯
主线一句话:能跑 → 能约束输出 → 能看见过程 → 能调工具 → 能编排。
| 级别 | 要解决的问题 | 示例 | 新增的 API |
|---|---|---|---|
| L0 最小闭环 | 怎么跑起来 | 01 |
Agent + Runner.run_sync |
| L1 控住输出 | 怎么让结果稳定 | 02 |
ModelSettings |
| L2 结构化 | 怎么拿到可靠的 JSON | 03 04 05 |
output_type + Pydantic |
| L3 可观测 | 怎么看见过程 | 06 06_1 06_2 |
trace + 自定义 Processor |
| L4 会用工具 | 怎么让它动手 | 07 |
@function_tool + tools=[...] |
| L5 会编排 | 怎么绕开能力冲突 | 07x 08 |
多 Agent + 校验器 + 重试 |
2.1 L0:最小闭环(01_first_agent.py)
书本版只有 7 行核心代码。本地版多了三行"骨架",这三行在每个文件里都会出现,且顺序不能改:
python
load_dotenv(Path(__file__).parent.parent / ".env", override=True) # ① 必须最先
sys.path.insert(0, str(Path(__file__).parent.parent)) # ②
from llm_config import create_model, get_model_settings # ③
| 行 | 为什么不能省 |
|---|---|
① override=True |
让 .env 覆盖 shell 环境变量(典型是代理 no_proxy),否则请求被本机代理吃掉,表现为 Connection refused / 超时 |
| ① 的位置 | llm_config 在 import 时就把环境变量读完了,写在它后面等于没写 |
② sys.path |
示例在 chapter_02/,llm_config.py 在上一级 |
另外 01 里 input = "你好" ------那不是真研究主题,只是验证链路通不通。后续文件都在这份骨架上往上加东西。
2.2 L1:采样参数(02_setting_agent_model_parameters.py)
书 §2.1.3 给了完整的参数表。核心取舍:Temperature 换创造力,Max Tokens 换成本与安全。
| 参数 | 作用 | 本项目取值 |
|---|---|---|
temperature |
调平/调尖概率分布 | 规划类 Agent 设 0(要可复现) |
max_tokens |
本次回复的 Token 上限 | 150~2000,视输出长度 |
top_p |
核采样,保留累计概率 p 的候选 | 1.0(与 temperature 二选一调) |
frequency_penalty |
按出现次数惩罚,减少重复 | 0.5 |
presence_penalty |
已出现即惩罚,鼓励新话题 | 0.5 |
两个坑(都是真踩过的):
-
.env的采样参数默认不生效。02是硬编码ModelSettings(...),所以.env里的MODEL_TEMPERATURE / MODEL_MAX_TOKENS / MODEL_TOP_P对它无效。想让.env生效必须写:pythonmodel_settings=get_model_settings() # 见 01 / 07x / 08 -
model=create_model少了括号。 实测直接崩:TypeError: Agent model must be a string, Model, or None, got functionAgent.model只接受str | Model | None。正确写法model=create_model()。这个笔误故意没修,留作反例------它提醒我们:类型/参数错配如果没报错,比报错危险得多。
2.3 L2:结构化输出(03 / 04 / 05)
为什么需要它:书图 2.5 说得很直接------未类型化的输出一旦传给下一个 Agent,格式一变整个工作流就断。类型化输出让 Agent 之间的数据交换变得可信赖。
三份示例代表三种强度:
| 强度 | 写法 | 文件 | 适用 |
|---|---|---|---|
| 松 | tasks: List[str] |
03 |
只要字符串数组 |
| 中 | list[Task] 嵌套 TypedDict |
04/05 |
需要字段结构 |
| 紧 | + ConfigDict(extra='forbid') |
04/05 |
不容忍多余字段 |
书里 04 → 05 是一次修 bug :dict[int, str] 不符合 strict JSON Schema,SDK 抛 UserError,解法是用 TypedDict 定义 Task + extra='forbid'(书里强调:不要图省事关掉 strict_json_schema=False,那是埋雷)。
本地版的反转 :我们的 04 和 05 内容逐字相同(只差空行和一行注释)。这不是复制错,是刻意的:
本地
04的病症是"模型不吐 JSON、只吐 Markdown 散文"。这个病一行 prompt 都没改 ,是在llm_config.py的请求侧注入 JSON 提示治好的。
两个文件内容相同,恰恰是这个设计最好的注脚:后端能力缺失应在适配层统一补齐,而不是让每个调用方各写一遍"请输出 JSON"。
2.4 L3:可观测(06 / 06_1 / 06_2)
书 §2.3.4 说:用 OpenAI 的话 Tracing 默认就开着,登录控制台就能看。我们用的是内网模型,这条路断了------于是多了两个本地版本。
| 方式 | Processor | 输出 | 适用 |
|---|---|---|---|
默认(书版 06) |
平台 processor | 上报 OpenAI 平台 | 有 key 时;本地什么都看不到 |
06_1 |
ConsoleSpanExporter |
平铺 JSON dict | 对照字段名 |
06_2 |
自定义 TreeTraceProcessor |
树形层级 | 排障首选 |
两个顺序敏感点(踩了必懵):
① 环境变量必须在 import agents 之前设。 SDK 默认对 LLM / 工具数据脱敏,而 agents/_debug.py 在模块加载时就读环境变量:
python
import os
os.environ["OPENAI_AGENTS_DONT_LOG_MODEL_DATA"] = "0"
os.environ["OPENAI_AGENTS_DONT_LOG_TOOL_DATA"] = "0"
from agents import Agent, Runner # 必须在这两行之后
否则你去 trace 里找输入输出,只会看到一片脱敏占位符。
② tracing 必须在 create_model() 之后重新打开。 因为 create_model() 对非 OpenAI provider 会执行 set_tracing_disabled(True)------它会顺便把整个 SDK 的 trace 关掉:
python
model = create_model() # 内部关掉了 tracing
set_tracing_disabled(False) # 再打开,顺序不能反
set_trace_processors([tree_processor])
2.5 L4:给模型手(07_agent_with_tool.py)
@function_tool 装饰器把普通 Python 函数注册成工具:自动生成 JSON Schema、告诉模型叫什么、参数是什么、什么时候该用。
书 §2.4.1 给了四条很实在的告诫,每一条在我们的场景里都被放大了:
| 书的告诫 | 本地的放大效应 |
|---|---|
| 工具越多 Token 越贵(即使没调用也要付费发 Schema) | 文本协议模式下,工具说明是以纯文本注入 system 的,比原生 Schema 更费 Token |
| 工具越多越容易选错 | 内网模型选型能力弱,08 必须靠"只认白名单命中行"来纠偏 |
| 工具本身会失败 | 本地服务还会偶发不返回、漏 usage、混进思维链标签 |
| 工具即决策权(最小权限原则) | 同样适用:只给完成任务必需的工具 |
07 有两个本地特有的细节:
- 它固定
model=create_model("deepseek")------因为本地那个 deepseek-r1:32b 蒸馏模型不支持工具调用; - 用
try/except把e和e.__cause__一起打出来,这是适配层排障时最省事的写法。
注意 07 没有 output_type,所以它天然避开了下一节的冲突。
3. 暗线:llm_config.py ------ 为什么要写适配层
3.1 现实:自建网关的四个缺陷
我们跑的不是 OpenAI,而是兼容 Chat Completions 协议的自建本地网关------即 MODEL_PROVIDER=local
所指向的 LOCAL_BASE_URL(本文档统一用 http://localhost:7888/v1 作脱敏示例,实际地址按
自己的部署填写)。实测有四点与 OpenAI 不一致:
| # | 缺陷 | 表现 | 后果 |
|---|---|---|---|
| 1 | 无原生 function calling | 模型只在正文里"复述"要调什么,永不产生 tool_calls 字段 |
工具永远不执行 |
| 2 | 不支持 response_format: json_schema |
结构化输出直接报错 | output_type 全废 |
| 3 | 只接受流式请求 | 非流式请求被拒 | 请求发不出去 |
| 4 | 思维链混进 content |
输出开头带 </think> 等标签 |
污染下游解析,静默丢数据 |
3.2 设计取舍:补丁下沉
面对这些缺陷,有两条路:
- ❌ 改业务代码 :每个示例的 prompt 里都写"请输出 JSON""请用
<tool_call>格式调工具"...... 11 个文件各写一遍,改一处漏十处。 - ✅ 改适配层 :业务代码保持标准 SDK 写法,兼容逻辑全部收进
llm_config.py。
我们选了后者。
业务代码(chapter_02/*.py)
│ 标准 SDK 写法:Agent / Runner / function_tool / output_type / trace
▼
agents SDK
▼
llm_config.create_model() ←── 三层补丁
│ ① _wrap_response_format 请求侧:response_format 降级/延迟 + JSON 提示注入
│ ② _wrap_as_stream_forcing 传输侧:强制流式再聚合
│ ③ _wrap_text_tool_calls 响应侧:文本 <tool_call> → 真 tool_calls(最外层)
▼
内网模型服务
证据 :04 与 05 逐字相同。示例一行没动,病好了。
3.3 补丁按 provider 自动开关
python
use_text_tools = provider == "local" or _tool_protocol_mode() == "force"
if provider != "openai": _wrap_response_format(model, text_tools=use_text_tools)
if provider == "local": _wrap_as_stream_forcing(model)
if use_text_tools: _wrap_text_tool_calls(model) # 必须最后
切到 DeepSeek 这类有 原生 function calling 的后端,文本协议补丁自动关闭,只保留 response_format 降级。MODEL_PROVIDER 一改,业务代码零改动。
3.4 请求侧做了三件事
① 注入文本工具协议。 后端不懂 tools 字段,那就把工具定义翻译成文本,塞进 system 消息,并明确规定调用格式:
<tool_call>{"name": "...", "arguments": {...}}</tool_call>
协议里还特意写清楚"工具结果由系统返回,你不许自己编"------因为实测模型会连调用带结果一起编出来。
② 把历史消息拍平。 网关不认识 assistant.tool_calls 和 role=tool(后者通常直接 400)。_flatten_tool_history 把它们改写成纯文本:
assistant + tool_calls → assistant 文本:<tool_call>...</tool_call>
role=tool → user 文本:「工具 x 的返回结果:...」
③ response_format 降级与延迟。 不支持 json_schema → 降级成 json_object,并在 messages 末尾注入一段描述 schema 的提示。更关键的是"延迟":见 §4.3。
3.5 响应侧:从规范到脏的三级解析
模型不会老老实实按协议输出,所以解析顺序是"从规范到脏",每层都能独立兜底:
① <tool_call>{...}</tool_call> 标签 ← 规范写法
② 裸 JSON 对象:要求 name 命中本次声明的工具名 且 带 arguments ← 脏写法兜底
③ 都失败 → 返回原文,交给业务侧纠正提示重试
第 ② 步的 name 白名单校验 是必需的:否则会把结构化输出里的业务字段(比如 research_source.name)误判成工具调用。
解析成功后,_apply_text_tool_calls 把文本里的调用写成真正的 ChatCompletionMessageToolCall,设 finish_reason="tool_calls",交回 SDK 走正常工具执行流程。所以不走原生 function calling,trace 里照样能看到 function: span。
3.6 两个"看似无害、实则致命"的细节
① 思维链标签要清洗,且必须在判空前清洗。
python
stripped = _strip_think_tags(content) # 先清洗
if stripped != content:
message.content = stripped or None
content = stripped
if not content:
return completion # 再判空
写在判空之后就漏了------没有工具调用时正文会原样放行,脏标签一路漏进 final_output。而 08 是按整行前缀 匹配来源名的,标签一旦和来源名粘在同一行,来源就被静默丢弃。这比报错难查得多。
② 一轮里的多个调用必须都执行。 这里有个真实的 bug:解析循环的兜底分支末尾原本有个 break,只取第一个命中就退出。表现是"模型明明写了两个 get_resource_url(Google / YouTube),trace 下只挂了一个 span",结果只有一个来源拿到 url,最后 5 个 task 全用 Wikipedia。
去掉 break 改成收集全部 + 按 (name, arguments) 去重后,同一轮出现 4 个 function: span,计划终于覆盖了三个来源。
教训:模型行为不合规时,先确认是不是被自己的代码砍掉了。表面上看是"模型只调了一次工具",实际是适配层把后面的调用丢了。
4. 冲突:当一个 Agent 同时要"调工具"和"吐 JSON"
这是全章改动最集中的地方,也是本书没有覆盖的坑。
4.1 现象
07x 和书版 08 都是"同一个 Agent 既挂 tools 又挂 output_type"。在我们的后端上跑,出现:
- 工具永远不被调用(模型直接用编造的内容吐 JSON);
- 或者最后一轮不吐 JSON,吐 Markdown,SDK 抛
ModelBehaviorError。
4.2 根因
结构化输出与工具调用在同一轮互斥 。这是 openai-agents-python 的已知问题(issue #3709 ):response_format 一旦压上去,模型就被推向"立刻产出最终答案",工具调用被掐掉。
书里没提,是因为 OpenAI 官方后端上两者能共存。我们的后端不行------而且是压倒性的不行:
实测:内网模型对"只输出 JSON"的服从性远强于工具协议。同一轮里两句要求并存,它必然选择编造内容直接吐 JSON。
4.3 解法 A:延迟 response_format(07x 路线)
把两件事分到不同轮次:
第一轮(还没有工具结果) → 撤掉 response_format,只发"去调工具"的提示,让模型自由发调用
历史里出现工具结果之后 → 恢复 json_object + JSON 提示,再压结构化输出
对应 _defer_response_format() / MODEL_DEFER_RESPONSE_FORMAT=1。
这比任何提示词都可靠------与其在提示词里反复强调"先调工具再输出 JSON",不如让这两句话根本不在同一轮出现。
4.4 解法 B:拆成两个 Agent(08 路线,推荐)
更彻底的做法:让"调工具"和"吐 JSON"根本不在同一个 Agent 身上发生。
python
# Agent 1:Source Discovery ------ 只调工具,不要 output_type
discovery_agent = Agent(name="Source Discovery", tools=[...], ...)
# Agent 2:Research Planner ------ 只做结构化输出,来源由 Agent 1 的结果注入
planner_agent = Agent(name="Research Planner",
instructions=planner_instructions.format(discovered_sources=...),
output_type=ResearchPlanModel)
关键细节:别在 Agent 1 的提示里泄露白名单 。否则它会背答案而不是真调工具(实测它直接编出 PubMed | https://pubmed.ncbi.nlm.nih.gov)。08 的做法是------
- Agent 1 的指令里只给字面量调用示例 ,参数用占位符
<one source name from Step 1>; - 来源列表用
{discovered_sources}占位符,在运行时才注入 Agent 2 的提示。
4.5 两条路线对比
| 维度 | 解法 A:延迟(07x) |
解法 B:拆分(08) |
|---|---|---|
| Agent 数 | 1 | 2 |
| 依赖适配层 | 强(必须开 MODEL_DEFER_RESPONSE_FORMAT) |
弱(纯 SDK 写法) |
| 稳定性 | 中(实测约 1/3 概率最后一轮吐 Markdown) | 高 |
| 可观测性 | 一般 | 好(trace 里两个 agent 分支清晰) |
| 推荐度 | 保留作为冲突样本 | ✅ 生产推荐 |
写新脚本的硬规则 :
tools与output_type不要挂在同一个 Agent 上。
5. 兜底:模型不可信时的三道防线
书里的模型(GPT-4 级)比较守规矩;内网模型会编造。这一整节都是本地补的。
5.1 第一道:单一事实源
08 里的 AVAILABLE_SOURCES 是唯一真相:
python
AVAILABLE_SOURCES = {
"Wikipedia": "https://www.wikipedia.org",
"Google": "https://www.google.com",
"YouTube": "https://www.youtube.com",
}
三个地方都从它取值:工具的返回值、Agent 2 的指令注入值、校验器的比对值。一处改,处处改。
白名单一定要单一数据源。在多处各写一份,迟早对不上------而一旦对不上,校验就变成了"合法的编造"。
5.2 第二道:校验器------容忍写法,不容忍编造
python
@field_validator("tasks")
@classmethod
def check_sources(cls, tasks: list[Task]) -> list[Task]:
for t in tasks:
src = t["research_source"]
if src["name"] not in AVAILABLE_SOURCES:
raise ValueError(f"invalid research_source '{src['name']}' ...")
expected = AVAILABLE_SOURCES[src["name"]]
if cls._normalize_url(src["url"]) != cls._normalize_url(expected):
raise ValueError(f"url '{src['url']}' does not match source ...")
src["url"] = expected # ← 通过校验后回写白名单原值
return tasks
这套哲学值得单独拎出来:
| 行为 | 处理 | 例子 |
|---|---|---|
| 写法差异 | 容忍,规范化后比较 | 大小写、首尾空白、根路径结尾斜杠、query/fragment |
| 编造 | 拒绝 | 主机名不同(en.wikipedia.org)、路径不同、来源不在白名单 |
| 通过校验 | 回写权威值 | 保证 final_output 里的 url 与白名单逐字一致 |
为什么要有"结尾斜杠"这条:模型实测会把根 URL 规范化成 https://www.wikipedia.org/,逐字 != 会让整份计划作废。规范化 + 回写,既救了这次运行,又保证下游拿到的是权威值。
5.3 第三道:重试 + 错误回灌
SDK 不会替你重试 :output_type 校验失败 = ModelBehaviorError 直接抛出。业务侧必须自己套:
python
last_error = None
for _ in range(3):
try:
result = Runner.run_sync(agent, input=planner_input)
break
except ModelBehaviorError as error:
last_error = error
planner_input = f"{input}\n\n上一次输出未通过校验:{error}\n请逐字照抄来源名与 url。"
else:
raise last_error # 三次都没过,抛出真实原因,而不是让编造静默过关
关键技巧:把原始错误文本回灌给模型。 "你上次输出了 X,不合法,应该 Y" 比抽象说教有效得多------这是最好的 few-shot。
07x 覆盖两类失败,纠正提示各不相同:
| 失败类型 | 纠正提示要点 |
|---|---|
ModelBehaviorError(吐了 Markdown) |
"最后一轮必须只输出一个 JSON 对象,不要 Markdown、不要散文、不要结尾解释" |
| 白名单外来源(没调工具、凭空编造) | "你必须先调用 get_research_sources(),只输出 <tool_call>... 这一行" |
改前连跑 3 次失败 1 次,改后 3 次全部 exit=0。
5.4 静默失败比报错危险
07x 里有个隐蔽情况:如果模型压根没调工具、凭空编了个 "AI Agents Overview" 当来源,而 ResearchPlanModel 没有校验器 ------那么它会静默成功。
所以 07x 手动补了一道白名单检查:
python
used_sources = {task["research_source"]["name"] for task in result.final_output.tasks}
unknown = sorted(used_sources - set(AVAILABLE_SOURCES))
if unknown: # 把静默错误变成显式错误,让重试有机会纠正
...
凡是"模型可能编造"的字段,都要有可对照的事实源。 没有对照物的成功,只是还没暴露的失败。
6. 排障:trace 是唯一的真相
6.1 树形 trace 怎么读
06_2 / 08 里的 TreeTraceProcessor 做两件事:on_span_end 收集 span,on_trace_end 按 parent_id 还原层级,用 ├── / └── 打印。
一个典型的输出长这样:
Trace: Deep Research Workflow (Tools)
└── agent: Source Discovery
├── generation: model=default, usage={...}
│ ├── input: system: You are a research source discovery assistant...
│ └── output: assistant: <tool_call>{"name": "get_research_sources"...}
├── function: get_research_sources
├── generation: model=default, usage={...}
├── function: get_resource_url
└── function: get_resource_url
读法:agent 是分支,generation 是一次模型往返,function 是一次工具执行 。看有没有 function: span,就知道工具到底调没调。
6.2 速查表
| trace 现象 | 可能原因 |
|---|---|
完全没有 function: span |
模型没发调用(指令不够硬)或 适配层没解析出来(去看 assistant 原文里有没有调用块) |
| 模型写了 N 个调用,只有 1 个 span | 适配层只取了第一个(已修,见 §3.6②) |
assistant 输出带 </think> |
响应侧清洗没生效 |
| 结构化输出报错,但 assistant 原文是散文 | 请求侧 JSON 提示没注入,或 response_format 延迟后没恢复 |
usage 全 0 |
网关没返回 usage,功能正常,忽略 (Chat Completions 下 requests 本来就恒为 0) |
assistant: null |
正常 :工具调用消息的 content 本就为 None |
6.3 五个真实 bug 复盘
| # | 现象 | 根因 | 改法 | 验证 |
|---|---|---|---|---|
| 1 | 输出开头带 </think> |
网关把思维链混进 content,清洗在判空之后 |
新增 _strip_think_tags(),移到判空之前 |
trace 里 `grep -c "start of response |
| 2 | 一轮写 2 个调用只执行 1 个 | 解析兜底分支末尾有 break |
去掉 break,收集全部 + 按 (name, args) 去重 |
出现 4 个 function: span |
| 3 | URL 多个斜杠,整份计划作废 | 校验器逐字 != |
_normalize_url() 规范化 + 通过校验后回写原值 |
两次尝试内通过 |
| 4 | 发现阶段编造来源(PubMed / source1) | 模型用文字复述步骤而不发调用块 | 指令给字面量示例 + 只认白名单命中行 + 纠正重试(≤3 次) | 第 1 次 0 条命中 → 重试 → 第 2 次拿到真来源 |
| 5 | 5 个 task 全用同一来源 | 根因是 #2,planner 只拿到 1 条白名单 | 修 #2 + 指令加一句"Spread the 5 tasks across the different available sources" | 变成 Wikipedia/Google/Google/YouTube/Wikipedia |
6.4 三条排障心法
- 改最咽处 :
_apply_text_tool_calls一处覆盖所有路径,别在 N 个地方打补丁。 - 先量化再改:连跑 3 次区分"偶发"与"必现",否则会把随机失败当成回归。
- 改完必重跑:同一份代码,模型每次行为都可能不同。
7. 全景图:一次 08 的完整执行时序
┌─────────────────────────────────────────┐
│ with trace("Deep Research Workflow") │
└───────────────────┬─────────────────────┘
│
┌───────────────────────────────────▼───────────────────────────────┐
│ Agent 1 · Source Discovery(无 output_type,可自由调工具) │
└───────────────────────────────────┬───────────────────────────────┘
│
┌──────────────┐ ┌───────────────┐ │ ┌───────────────────────────┐
│ generation ① │──►│ function: │───┴──►│ 过滤:只认命中白名单的 │
│ 决定调工具 │ │ get_research_ │ │ 「name | url」行 │
└──────────────┘ │ sources │ └───────────┬───────────────┘
└───────────────┘ │
┌──────────────┐ ┌───────────────────────────┐ │ 0 条命中?
│ generation ② │──►│ function: get_resource_url │ ├─► 是 → 纠正提示重跑(≤3 次)
│ 逐个查 url │ │ × N(一轮多个调用) │ │
└──────────────┘ └───────────────────────────┘ │
▼ 拿到了
┌───────────────────────────────────────────────────────────────────┐
│ 运行时注入:planner_instructions.format(discovered_sources=...) │
└───────────────────────────────┬───────────────────────────────────┘
│
┌───────────────────────────────▼───────────────────────────────┐
│ Agent 2 · Research Planner(output_type + @field_validator) │
└───────────────────────────────┬───────────────────────────────┘
│
┌──────────────┐ ┌───────────────▼────────┐ ┌──────────────────┐
│ generation ③ │──►│ Pydantic 校验 │──►│ ModelBehavior- │
│ 产出 JSON │ │ name 在白名单? │ │ Error?→ 重试 1 次│
└──────────────┘ │ url 规范化后匹配? │ │ + 错误回灌 │
│ 通过 → 回写权威 url │ └────────┬─────────┘
└───────────────┬────────┘ │
▼ ▼
result.final_output raise last_error
三条防线在这一张图里全部到位:
- 过滤(只看白名单命中的行)→ 挡住编造的来源
- 校验器(容忍写法、不容忍编造 + 回写)→ 挡住改写的 url
- 重试 + 错误回灌 → 挡住偶发的不守格式
8. 复现指南
8.1 环境
dotenv
MODEL_PROVIDER=local
LOCAL_BASE_URL=http://localhost:7888/v1 # 服务不在本机时改成对应的机器地址
LOCAL_MODEL=default
LOCAL_API_KEY=not-needed
MODEL_TEMPERATURE=0
MODEL_MAX_TOKENS=2000
MODEL_TOP_P=1.0
MODEL_TIMEOUT=180 # 本地推理慢,避免无限挂起
MODEL_TRUST_ENV=1 # 0 = 忽略 shell 的 http_proxy/https_proxy
MODEL_TOOL_PROTOCOL=auto # auto = 文本协议 + 保守兜底;force = 强制
MODEL_DEFER_RESPONSE_FORMAT=1
8.2 跑法
bash
pip install openai-agents python-dotenv httpx pydantic
python AI-Agent-Workflows/chapter_02/01_first_agent.py # 先跑最小闭环
python AI-Agent-Workflows/chapter_02/08_agent_with_tools_tracing.py # 综合示例
8.3 两个容易误判的"正常现象"
| 现象 | 真相 |
|---|---|
trace 里 usage 全 0 |
Chat Completions 下 requests 恒为 0;token 为 0 是网关没返回 usage,客户端补不了。不是请求没发出去 |
| 带工具的运行要 2~5 分钟,期间不打印任何东西 | 多轮 Agent 每次 5~10 次模型往返,且 trace 在 on_trace_end 才落盘。不是卡死 |
8.4 写新脚本的检查清单
- 骨架三行齐全,且
load_dotenv在import llm_config之前 -
Agent(...)里写model=create_model()(带括号) - 需要
.env采样参数生效 → 显式传model_settings=get_model_settings() - 工具函数有 docstring + 正确的类型注解
- 结构化输出用
extra='forbid';关键字段加@field_validator - 白名单 / 事实源只有一个,校验全部回查它
-
tools与output_type不要挂在同一个 Agent 上 - 要 trace:先设两个
OPENAI_AGENTS_DONT_LOG_*(在import agents之前),再在create_model()之后set_tracing_disabled(False)+set_trace_processors(...) -
output_type校验失败要手动套重试,并把原始错误文本回灌给模型 - 改完连跑 3 次确认不是偶发
9. 从这一章带走的 8 条原则
- 不确定性是默认状态。 类型化输出、采样参数、白名单、重试------所有工程手段都是在给概率机器装护栏。
- 后端能力缺失,改适配层,不要改业务。
04/05逐字相同是这条原则最好的注脚。 - 不要在一个 Agent 上同时压两件互斥的事。 拆成两个 Agent,比在提示词里反复强调可靠得多(issue #3709)。
- 模型会编造,必须有可对照的事实源。 编造在弱模型上是常态,不是意外。
- 容忍写法差异,不容忍编造。 规范化后比较,通过校验后回写权威值。
- 静默失败比报错危险。 凡是"可能编造"的字段都要显式检查,把静默成功变成显式错误。
- SDK 不会替你重试。 自己套重试,并把原始错误文本回灌给模型------那是最好的 few-shot。
- trace 是唯一的真相。 "工具应该被调用了"不算数,trace 里有
function:span 才算数。
附录 A:示例速查表
| 文件 | tools | output_type | trace | processor | 用途 |
|---|---|---|---|---|---|
01_first_agent.py |
--- | --- | --- | --- | 最小闭环 |
02_..._model_parameters.py |
--- | --- | --- | --- | 采样参数(含一个真实 bug,见 §2.2) |
03_output_types_basic.py |
--- | ✔ | --- | --- | 结构化输出入门 |
04_output_types.py |
--- | ✔ | --- | --- | 嵌套 + extra='forbid' |
05_output_types_fixed.py |
--- | ✔ | --- | --- | 与 04 逐字相同(修复在适配层) |
06_agent_with_tracing.py |
--- | ✔ | ✔ | 默认 | 本地看不到输出 |
06_1_trace_console.py |
--- | ✔ | ✔ | ConsoleSpanExporter |
平铺 JSON |
06_2_trace_tree.py |
--- | ✔ | ✔ | TreeTraceProcessor |
树形,排障首选 |
07_agent_with_tool.py |
✔ | --- | --- | --- | 工具调用入门 |
07x_..._extended.py |
✔ | ✔ | --- | --- | 单 Agent 挤两样能力(冲突样本) |
08_..._tracing.py |
✔ | ✔ | ✔ | TreeTraceProcessor |
综合收敛版 |
附录 B:两条路线对照
| 维度 | 路线 A(OpenAI 官方) | 路线 B(本地 / 兼容) |
|---|---|---|
| 模型指定 | model="gpt-4o" |
model=create_model() |
| API | Responses(默认) | Chat Completions(适配层强制) |
| 工具调用 | 原生 function calling | 文本协议模拟 |
| 结构化输出 | strict json_schema |
json_object + 提示词 |
| 托管工具(WebSearch 等) | 可用 | 不可用 |
| tracing | 默认上报平台 | 需手动 set_tracing_disabled(False) |
| 耗时 | 秒级~十几秒 | 2~5 分钟(多轮工具场景) |
| 主要风险 | 费用 / 数据出境 | 模型不守协议,需白名单 + 重试兜底 |
附录 C:材料差异提示
学习笔记.md 写于改造过程中,与当前 llm_config.py 有几处细微出入,以代码为准:
| 笔记描述 | 当前代码 |
|---|---|
_build_tool_protocol 把参数类型翻译成中文 |
当前直接 json.dumps 英文 tool specs 注入 |
_flatten_tool_history 处理 {"type": "function_call"} 形态 |
当前处理的是 Chat Completions 的 assistant.tool_calls / role=tool |
06_2 树渲染"按 started_at 排序" |
当前实现不排序,按收集顺序还原层级 |
提及 set_tracing_export_api_key(...) |
当前用的是 set_tracing_disabled(False) + set_trace_processors(...) |
一句话总结
用标准 Agents SDK 写法驱动任意后端 ------OpenAI 路线直接用满原生能力;本地 / 兼容路线把后端缺的能力在 llm_config.py 里补齐,模型的不确定性用白名单 + 校验器 + 重试 兜住,每一步是否真发生靠 trace 验证。
备注*
- 原书:Michael Lanham, AI Agents in Action, Second Edition, Manning Publications
- 本地化跑通链路代码:https://download.csdn.net/download/applehunqiu/93700841