跳到主内容
快讯直播
AI智模界
教程

复现 76 倍降本:浏览器 E2E 测试的模型账单优化

这套流程能做出什么

跑完这份教程,你会得到一条可运行的浏览器 E2E 测试流水线,它同时满足三件事:

1. 同一批用例、同一套断言强度,单次运行的模型费用比"每一步都问大模型"的方案低一到两个数量级。公开分享里提到的 76 倍是这个量级的参考点,你自己能压到多少,取决于用例结构、缓存命中率和断言下沉的比例。

2. 一份按步骤拆开的成本报表,能直接看出钱花在哪一档模型、哪一类步骤上。

3. 一个不会因为模型抖动而随机变红的测试集——因为所有能被代码判断的断言,都已经下沉到代码里了。

核心思路一句话:把模型调用当成一次网络请求来优化——能省就省、能缓存就缓存、能不用就不用。

具体拆成四把刀:

手段作用点典型效果来源
模型分档路由调用次数与单价高频步骤走小模型,低频难题才上强模型
提示缓存输入单价稳定前缀命中缓存读,单价明显低于未命中输入
断言下沉到代码调用次数大量断言不再需要模型参与
上下文裁剪输入 token 量只喂无障碍树,不喂整页 HTML

这四项在账上是相乘关系,不是相加。这也是为什么倍数容易做大。

前置条件清单

  • 一个能跑起来的浏览器 E2E 工程(Playwright、Puppeteer、Selenium 任一,本文示例用 Playwright 的 API 风格,具体 API 名称以官方文档当前版本为准)
  • 一个支持多档模型、且支持提示缓存的模型服务;型号、价格、缓存最小 token 阈值一律以官方文档当前版本为准
  • Node.js 或 Python 运行环境
  • 一个可重复执行的测试环境(staging),能在同一份代码上反复跑同一批用例
  • 模型调用的日志能力,能导出每次请求的模型名、输入 token、缓存读 token、缓存写 token、输出 token
  • 一个用来放价格的配置文件(后面会讲为什么不能硬编码)

步骤一:先量基线,再谈降本

没有基线的降本都是讲故事。先做一个全强模型、无缓存、每步都调用的基线版本,把它跑完整批用例,记录总费用。

埋点包装器长这样:

```python

cost_meter.py

import time, json, os

价格从配置文件读,不要写死在代码里

with open("pricing.json", encoding="utf-8") as f:

PRICING = json.load(f)

def call_model(client, *, model, messages, step_type, run_id, **kw):

resp = client.chat(model=model, messages=messages, **kw)

u = resp.usage

rec = {

"run_id": run_id,

"step_type": step_type,

"model": model,

"in_uncached": getattr(u, "input_tokens", 0),

"cache_read": getattr(u, "cache_read_tokens", 0),

"cache_write": getattr(u, "cache_write_tokens", 0),

"out": getattr(u, "output_tokens", 0),

"ts": time.time(),

}

with open("llm_calls.jsonl", "a", encoding="utf-8") as f:

f.write(json.dumps(rec, ensure_ascii=False) + "\n")

return resp

```

```json

// pricing.json —— 数值请从官方定价页填入,单位统一为「每百万 token」

{

"strong": { "in": 0, "cache_read": 0, "cache_write": 0, "out": 0 },

"small": { "in": 0, "cache_read": 0, "cache_write": 0, "out": 0 }

}

```

成本计算:

