把“会说话的模型“变成“会干活的 Agent“

《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

两个坑(都是真踩过的):

  1. .env 的采样参数默认不生效。 02 是硬编码 ModelSettings(...),所以 .env 里的 MODEL_TEMPERATURE / MODEL_MAX_TOKENS / MODEL_TOP_P 对它无效。想让 .env 生效必须写:

    python 复制代码
    model_settings=get_model_settings()   # 见 01 / 07x / 08
  2. model=create_model 少了括号。 实测直接崩:

    复制代码
    TypeError: Agent model must be a string, Model, or None, got function

    Agent.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 三条排障心法

  1. 改最咽处 :_apply_text_tool_calls 一处覆盖所有路径,别在 N 个地方打补丁。
  2. 先量化再改:连跑 3 次区分"偶发"与"必现",否则会把随机失败当成回归。
  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 条原则

  1. 不确定性是默认状态。 类型化输出、采样参数、白名单、重试------所有工程手段都是在给概率机器装护栏。
  2. 后端能力缺失,改适配层,不要改业务。 04/05 逐字相同是这条原则最好的注脚。
  3. 不要在一个 Agent 上同时压两件互斥的事。 拆成两个 Agent,比在提示词里反复强调可靠得多(issue #3709)。
  4. 模型会编造,必须有可对照的事实源。 编造在弱模型上是常态,不是意外。
  5. 容忍写法差异,不容忍编造。 规范化后比较,通过校验后回写权威值。
  6. 静默失败比报错危险。 凡是"可能编造"的字段都要显式检查,把静默成功变成显式错误。
  7. SDK 不会替你重试。 自己套重试,并把原始错误文本回灌给模型------那是最好的 few-shot。
  8. 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 验证。

备注*

相关推荐
starzy19901 小时前
Flink Task 执行源码深度剖析:从 Task 线程启动到算子链执行的完整链路
大数据·flink
救救孩子把1 小时前
AI面试八股文-03_3-RAG 系统面试题
人工智能·redis·面试·agent·rag
lazy熊1 小时前
Codex 能聊天但读不到项目文件:通常是这 4 个地方没有对上
人工智能
tianyatest1 小时前
魔兽争霸 III 接入大语言模型:基于文件通道的混合式游戏 AI 智能体系统设计与实现
人工智能·游戏·语言模型·war3
buddyMe智能助手1 小时前
不改模型权重,让智能体自己进化:脚手架自我改良的路线图
人工智能
Fang_YuanAI1 小时前
AI反推提示词指南:12种场景反推模板,角色、插画、摄影、风景全覆盖
人工智能
Sayai1 小时前
ComfyUI+Flux ControlNet 实战:一张图拆线稿和深度图,双模型重构画面(12G 显存实测,附模型与工作流)
人工智能·重构·aigc·flux·controlnet·comfyui
墨天梦1 小时前
D21_第三周系统功能回归
人工智能·数据挖掘·回归