这套流程能做出什么
跑完这份教程,你会得到一条可运行的浏览器 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 倍是一个参考量级,不是承诺。真正能复用的是这套方法——先量基线,再按步骤分档,把能下沉的断言下沉,把稳定的前缀缓存住,把上下文削到刚好够用。这四件事做完,账自然会下来。
