AI Agents in Action · 第七章

你怎么知道
它答对了

前六章一直在让智能体更能干。这一章问一个更难的问题: 你凭什么说它答对了?难点不在跑测试,在于—— 判分器本身就很难写。这一章的代码正好证明了这一点: 它自带的第一个判分器是坏的,会把正确答案判成错的。 整章就是判分器的四次升级。

01–03
字符串精确相等 —— 拿智能体整句回答去 == 一个单词
几乎永远判错
04
请一个智能体当评委 —— 让模型判断「意思对不对」
能用了
05–06
还要查出处 —— 不光问对不对,还问「这话是检索来的还是编的」
能防幻觉
09–10
让人来看 —— Phoenix 把每一步摊开在你面前
看得见了

第 09 节是我们从第二章欠到现在的那笔账:用第三方模型也能看到追踪了

动手之前

环境照旧。这一章 0106同一份代码复制粘贴六次、 每次改一点——那 5 条虚构知识和 5 道基准题在六个文件里逐字重复。 所以下面我只在变化处贴代码,不重复贴一样的部分。

好消息:这一章有你能用的追踪了

第二章我说过:SDK 的追踪端点写死api.openai.com, 你拿第三方 key 传上去只有 401,所以只能 set_tracing_disabled(True)—— trace() 一直是空转的。

这一章的 Phoenix 就是解药,而且它跟你用谁家的模型完全无关。 我已经实测过:Phoenix 容器 + 第三方模型,12 个 span 完整落库。 关键差别在第 09 节,到那儿细说。

判分器 1.0

先看它坏在哪

这一章讲评估,而它自带的第一个判分器是坏的。这不是在挑刺—— 看懂它为什么坏,就理解了这一章存在的理由。

五条编出来的知识,和一个判不对分的判分器

对应 7.2 · 01_RAG_agent.py

import asyncio

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

# 从 .env 读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_DEFAULT_MODEL
load_dotenv()
# 下面两行仓库原文没有,是为了接第三方模型加的
set_default_openai_api("chat_completions")
set_tracing_disabled(True)

# 一个极简的内存知识库
# 这五条是作者编的,现实中不存在 —— 保持英文,因为下面的检索是英文子串匹配
_special_knowledge_db = [
    "Nebula Forge engine spins antimatter rings for gravity control.",
    "Solaris Glacier absorbs heat, releasing luminescent icefire at dusk.",
    "Quantum Bark trees emit entangled photons guiding nocturnal insect migrations.",
    "Aether Silk fabric self-weaves repairs within fourteen-millisecond microtears.",
    "Chrono Coral reefs rewind water currents three minutes every solstice.",
]

# 针对这些特殊知识的基准题
_benchmarks = [
    {"q": "Nebula Forge engine spins what?", "a": "antimatter", "wrong": "fusion"},
    {
        "q": "Solaris Glacier releases luminescent what?",
        "a": "icefire",
        "wrong": "lava",
    },
    {"q": "Quantum Bark trees emit what?", "a": "photons", "wrong": "spores"},
    {"q": "Aether Silk fabric repairs what?", "a": "microtears", "wrong": "threads"},
    {"q": "Chrono Coral reefs rewind what?", "a": "currents", "wrong": "tides"},
]


@function_tool
def search_knowledge(query: str) -> dict:
    """在知识库里检索相关事实。"""
    matches = [doc for doc in _special_knowledge_db if query.lower() in doc.lower()]
    print(f"'{query}' 命中 {len(matches)} 条")
    return {"status": "ok", "context": "\n".join(matches)}


agent = Agent(
    name="RAG Agent",
    instructions="""
你是一个检索增强的知识智能体。
""",
    tools=[search_knowledge],
)

for benchmark in _benchmarks:
    question = benchmark["q"]
    answer = benchmark["a"]
    wrong_answer = benchmark["wrong"]
    result = asyncio.run(Runner.run(agent, input=question)).final_output.strip()
    print("" + "=" * 40)
    print(f"问题: {question}")
    print(f"回答: {result}")
    # ↓ 这一行就是坏掉的判分器 —— 见下方说明
    if result.lower() == answer.lower():
        print(f"正确 -> {answer}")
    else:
        print(f"错误 -> 期望: {answer}")
        if wrong_answer:
            print(f"错误答案应该是: {wrong_answer}")
