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 / P95 | P95 超过 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 全景回顾:从零到框架的完整路径与展望 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。