```python

report.py

import json

from collections import defaultdict

PRICING = json.load(open("pricing.json", encoding="utf-8"))

def cost(rec):

tier = "strong" if rec["model"].startswith("strong") else "small"

p = PRICING[tier]

return (

rec["in_uncached"] / 1e6 * p["in"]

+ rec["cache_read"] / 1e6 * p["cache_read"]

+ rec["cache_write"] / 1e6 * p["cache_write"]

+ rec["out"] / 1e6 * p["out"]

)

by_step = defaultdict(lambda: {"calls": 0, "cost": 0.0})

total = 0.0

for line in open("llm_calls.jsonl", encoding="utf-8"):

rec = json.loads(line)

by_step[rec["step_type"]]["calls"] += 1

by_step[rec["step_type"]]["cost"] += cost(rec)

total += cost(rec)

for k, v in sorted(by_step.items(), key=lambda x: -x[1]["cost"]):

print(f"{k:28s} calls={v['calls']:6d} cost={v['cost']:.4f}")

print(f"{'TOTAL':28s} cost={total:.4f}")

```

跑完这一步,你会得到第一张表:每一步花了多少钱。多数人第一次看到这张表都会发现,钱集中在两三个步骤上。

步骤二:把测试流程拆开,按步骤分档

关键认识:E2E 测试里真正需要"判断力"的步骤,其实很少。把流程拆成下面这几类:

步骤频率需要的能力分档
从需求/工单生成测试计划极低(需求变更时)长上下文理解、规划强模型
生成或更新 Page Object、选择器策略低代码生成强模型
单步元素定位极高从无障碍树里挑一个节点小模型 / 或纯代码
单步断言(语义等价判断)高轻量比较小模型
失败归因(用例过期 vs 真 bug)中多模态推理强模型,仅失败时调用
失败修复与重跑低代码修改强模型

路由表配置化:

```yaml

routing.yaml

routes:

plan_from_ticket: { tier: strong, use_cache: true, escalate_after: 0 }

gen_page_object: { tier: strong, use_cache: true, escalate_after: 0 }

locate_element: { tier: small, use_cache: true, escalate_after: 2 }

assert_semantic: { tier: small, use_cache: true, escalate_after: 1 }

triage_failure: { tier: strong, use_cache: true, escalate_after: 0 }

repair_test: { tier: strong, use_cache: true, escalate_after: 0 }

```

escalate_after: 2 的意思是:小模型连续两次给不出可用结果,第三次才升级到强模型。这条规则是控制长尾成本的关键——不要让少数困难样本把整体单价拉回强模型水平。

步骤三:把断言下沉到代码

这是降本幅度里占比很大的一块,也最容易被忽略。

做法是:模型只输出结构化的"断言语义",代码负责把它编译成真正的断言。

模型返回的东西长这样:

```json

{

"kind": "text_visible",

"role": "alert",

"name": "提交成功",

"strict": true

}

```

代码把它编译成断言:

```typescript

// assert-compiler.ts

type Intent =

| { kind: "text_visible"; role: string; name: string; strict: boolean }

| { kind: "url_matches"; pattern: string }

| { kind: "count_equals"; role: string; name: string; count: number }

{ kind: "attr_equals"; role: string; name: string; attr: string; value: string };

export function compile(page: Page, i: Intent) {

switch (i.kind) {

case "text_visible":

return expect(page.getByRole(i.role as any, { name: i.name })).toBeVisible();

case "url_matches":

return expect(page).toHaveURL(new RegExp(i.pattern));

case "count_equals":

return expect(page.getByRole(i.role as any, { name: i.name }))

.toHaveCount(i.count);

case "attr_equals":

return expect(page.getByRole(i.role as any, { name: i.name }))

.toHaveAttribute(i.attr, i.value);

}

}

```

分界线画在哪:

  • 数字、金额、时间戳、状态码、条数、顺序——一律代码比较,模型不参与
  • 固定文案的可见性——代码比较,getByRole 就够了
  • 文案的语义等价(比如"操作成功"和"已保存"算不算同一件事)、时区/千分位格式差异——这才交给小模型,而且要把候选集合限制死,让它做选择题而不是问答题

一个判断标准:如果你能用一句 if 写出来,就不要调用模型。

步骤四:提示缓存怎么设计

