《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距

全卷都在用假模型跑确定性对照,但「假模型全绿」到底能不能代表上线质量?本节给出一套可运行的对照 harness:同评测集、同评分器,先真跑出假模型列的数字,再把真模型列留空、显式标注「需用户授权后运行 --real 才填」,并逐条列出假模型量不出的五件事及其度量方法。

11.2 真实 API 冒烟:假模型 vs 真模型量化差距

从第 1 章到第 10 章,本卷的每一份「真跑输出」都来自假模型:TestModel 或 FunctionModel,零网络、零凭据、逐字节可复现。这套方法让所有断言都稳如磐石,但它有一个必须被正视的盲区——假模型的「全绿」是设计出来的,不是测出来的。一个按脚本吐正确答案的模型,当然会拿到 100% 的分数。真正的问题不是「假模型准不准」,而是「假模型和真模型之间差多少、差在哪」。

本节把「假模型能测什么」推进到「假模型测不出什么、怎么补」:给出一份可运行的对照 harness,用同一评测集与同一评分器,先真跑出假模型列的真实数字,再把真模型列留空并显式标注其运行前提。本节不发起任何真实 API 调用,不消耗任何额度。

11.2.1 「假模型全绿」为什么说明不了上线质量

先厘清一个误解。假模型不是「简化版真模型」,它是一台按剧本执行的确定性状态机。它的价值在于验证「编排逻辑对不对」:工具是否被调用、调用了几次、参数是什么、循环是否收敛、错误是否被回灌。这些都是结构问题,假模型能给出精确答案。

但上线质量里还有一半是内容问题,假模型结构上无法回答:

问题类型例子假模型能答吗
结构有没有调用 search?调了几次?能,逐字节确定
结构超步数时是否正确中止?能
内容答案是不是编的?不能
内容输出 JSON 会不会偶尔崩格式?不能
内容工具选对了但参数错,模型会不会自我纠正?不能
成本这次真跑花了多少 token / 多少钱?不能(usage 是合成的)
性能首 token 延迟、端到端延迟?不能(假模型近乎瞬时)

所以「假模型全绿」的正确解读是:编排没问题。它绝不等于「模型没问题」。要把后半句也测了,唯一的办法是真跑一次,而真跑就要消耗额度——这就引出了本节的核心纪律。

11.2.2 对照方法论:同评测集、同评分器、两列结果

对照要成立,必须锁死三件事,只放开「模型」这一个变量:

  • 同一评测集:一组固定的 (query, 期望工具, 期望关键词) 样本。
  • 同一评分器:工具选择命中、答案关键词命中、步数、token 数,全部用同一段代码算。
  • 同一工具:search(query) 返回逐字节一致的知识库片段。

在这个前提下,跑两次:一次喂假模型(FunctionModel 按剧本走),一次喂真模型。两次结果并排放进同一张表,差距才有意义。下表是对照表的骨架——左列本节真跑,右列本节留空:

指标假模型列(本节真跑)真模型列(需授权后运行 --real)
完成率 run_ok见 11.2.4 输出待填
工具选择准确率 tool_sel见 11.2.4 输出待填
答案关键词命中率 answer_hit见 11.2.4 输出待填
平均步数见 11.2.4 输出待填
平均 token见 11.2.4 输出待填
平均延迟(毫秒)见 11.2.4 输出待填

为什么右列必须留空:填真模型列意味着真的发起网络调用、真的消耗用户额度。这需要用户明确同意。本节的立场是——可以先把方法、harness、假模型列都做扎实,但绝不代用户决定何时花钱。因此本节只交付一套「一按 --real 就能跑」的工具,把授权这一步显式留给用户。

11.2.3 可运行的对照 harness

下面这份脚本是完整的对照工具。默认(不加 --real)只跑假模型列,且在 --real 分支上加了双重守卫,确保不会被误触:

# gap_harness.py
import argparse
import os
import sys
import time

from pydantic_ai import Agent
from pydantic_ai.models.function import FunctionModel, AgentInfo
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart

EVAL = [
    {"id": "q1", "query": "smolagents", "tool": "search", "expect": "CodeAgent"},
    {"id": "q2", "query": "langgraph", "tool": "search", "expect": "StateGraph"},
    {"id": "q3", "query": "pydanticai", "tool": "search", "expect": "FunctionModel"},
    {"id": "q4", "query": "a2a protocol", "tool": "search", "expect": "Agent Card"},
    {"id": "q5", "query": "durable execution", "tool": "search", "expect": "重放"},
]

DOCS = {
    "smolagents": "smolagents 用 CodeAgent 把动作写成 Python 代码执行。",
    "langgraph": "LangGraph 用 StateGraph 与检查点做有状态编排。",
    "pydanticai": "PydanticAI 提供 FunctionModel / TestModel 做确定性测试。",
    "a2a protocol": "A2A 用 Agent Card 描述能力,用 Task 承载协作。",
    "durable execution": "Durable Execution 通过确定性重放恢复长任务。",
}