先说这一章最聪明的设计:五条假知识

Nebula Forge、Quantum Bark、Chrono Coral——这些东西现实中根本不存在, 是作者编的。为什么要编?因为模型的训练数据里绝不可能有它们

所以只要智能体答得出「Nebula Forge 转的是反物质环」,就只能是它真的去查了知识库。 换成真实知识(比如「地球绕太阳转」),模型直接背出来也能答对, 你根本分不清它是查了还是猜的。这一招是本章最值得偷的招—— 第六章用整个《回到未来》剧本干的也是同一件事。

判分器坏在哪:拿一整句话去 == 一个单词

result 是智能体的完整回答answer"antimatter" 一个词。然后代码做的是 result.lower() == answer.lower()——精确相等。我实测了几种可能的回答:

判对 ✓  回答: 'antimatter'
判错 ✗  回答: 'The Nebula Forge engine spins antimatter rings.'
判错 ✗  回答: 'Antimatter rings.'
判错 ✗  回答: 'The Nebula Forge engine spins antimatter rings for gravity control.'

只有智能体恰好只吐「antimatter」这一个孤零零的单词才算对。 而真实模型几乎总会说一句完整的话——于是全部判错010203 三个文件都是这个判分器

这正是本章的出发点:判分器比被判的东西还难写。 用 == 判自然语言,和第四章拿 in 当护栏、 第五章拿 in 筛分支,是同一个病用字符串操作去理解语言。

检索工具也是子串匹配

search_knowledge 干的是 query.lower() in doc.lower()—— 把整个查询当子串去匹配整条知识。所以问「Nebula Forge engine spins what?」 会命中 0 条,因为没有哪条知识包含这一整个问句。

它只在模型刚好用单个关键词去查时才有用——这就是为什么 03 把工具改名成 search_knowledge_by_keyword, 并在 docstring 里强调「single keyword」。改名不是美化,是在教模型怎么用它。 (第六章那个 Chroma 才是真语义检索。这里是刻意简化。)

判分器 2.0

请一个智能体来当评委

既然 == 判不了自然语言,那就换个能听懂话的东西来判—— 再来一个智能体。

LLM-as-judge:这一章真正的转折点

对应 7.2.4 · 04_RAG_agent.py

class EvaluationOutput(BaseModel):
    """评委智能体的输出模型。"""

    is_correct: bool
    feedback: str


evaluation_agent = Agent(
    name="Evaluation Agent",
    instructions="""
你是一个评估智能体。
你的任务是评估 RAG 智能体给出的答案。
你要把答案和「期望答案里的关键词」做对比,来判断它是否正确。
""",
    output_type=EvaluationOutput,
)

for benchmark in _benchmarks:
    question = benchmark["q"]
    answer = benchmark["a"]
    wrong_answer = benchmark["wrong"]
    result = asyncio.run(Runner.run(agent, input=question)).final_output.strip()
    # 把题目、实际回答、期望关键词一起打包交给评委
    evaluation_input = dict(
        question=question,
        answer=result,
        expected_key_term=answer,
        wrong_answer=wrong_answer,
    )
    print("" + "=" * 40)
    evaluation = asyncio.run(
        Runner.run(evaluation_agent, input=str(evaluation_input))
    ).final_output
    print(f"评估结果: {evaluation.is_correct}")
    print(f"评委反馈: {evaluation.feedback}")
    print(f"问题: {question}")
    print(f"回答: {result}")
    if evaluation.is_correct:
        print(f"正确 -> {answer}")
    else:
        print(f"错误 -> 期望: {answer}")

看清楚发生了什么:if result.lower() == answer.lower() 变成了 if evaluation.is_correct。判分从一行字符串比较, 换成了一次模型调用

为什么 output_type 在这里是刚需

评委的 output_type=EvaluationOutput 给出的是一个 真正的布尔值 is_correct,不是一句「我觉得这个答案基本正确」。 如果评委也吐自然语言,你就得再写一个判分器去判评委——套娃了。