提示缓存能否命中,取决于前缀是否逐字节稳定。把 prompt 切成两段:

  • 稳定前缀:系统指令、输出 schema 定义、Page Object 可用 API 目录、无障碍树裁剪规则、断言词汇表、few-shot 示例
  • 变化尾部:当前步骤、当前无障碍树片段、上一步的执行结果

```python

prompt_builder.py

SYSTEM_PREFIX = """你是一个浏览器自动化助手。

输出必须严格符合以下 JSON Schema:

{ ... }

可用的定位 API 只有:

  • getByRole(role, name)
  • getByLabel(text)
  • getByTestId(id)

无障碍树裁剪规则:

  • 只保留 role / name / value / disabled / checked
  • 忽略装饰性节点

断言词汇表:

{ ... }

"""

def build_messages(step, ax_snapshot):

前缀必须完全一致:不要在这里插入时间戳、run_id、用例名

return [

{"role": "system", "content": SYSTEM_PREFIX},

{

"role": "user",

"content": (

f"当前步骤:{step.instruction}\n"

f"页面无障碍树:\n{ax_snapshot}"

),

},

]

```

几个必须守住的细节:

1. 前缀放在最前面,缓存通常只对开头连续相同的部分生效。

2. 前缀里不能有动态内容:时间戳、随机 ID、当前用例名、运行次数,任何一个都会让命中率归零。

3. 前缀长度要够。多数服务对可缓存前缀有最小 token 阈值,具体数值以官方文档为准;前缀太短时会完全不缓存。

4. 工具/schema 定义的顺序要固定。字段顺序变了,缓存就失效。

5. 缓存写是有成本的。低复用场景(同一前缀只跑一两次)缓存反而更贵,这时应该关掉。

6. 监控命中率。命中率掉下来,通常是有人往系统提示里塞了动态内容。

步骤五:上下文裁剪——只喂无障碍树

整页 HTML 是输入 token 的主要来源,而且大部分是无用噪音。改成只取无障碍树:

```python

用浏览器提供的无障碍快照能力,具体 API 以官方文档当前版本为准

snapshot = page.accessibility.snapshot(interesting_only=True)

KEEP = {"role", "name", "value", "disabled", "checked", "focused"}

def prune(node):

if not isinstance(node, dict):

return None

out = {k: node[k] for k in KEEP if k in node}

kids = [prune(c) for c in node.get("children", [])]

kids = [k for k in kids if k]

if kids:

out["children"] = kids

return out or None

```

再叠加两层裁剪:

  • 按视口或相关子树截断,不要把整棵树都塞进去
  • 树节点数设上限,超出就按"交互性优先"排序后截断

这一步通常能把单次调用的输入 token 量降一大截,而且是不牺牲准确率的降低。

步骤六:结果缓存与升级策略

除了提示缓存,再加一层结果缓存:同样的 (步骤签名, 无障碍树哈希) 直接复用上次结果,连模型都不调用。

```python

import hashlib, json, os

def step_signature(step):

return hashlib.sha256(

f"{step.type}|{step.instruction}|{step.locator_hint or ''}".encode()

).hexdigest()

def dom_hash(ax_snapshot):

return hashlib.sha256(ax_snapshot.encode()).hexdigest()

def cached_call(step, ax_snapshot, call_fn):

key = f"{step_signature(step)}:{dom_hash(ax_snapshot)}"

path = os.path.join(".llm_cache", key + ".json")

if os.path.exists(path):

return json.load(open(path, encoding="utf-8"))

result = call_fn()

os.makedirs(".llm_cache", exist_ok=True)

json.dump(result, open(path, "w", encoding="utf-8"), ensure_ascii=False)

return result

```

注意把 .llm_cache 在 CI 里做持久化,否则每次流水线都是冷启动,缓存等于没有。

步骤七:把降本幅度算清楚

单次运行成本:

```

cost = Σ_calls [

in_uncached/1e6 * p_in

+ cache_read /1e6 * p_cache_read

+ cache_write/1e6 * p_cache_write

+ out /1e6 * p_out

]

```