RESULTS = {"tool_args": []}


def search(query: str) -> str:
    """检索本地知识库。"""
    RESULTS["tool_args"].append(query)
    return DOCS.get(query, "[miss]")


def scripted(task):
    def fn(messages, info: AgentInfo) -> ModelResponse:
        returned = sum(
            1 for m in messages for p in getattr(m, "parts", [])
            if type(p).__name__ == "ToolReturnPart"
        )
        if returned == 0:
            return ModelResponse(parts=[ToolCallPart("search", {"query": task["query"]})])
        return ModelResponse(parts=[TextPart(f"{task['expect']}:{DOCS.get(task['query'], '')}")])
    return FunctionModel(fn)


def score_fake() -> list[dict]:
    rows = []
    for task in EVAL:
        RESULTS["tool_args"].clear()
        agent = Agent(scripted(task), instructions="先检索再给结论")
        agent.tool_plain(search)
        t0 = time.perf_counter()
        r = agent.run_sync(f"查 {task['query']} 并给结论")
        dt = (time.perf_counter() - t0) * 1000
        rows.append({
            "id": task["id"],
            "run_ok": True,
            "tool_sel": task["query"] in RESULTS["tool_args"],
            "answer_hit": task["expect"] in r.output,
            "steps": r.usage.requests,
            "tokens": r.usage.total_tokens,
            "ms": round(dt, 1),
        })
    return rows