回想第五章 ToT 那个 if "promising" in output 的 bug—— 它就是没用 output_type 才踩的坑。 第二章教的结构化输出,到这一章才显出它真正的价值: 它是让模型的判断能被代码消费的唯一可靠方式。

LLM 当评委的代价,书里没细说

一、贵一倍。每道题现在是两次模型调用:一次答,一次判。

二、评委自己也会错。你现在是在用一个不可靠的东西去评判另一个不可靠的东西。 feedback 字段就是为此存在的——它逼评委说出理由, 这样评委抽风时你能看出来。

三、它没解决根本问题,只是把它推远了一层。 「怎么判断答案对不对」这个问题依然在,只是从你的 if 语句 搬进了评委的提示词里。但这一层推得值——因为提示词能理解语言, == 不能。

判分器 3.0

光对还不够,还得说出处

评委只能判「答案对不对」。但有个更阴险的问题: 答案碰巧对了,可它是编的。这就是 grounding(接地)要解决的。

用一个全局变量记住「刚才检索到了什么」

对应 7.3 · 05_RAG_grounding_agent.py

_last_context = ""  # 全局变量,存住上一次检索到的内容


@function_tool
def search_knowledge_by_keyword(query: str) -> dict:
    """用单个关键词在知识库里检索。"""
    matches = [doc for doc in _special_knowledge_db if query.lower() in doc.lower()]
    global _last_context
    _last_context = "\n".join(matches)      # ← 检索时顺手存一份
    return {"status": "ok", "context": _last_context}


@function_tool
def get_last_context() -> str:
    """
    取回接地智能体上一次用到的检索内容。
    """
    global _last_context
    if not _last_context:
        return "没有可用的上下文。"
    return _last_context

思路很朴素:检索的时候偷偷把原文留一份,事后拿它去对质。 接地智能体的活儿就是——把答案和这份原文摆在一起,问:这话是原文里的,还是你编的?

grounding 和「答案对不对」是两回事

举个例子:知识库里说「Nebula Forge 转反物质环」。智能体答「转反物质环」—— 答案对,也确实检索到了,接地成立。

但如果它答「转反物质环,每秒 3000 转」呢?答案的主体是对的, 但「每秒 3000 转」是它编的——知识库里没这句。评委可能判它对 (关键词命中了),而接地检查会抓住这个尾巴

这就是 grounding 的价值:它防的不是「答错」,是「夹带私货」。 真实场景里后者危险得多——因为它听起来更可信

用全局变量存上下文,是教学简化

global _last_context 意味着整个进程只有一份。 单线程跑基准题没问题,但只要你并发处理两个请求, 它们就会互相覆盖对方的上下文——A 的答案会拿 B 检索到的原文去对质

真要上生产,这份上下文得挂在请求的上下文对象上(SDK 有 RunContextWrapper,第五章 Reflexion 那节用过)。 书里为了讲清概念用了全局变量,别原样搬。

再进一步:把接地做成护栏

对应 7.3.3 · 06_RAG_grounding_with_guardrails.py

06 把接地检查接到了第四章的 @output_guardrail。 区别是什么?05你事后自己去查06接地不通过就直接抛异常,答案根本发不出去

三章的东西在这里合流了

第二章output_type(让判断变成结构化数据)+ 第四章@output_guardrail(不合格就拦住)+ 本章的 grounding(拿原文对质)——三样拼在一起, 才是一个能防幻觉的 RAG

这也是这本书的结构在起作用:06 这个文件本身没有新概念, 它的价值全在于「把前面学的东西装到一起」。

判分器 4.0

Phoenix:欠了五章的账,还了

前面三种判分器都是让机器判。这一节把整个过程摊开给看—— 而且,它终于不依赖 OpenAI 了

一个 docker run,一个本地追踪面板

对应 7.4 · 09_arize_phoenix_tracing.py

启动命令就写在仓库那个文件的第一行注释里:

docker run -it --rm -p 6006:6006 -p 4317:4317 arizephoenix/phoenix:latest

# 6006 = 网页界面 + 数据接收
# 4317 = OTLP gRPC 端口
# 起来之后浏览器打开 http://localhost:6006

然后在代码里把追踪转向它:

import asyncio
import os

from agents import Agent, Runner, set_default_openai_api, set_trace_processors, trace
from dotenv import load_dotenv

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

os.environ["PHOENIX_COLLECTOR_ENDPOINT"] = "http://localhost:6006"

from phoenix.otel import register

# 关键的一行:清空默认的追踪处理器
# 默认那个是往 api.openai.com 发的 —— 我们用第三方模型,发过去只有 401
# 注意这里不是 set_tracing_disabled(True),那样 Phoenix 也收不到了
set_trace_processors([])

# 配置 Phoenix 追踪器,它会接管
tracer_provider = register(
    project_name="agents",   # 默认是 'default'
    auto_instrument=True,    # 按已安装的依赖自动埋点
)

agent = Agent(name="Assistant", instructions="你是一个乐于助人的助手")


async def main():
    with trace("俳句生成器"):
        result = await Runner.run(
            agent, "写一首关于编程中递归的俳句。"
        )
        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())
这一行就是我们等了五章的东西 —— 已实测

set_trace_processors([]) 和第二章那个 set_tracing_disabled(True) 不是一回事, 这个区别是整章对你最关键的一点:

set_tracing_disabled(True)   → 追踪整个关掉,Phoenix 也收不到
set_trace_processors([])     → 只清空默认那个(发往 OpenAI 的)
                                    然后让 Phoenix 接管

我用假的第三方模型服务 + 真的 Phoenix 容器实测过, 结果是 12 个 span 完整落库,层级一个不少:

Phoenix 项目 'agents' 收到的 span:
  俳句生成器  ← 你 trace() 里起的名字
    Agent workflow
      Assistant
        turn
          generation
            ChatCompletion

所以:从这一章起,请把前六章代码里的 set_tracing_disabled(True) 换成 set_trace_processors([]) + Phoenix。 那些 with trace(...) 立刻就不再是空转了—— 第四章的移交、第五章的推理循环、第六章的检索,全都能看见了。

为什么 import 顺序看着这么怪

注意 from phoenix.otel import register 被写在了文件中间, 而不是顶部——这不是作者随手写的。PHOENIX_COLLECTOR_ENDPOINT 这个环境变量必须在 phoenix 被 import 之前设好, 否则它连不到你的容器。顺序在这里是有意义的,别用格式化工具随手排序 import。

给追踪打标签:会话和元数据

对应 7.4.2 · 10_arize_phoenix_metadata.py

from openinference.instrumentation import using_metadata, using_session


async def main():
    # 把这一次运行归到某个会话下,并挂上自定义标签
    with using_session("sess-42"), using_metadata({"env": "dev", "用户": "张三"}):
        with trace("俳句生成器"):
            result = await Runner.run(agent, "写一首关于递归的俳句。")
            print(result.final_output)

为什么需要这个?因为跑一次看一次没问题,跑一千次之后 Phoenix 里就是一片 span 的海洋。using_session 把同一个用户的多轮对话串起来,using_metadata 让你能按环境、按版本、按用户去筛。

这一节其实是在为第八章铺路

第八章讲部署,那时候你面对的不是自己跑的 5 道基准题, 而是真实用户在真实环境里制造的一堆 trace没有会话和元数据,你根本没法回答「昨天下午那个投诉的用户到底遇到了什么」。

番外

顺带一提的图像生成

0708 跟评估关系不大,但 08 的结构很有意思。

07_image_generation_agent.pyImageGenerationTool 生成图片, 存进 gen_images/08_image_vision_critic_agents.py 则是生成 + 一个能看图的批评者智能体——让一个智能体看另一个智能体画的图, 然后提意见

08 其实是本章主题的另一种形态

「一个智能体评判另一个智能体的产出」——这不就是 04 那个评委吗? 只是从评文字变成了评图。LLM-as-judge 的模式是通用的, 换个模态照样成立。

这两个文件会不打招呼地弹窗

生成图片后它们会调 open_file(),也就是系统的 open(macOS)——会直接弹出图片查看器。 在服务器或 CI 里跑是意外行为。另外它们写死了 model="gpt-5-mini"第三方服务多半没有图像生成接口,这两个例子你大概率跑不了。

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