降本倍数可以拆成三个乘数来理解:

乘数含义来自哪个手段
N₀ / N₁调用次数下降倍数断言下沉、结果缓存
T₀ / T₁单次输入 token 下降倍数无障碍树裁剪、提示缓存
P₀ / P₁有效单价下降倍数分档路由、缓存读单价

总降本 ≈ N 倍数 × T 倍数 × P 倍数(近似值,因为各项互相耦合)。

用脚本输出对比表:

```python

compare.py

base = sum(cost(r) for r in load("baseline.jsonl"))

opt = sum(cost(r) for r in load("optimized.jsonl"))

print(f"baseline = {base:.4f}")

print(f"optimized = {opt:.4f}")

print(f"ratio = {base / opt:.1f}x")

```

算账时必须对齐三件事,否则数字没有意义:

1. 同一批用例、同一套断言强度。放宽断言换来的降本不算数。

2. 把失败重试算进去。只看绿色运行会严重高估效果。

3. 把升级到强模型的调用算进去。长尾样本的成本往往被漏掉。

建议再补一个质量守恒指标:往页面里注入若干条已知 bug(比如改掉一个按钮的可见性、改掉一个校验文案),看测试集能抓到几条。降本前后这个数字应该基本持平。

常见坑与排错

只报优化后成本,不报基线。 没有分母就没有倍数,这个数字也就没法对外解释。

往系统提示里塞动态内容。 症状是缓存命中率突然从高位掉到接近零。排查方法:把两次请求的前缀做逐字节 diff,第一个不一样的字符就是元凶。

前缀太短,根本不触发缓存。 多数服务有最小可缓存长度,具体阈值以官方文档为准。前缀不够长时,考虑把 Page Object API 目录、断言词汇表这类稳定内容合并进去。

小模型频繁升级到大模型。 结果是既付了小模型的钱,又付了大模型的钱。排查方法:统计每个步骤的 escalate_rate,超过某个比例就说明该步骤本来就不适合小模型,直接改档。

把整页 HTML 塞进上下文。 这是输入 token 的主要浪费源。先做无障碍树裁剪,再谈别的优化。

让模型判断"测试过没过"。 既贵又不稳定。测试结论应该由代码里的断言决定,模型只负责把自然语言期望翻译成结构化意图。

价格硬编码在代码里。 定价会变,硬编码会让成本报表悄悄失真。放配置文件,并注明以官方定价页为准。

缓存目录没持久化。 CI 里每次都是全新容器,结果缓存全冷启动。要么把缓存目录挂载出来,要么用 CI 的缓存机制。

缓存写成本高于收益。 前缀复用次数很低时(比如一次性的探索性用例),缓存写反而更贵。这类场景直接关掉。

忽略失败运行的采样偏差。 只在"一切顺利"的那几次运行上算成本,会低估真实账单。要把所有运行,包括中途失败的,都纳入统计。

下一步建议

1. 先做影子对比。新老两套并行跑一段时间,只比较成本和抓 bug 数量,不要急着替换。

2. 建一个小型评估集。把历史上真实出过问题的用例挑出来,作为路由表改动的回归测试。每次调路由,先在这批用例上验证。

3. 把升级率做成告警指标。某个步骤的升级率突然上升,通常意味着页面结构变了或者提示词被改动。

4. 定期复核分档。模型能力在变,今天适合小模型的步骤,明天可能纯代码就够了,也可能反过来。

5. 把成本指标写进 CI 看板。让每次改动对账单的影响可见,降本才守得住。

6. 注意 token 之外的账。人工排查失败用例的时间、流水线的墙钟时间,同样是成本。有时候多花一点模型费用换更短的反馈周期是划算的。

最后提醒一句:76 倍是一个参考量级,不是承诺。真正能复用的是这套方法——先量基线,再按步骤分档,把能下沉的断言下沉,把稳定的前缀缓存住,把上下文削到刚好够用。这四件事做完,账自然会下来。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。