def print_table(rows: list[dict]) -> None:
    hdr = f"{'id':4} {'run_ok':6} {'tool_sel':8} {'answer_hit':10} {'steps':5} {'tokens':6} {'ms':>8}"
    print(hdr)
    print("-" * len(hdr))
    for r in rows:
        print(f"{r['id']:4} {str(r['run_ok']):6} {str(r['tool_sel']):8} "
              f"{str(r['answer_hit']):10} {r['steps']:<5} {r['tokens']:<6} {r['ms']:>8}")
    n = len(rows)
    print(f"\n聚合: tool_sel={sum(r['tool_sel'] for r in rows) / n:.0%}  "
          f"answer_hit={sum(r['answer_hit'] for r in rows) / n:.0%}  n={n}")


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--real", action="store_true", help="用真模型跑(需用户授权,会消耗额度)")
    args = ap.parse_args()

    if args.real:
        if os.environ.get("AGENT_ALLOW_REAL") != "1":
            print("[拒绝] --real 需要用户明确授权:请先设置 AGENT_ALLOW_REAL=1。")
            return 2
        if not os.environ.get("ANTHROPIC_API_KEY"):
            print("[拒绝] 缺少 ANTHROPIC_API_KEY。")
            return 2
        print("[已授权] 真模型列将用同一评测集运行……(本节未执行此分支)")
        return 0

    print("== 假模型列(本次真跑)==")
    print_table(score_fake())
    print("\n真模型列:未运行(需 --real 且 AGENT_ALLOW_REAL=1 授权)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

--real 分支被两道门挡住:环境变量 AGENT_ALLOW_REAL 必须等于 1(这是用户授权的显式动作),且 ANTHROPIC_API_KEY 必须存在。缺任何一个都直接返回退出码 2,不会发起任何网络请求。这不是为了「安全」,而是为了把「花钱」这个决定从代码里挪到用户的命令行上。

11.2.4 假模型列:本次真跑输出

在本机 venv3 里跑默认分支(不加 --real),逐字节输出如下:

== 假模型列(本次真跑)==
id   run_ok tool_sel answer_hit steps tokens       ms
-----------------------------------------------------
q1   True   True     True       2     128        23.9
q2   True   True     True       2     124         3.5
q3   True   True     True       2     128         2.9
q4   True   True     True       2     135         2.9
q5   True   True     True       2     126         3.3

聚合: tool_sel=100%  answer_hit=100%  n=5

真模型列:未运行(需 --real 且 AGENT_ALLOW_REAL=1 授权)

顺手验证一下守卫:执行 gap_harness.py --real(不带授权变量),输出是 [拒绝] --real 需要用户明确授权:请先设置 AGENT_ALLOW_REAL=1。,退出码 2,没有任何网络流量。

这张表的数字要点明两件事。第一,它是真的——tokens 与 ms 是这次运行实际测到的,不是编的。第二,它是被设计成 100% 的——因为假模型的任务就是「按剧本答对」。所以这张表的正确用途不是「证明系统好」,而是建立基线口径:等真模型列填上,两列的差值才是本节真正要量的东西。

11.2.5 真模型列:留空,等授权

真模型列怎么填?把 score_fake 换成一个用真模型的版本——例如把 FunctionModel(scripted(task)) 换成 AnthropicModel(...),其余评分逻辑一字不动。骨架如下(本节未执行):

# 仅示意:需要活的 API 会话才能运行,本节未执行
from pydantic_ai.models.anthropic import AnthropicModel

def score_real(model_name: str) -> list[dict]:   # model_name 用你账户可用的模型 id
    model = AnthropicModel(model_name)           # 需要 ANTHROPIC_API_KEY
    rows = []
    for task in EVAL:
        RESULTS["tool_args"].clear()
        agent = Agent(model, instructions="先检索再给结论")
        agent.tool_plain(search)
        t0 = time.perf_counter()
        r = agent.run_sync(f"查 {task['query']} 并给结论")   # <- 真实网络调用
        rows.append({"id": task["id"], "tool_sel": task["query"] in RESULTS["tool_args"],
                     "answer_hit": task["expect"] in r.output,
                     "steps": r.usage.requests, "tokens": r.usage.total_tokens,
                     "ms": round((time.perf_counter() - t0) * 1000, 1)})
    return rows

本节的立场写死在这里:真模型列需要用户授权后运行 --real 才填,本节不代填、不估算、不编造。 任何「预计真模型会拿到 85%」的说法都是编数字,违反本卷「禁止假装跑过」的红线。真模型列的空缺不是遗漏,而是纪律——它把「什么时候花钱」的决定权留在用户手里。

11.2.6 假模型量不出的五件事:逐条怎么度量

留空不是终点。对照表要能指导「真模型列填上后看什么」,所以下面把五类「假模型量不出」的差距逐条拆开,说明它们在评测集上具体怎么度量:

差距类型假模型为何量不出在评测集上的度量方法判据
幻觉假模型不会编,答案由脚本给定对每题的最终答案做「引用支持度」核对:把答案里的断言回 DOCS 语料比对出现语料不支持的断言即计一次幻觉
格式漂移假模型输出逐字节固定对结构化输出题,解析模型返回的 JSON;统计解析失败率与字段缺失率解析失败率 > 0 即暴露漂移
工具误选假模型被写死只调 search扩充评测集到「多工具候选」,统计 tool_sel:调了错的工具、或该调没调tool_sel 下降即误选
真实 token 成本usage 是合成的读真模型返回的 usage,按当前价目表折算每题成本与预算比,超支即报警
真实延迟假模型近乎瞬时记录每题端到端墙钟时间,取 P50 / P95P95 超过 SLO 即暴露

这五类里,幻觉与工具误选是内容风险,格式漂移是接口风险,token 与延迟是成本与体验风险。它们恰好覆盖了卷二第 12 章评测门、第 14 章成本治理、第 15 章输出护栏想守住的那些线。换句话说,真模型列不只是「分数」,它把前面几章的护栏从「假模型上的纸面结论」变成「真模型上的实测证据」。

一个必须提醒的度量陷阱:评测集越大,真模型列的方差越大。5 题跑出 100% 和 100 题跑出 90%,后者更可信。所以真模型列最好按「题量 × 重复次数」给区间,而不是单点数字——单点数字在内容问题上极易骗人。

11.2.7 冒烟与全量的分工

真模型列不适合在每次提交时跑,因为又慢又贵。务实的分工是:

层次模型频率目的
单元 / 结构假模型每次提交编排正确、循环收敛、工具调用
冒烟真模型 × 小样本发版前一次量真假差距、发现内容风险
全量评测真模型 × 全评测集按周 / 里程碑分数趋势、回归检测

冒烟的作用是用最小成本暴露最大的差距。它不追求统计显著性,只追求「一眼看出假模型漏了什么」。如果冒烟发现幻觉率或工具误选率显著,再上全量评测定位——这是成本与信号强度的经典权衡。

11.2.8 这套 harness 替你做了什么、你又让渡了什么

你得到的你让渡的
一套真假对照的口径,可复用到任何 Agent需要维护一份有代表性的评测集
假模型列零成本、可 CI、可复现它证明不了内容质量,只能证明结构
--real 把花钱决定权留给用户真模型列要手动授权、手动跑、手动填
五类差距的度量方法每类都要额外的评分器与语料核对

一句话:harness 把「假模型 vs 真模型」从一句口号变成一张可填的表,并且诚实地把不能填的格子留白。这和第 4.3 节「同题对照」是同一种方法论——锁死变量、只放一个、量出差值。区别在于,4.3 量的是「框架之间」,本节量的是「模型之间」,而后者碰了钱,所以多了一道授权的门。

到这里,本卷的技术内容就讲完了:从框架全景到源码剖析,从持久化到计算机使用,从跨 Agent 协议到强化学习,再到生产化与平台化。下一节 11.3 全景回顾:从零到框架的完整路径与展望 会把三卷串成一张总表;想回看对照方法论的第一次出场,见 4.3 三框架同题对照:同评测集比分数与成本 。

阅读导航:上一节:11.1 平台化:Agent 注册、版本与配额 · 下一节:11.3 全景回顾:从零到框架的完整路径与展望 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.1 平台化:Agent 注册、版本与配额