大多数关于AI代理的教程都会犯同一个错误:它们会将所有任务都分配给最昂贵的模型来处理。
计算字符数量并不需要使用GPT-4;进行存在性检查也不需要Sonnet;而进行正则表达式匹配,除了Python之外根本不需要其他任何工具。
真正的错误并不在于使用AI,而在于不知道何时应该停止使用它。
本教程会向你展示如何构建一个分层路由系统,该系统会将任务分配给能够解决这些任务的、最便宜的模型。这种设计模式被称为“成本曲线”。这个想法源自DEV.to上一篇文章下的评论区,由三位开发者在周末时间里实现了出来,他们使用这一方法将某个真实SEO审计工具的每个URL的处理成本从0.006降到了大多数页面上的0。
学习完本教程后,你将会得到一个可用的`cost_curve.py`模块,你可以将其添加到任何代理项目中去使用。
你将构建什么
你会构建一个三层路由系统,该系统能够:
-
首先使用确定性的Python代码进行检查——这样就不会产生任何API调用费用
-
只有在对结果存在明显模糊性时,才会使用Claude Haiku模型进行处理——每次调用的成本约为0.0001美元
-
只有在需要进行语义判断时,才会使用Claude Sonnet模型——每次调用的成本约为0.006美元
-
如果任何一层处理机制出现故障,系统都能优雅地切换到其他层进行处理
-
无论哪个层次负责处理请求,最终都会返回一致的结果格式
这一完整实现方案是开放源码SEO审计工具`dannwaneri/seo-agent`的一部分。其中,“成本曲线”模块就是该工具中的高级路由层,而这种设计原理也适用于任何需要处理复杂任务的代理系统。
先决条件
-
Python 3.11或更高版本
-
Anthropic API密钥
- 对Python以及Claude API有基本的了解
目录
在所有情况下都使用Claude模型存在的问题
大多数代理系统的代码都是这样的:……
def audit_url(snapshot: dict) -> dict:
response = client.messages.create(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": buildprompt(snapshot)}]
)
return parse_response(response)
这个方法确实有效。它会对列表中的每个URL进行检查——包括那些标题长度为142个字符、且显然属于失败情况的URL,而这些情况下根本不需要使用任何模型。
Claude Sonnet 4的服务费用为:每百万输入标记3美元,每百万输出标记15美元。通常情况下,一个页面的快照大约需要500个输入标记,因此仅输入部分的费用约为每个URL 0.0015美元——这还没有包括输出标记的费用。如果每周对20个URL进行审核,总费用大约为0.12美元。这个费用并不高。但是,这些页面中的大多数都存在一些机械性的SEO问题,比如缺少描述、标题长度超过60个字符、没有设置规范标签等等。而通过统计字符数量就可以发现这些问题,因此根本不需要使用模型。
这种收费机制能够根据任务的实际需求来决定处理流程,而不是依据模型的功能能力来制定费用标准。
成本曲线的解释
在成本曲线中,我们划分出了三个层级、三种工具以及相应的价格区间:
第一层级——确定性Python。成本:0美元。这一层会检查标题长度、描述长度、H1标签的数量以及是否设置了规范标签。这些检查都是通过简单的字符串操作来完成的,并不需要使用模型。
第二层级——Claude Haiku。成本:每次调用约0.0001美元。如果页面的标题只有4个字符,描述只有30个字符,或者状态码表示需要重定向,那么这些页面虽然通过了机械性的审核,但仍然存在问题。不过Claude Haiku运行速度很快,使用成本也很低,因此对于那些判断结果模糊的情况,使用它进行进一步处理所花费的成本,其实比因为误判而浪费的调试时间还要少。
第三层级——Claude Sonnet。成本:每次调用约0.006美元。只有当Claude Haiku认为某些页面需要通过语义分析来进一步判断时,才会使用这一层。例如,“这个标题虽然长度符合要求,但读起来更像是一个导航标签”;“这个描述与标题的内容完全重复”。Claude Sonnet的主要作用是在那些确实需要复杂处理的页面上发挥作用,并不是针对列表中的每一个URL都进行检测。
路由决策会在任何API调用之前完成。无论哪个层级处理了请求,最终得到的结果格式都是相同的。
项目设置
mkdir cost-curve-demo && cd cost-curve-demo
pip install anthropic
请设置您的API密钥:
# macOS/Linux
export ANTHROPIC_API_KEY="sk-ant-..."
# Windows PowerShell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
接下来创建`cost_curve.py`文件——您将逐步构建这个模块。
第一层级:确定性Python
在处理每个URL时,第一层级会首先被执行。它仅使用Python的字符串操作来检查四个字段,因此不会产生任何API调用、延迟或费用。
import json
import logging
import os
import re
from datetime import datetime, timezone
import anthropic
logger = logging.getLogger(__name__)
REDIRECT_CODES = {301, 302, 307, 308}
# 会触发第二级审核的字段
# 标题或描述存在,但长度明显过短
AMBIGUOUS_TITLE_MAX = 10 # 字符数——存在,但长度太短,因此不可信
AMBIGUOUS_DESC_MAX = 50 # 字符数——存在,但长度太短,因此没有实际意义
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _build_result(snapshot: dict, method: str) -> dict:
"""基本结果结构——无论审核级别如何,使用相同的格式。"""
return {
"url": snapshot.get("final_url", ""),
"final_url": snapshot.get("final_url", ""),
"status_code": snapshot.get("status_code"),
"title": {"value": None, "length": 0, "status": "PASS"},
"description": {"value": None, "length": 0, "status": "PASS"},
"h1": {"count": 0, "value": None, "status": "PASS"},
"canonical": {"value": None, "status": "PASS"),
"flags": [],
"human_review": False,
"audited_at": _now_iso(),
"method": method,
"needs_tier3": False,
}
def tier1_check(snapshot: dict) -> dict:
"""
仅使用Python内置功能进行SEO检查,不调用任何API接口。
返回的结果字典中,方法字段的值为"deterministic"。
总会将"needs_tier3"字段的值设置为False——第一级审核永远不会直接升级为第三级审核。
是否需要进入第二级审核由其他系统或规则决定,不在这里进行判断。"""
result = _build_result(snapshot, "deterministic")
title = snapshot.get("title") or ""
description = snapshot.get("meta_description") or ""
h1s = snapshot.get("h1s") or []
canonical = snapshot.get("canonical") or ""
# 标题检查
result["title"]["value"] = title or None
result["title"]["length"] = len(title)
if not title or len(title) > 60:
result["title"]["status"] = "FAIL"
msg = "标题缺失" if not title else f"标题长度为{len(title)}个字符(最长为60个字符)"
result["flags"].append(msg)
# 描述检查
result["description"]["value"] = description or None
result["description"]["length"] = len(description)
if not description or len(description) > 160:
result["description"]["status"] = "FAIL"
msg = "元描述缺失" if not description else f"元描述长度为{len"description)}个字符(最长为160个字符)"
result["flags"].append(msg)
# H1标签检查
result["h1」「count"] = len(h1s)
result["h1」「value"] = h1s[0] if h1s else None
if len(h1s) == 0:
result["h1」「status"] = "FAIL"
result["flags"].append("H1标签缺失")
elif len(h1s) > 1:
result["h1」「status"] = "FAIL"
result["flags"].append(f"检测到多个H1标签,共{len(h1s)}个")
# 标准化链接检查
result["canonical」「value"] = canonical or None
if not canonical:
result["canonical」「status"] = "FAIL"
result["flags"].append("标准化链接标签缺失")
return result
关键的设计决策是:tier1_check()本身并不会决定是否需要升级处理。它只会执行相应的检查并返回结果,而是否升级则由路由器根据这些结果来决定。
第二层级:用于处理模糊情况的Claude Haiku模型
当第一层级检测到某些机械性问题,但结果需要进一步核实时,就会启动第二层级的处理流程。例如:页面标题只有4个字符,但明显是错误的;描述内容有30个字符,但从技术上讲确实存在,但实际上毫无意义;或者重定向状态码需要人类能够理解的解释等等。
在这种情况下,Claude Haiku模型是非常合适的。它的处理速度很快,成本也很低(每百万个输入数据仅产生5个输出结果),而且完全足以用于进行初步的判断。系统会提出一个具体的问题:这个情况是否足够模糊,以至于需要使用Sonnet模型来进行更深入的分析?
def tier2_check(snapshot: dict) -> dict:
"""
使用Claude Haiku模型来处理模糊情况。
以“haiku”方法返回处理结果;
如果Haiku模型认为该情况需要进一步的语义分析,则将“needs_tier3”设置为True;
在API调用出现错误时,会回退到第一层级的处理结果。
"""
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise OSError("ANTHROPIC_API_KEY未设置。")
client = anthropic.Anthropic(api_key=api_key)
title = snapshot.get("title") or ""
description = snapshot.get("meta_description") or ""
status_code = snapshot.get("status_code")
prompt = f"""你是一名SEO审核员,正在进行快速初步检查。
页面信息:
- 标题:{repr(title)}({len(title)}个字符)
- 描述:{repr(description)}({len(description)}个字符)
- 状态码:{status_code}
请用“是”或“否”来回答以下两个问题:
1. 这个页面是否需要超出简单长度/存在性检查的进一步语义分析?
(例如,标题虽然存在但明显错误,描述虽然存在但毫无意义)
2. 这个状态码是否表示需要进一步调查的重定向情况?
请以以下的JSON格式进行回复,仅包含这些信息:
{{"needs_tier3": true_or_false, "reason": "一句话的解释"}}"""
try:
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=150,
messages=[{"role": "user", "content": prompt}],
)
raw = response.content[0].text.strip()
# 如果原始响应中包含Markdown格式,需要将其去除
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
parsed = json.loads(raw)
result = _build_result(snapshot, "haiku")
# 复制第一层级的检查内容——因为Haiku模型不会重复进行这些检查
t1 = tier1_check(snapshot)
result["title"] = t1["title"]
result["description"] = t1["description"]
result["h1"] = t1["h1"]
result["canonical"] = t1["canonical"]
result["flags"] = t1["flags"]
result["needs_tier3"] = parsed.get("needs_tier3", False)
if result["needs_tier3"]:
result["flags"].append(f"已升级至第三层级:{parsed.get('reason', '')}")
return result
except Exception as exc:
logger.warning("[tier2] Claude Haiku API调用出现错误:%s — 将回退到第一层级的处理结果", exc)
fallback = tier1_check(snapshot)
fallback["method"] = "haiku-fallback"
return fallback
回退机制是至关重要的。如果Haiku出现故障——无论是由于速率限制、网络错误还是响应格式不正确——该函数会返回一级处理的结果,而不会导致系统崩溃。审计过程依然会继续进行。这些被标记了method="haiku-fallback"的URL之后可以很容易地被识别出来。
第三级:用于语义判断的Claude Sonnet模型
在第三级中,会执行完整的提取流程。这与在简单实现中使用的调用方式是相同的;不同之处在于,只有极少数的URL会进入这一处理阶段。
def tier3_check(snapshot: dict) -> dict:
"""
使用Claude Sonnet模型进行语义判断。
返回的结果中会包含method="sonnet"这一字段。
这实际上就是直接调用该模型进行的完整提取流程。
"""
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise OSError("ANTHROPIC_API_KEY未设置。")
client = anthropic.Anthropic(api_key=api_key)
prompt = f"""你是一名SEO审计员。请分析这个页面快照,并仅返回一个JSON对象。
不需要撰写任何散文、解释内容,也不要使用markdown格式。只需提供原始的JSON数据即可。
页面信息:
- URL: {snapshot.get('final_url'}
- 状态码: {snapshot.get('status_code')]
- 标题: {snapshot.get('title')}
- 元描述: {snapshot.get('meta_description')]
- H1标签: {snapshot.get('h1s').
- 规范链接: {snapshot.get('canonical')}
需要返回的结构如下:
{
"url": "string",
"final_url": "string",
"status_code": number,
"title": {{"value": "string or null", "length": number, "status": "PASS or FAIL"},
"description": {{"value": "string or null", "length": number, "status": "PASS or FAIL"},
"h1": {{"count": number, "value": "string or null", "status": "PASS or FAIL}},
"canonical": {{"value": "string or null", "status": "PASS or FAIL"},
"flags": ["array of strings describing specific issues"],
"human_review": false,
"audited_at": "ISO timestamp"
}
判断标准:
- 标题:如果为空或长度超过60个字符,或者明显不是真实的标题,则判定为FAIL。
- 描述:如果为空或长度超过160个字符,或者内容毫无意义,则判定为FAIL。
- H1标签:如果数量为0或大于1,则判定为FAIL。
- 规范链接:如果为空,则判定为FAIL。
- 审计时间:应使用当前的UTC时间。
"""
try:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1000,
messages=[{"role": "user", "content": prompt}],
)
raw = response.content[0].text.strip()
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(raw)
result["method"] = "sonnet"
result["needs_tier3"] = False
return result
except Exception as exc:
logger.warning("[tier3] Claude Sonnet API出现错误:%s — 将回退到一级处理结果", exc)
fallback = tier1_check(snapshot)
fallback["method"] = "sonnet-fallback"
return fallback
请注意,第三层级中添加了一些在第一层级中并不存在的条件:"或者该标题虽然存在,但显然并不真实"以及"或者该标题虽然存在,但却没有任何实际意义"。这些正是Haiku认为需要进行语义判断的情况,而第三层级就是针对这些情况进行处理的。
路由器:audit_url()函数
路由器是公众使用的接口,其他所有内容都属于实现细节。
def audit_url(snapshot: dict, tiered: bool = False) -> dict:
"""
将页面快照通过相应的审核层级进行处理。
参数:
snapshot: 来自browser.py的页面数据——必须包含final_url、status_code、title、meta_description、h1s和canonical字段。
tiered: 如果设置为False,直接调用第三层级的处理函数;如果设置为True,则会按照成本曲线进行路由处理。
返回值:
一个审核结果字典,其中method字段会说明具体使用了哪个层级进行处理。
"""
if not tiered:
# 非分层模式:直接调用第三层级的处理函数,其行为与v1版本相同
return tier3_check(snapshot)
# 第一层级:总是首先被执行
t1_result = tier1_check(snapshot)
# 判断是否需要升级到第二层级进行进一步审核
title = snapshot.get("title") or ""
description = snapshot.get("meta_description") or ""
status_code = snapshot.get("status_code")
needs_tier2 = (
# 标题存在,但长度明显过短
(title and len(title) < AMBIGUOUS_TITLE_MAX) or
# 描述存在,但长度明显过短
(description and len(description) < AMBIGUOUS_DESC_MAX) or
# 重定向状态码——可能需要进一步解释
(status_code in REDIRECT_CODES)
)
if not needs_tier2:
# 第一层级的审核结果就是最终结果,直接返回即可
return t1_result
# 第二层级:由Haiku进行语义判断
t2_result = tier2_check(snapshot)
if not t2_result.get("needs_tier3", False):
# Haiku认为不需要进行语义判断,直接返回第二层级的结果
return t2_result
# 第三层级:由Sonnet进行语义判断
return tier3_check(snapshot)
路由器的逻辑结构非常清晰、易于理解。每一个决策点都对应着一个明确的条件。当tiered=False时,其行为与v1版本完全相同——这种向后兼容的设计使得你可以逐步添加新的功能,而不会影响现有的审核流程。
优雅的回退机制
回退机制在第二层级和第三层级中都会被使用。有必要明确说明这一机制的具体实现方式:
# 在tier2_check()和tier3_check()函数中都会使用这种回退机制
except Exception as exc:
logger.warning("[tierN] API错误:%s — 将回退到第一层级的处理结果", exc)
fallback = tier1_check(snapshot)
fallback["method"] = "tierN-fallback"
return fallback
这种回退机制具有以下三个作用:
-
记录错误信息,并提供足够的上下文以便后续进行调试。
-
一定会返回一个有效的结果——因为第一层级的检查操作总是会被执行。
-
会在结果中标记出使用的是哪种回退机制,这样你就可以在报告中对这些结果进行区分了。
如果一个代理在遇到API错误时会崩溃,那么它显然还不适合投入生产环境;而那些能够在出现错误时依然正常运行、且性能不会下降的代理,才真正具备上线条件。
测试成本曲线
创建文件test_cost_curve.py,以便在不进行实际API调用的情况下验证路由行为:
import json
from unittest import mock
from cost_curve import audit_url, tier1_check
def make_snapshot(title="正常标题,长度小于60个字符",
description="一条描述性元描述,长度不超过160个字符,且能准确反映页面内容。",
h1s=["单个H1标题"],
canonical="https://example.com/page",
status_code=200,
final_url="https://example.com/page"):
return {
"title": title,
"meta_description": description,
"h1s": h1s,
"canonical": canonical,
"status_code": status_code,
"final_url": final_url,
}
def test_clean_page_returns_tier1_no_api_calls():
"""测试干净页面的情况:所有检查都会以确定性的方式通过,且不会进行任何API调用。」
snapshot = make_snapshot()
with mock.patch("anthropic.Anthropic") as mock_client:
result = audit_url(snapshot, tiered=True)
assert result["method"] == "deterministic"
mock_client.assert_not_called()
print("测试通过:干净页面 → 第1级评估,未进行任何API调用")
def test_long_title_returns_tier1_fail_no_api_call():
"""测试标题长度超过60个字符的情况:会失败,无法通过第1级评估,且不会进行任何API调用。」
snapshot = make_snapshot(title="A" * 70)
with mock.patch("anthropic.Anthropic") as mock_client:
result = audit_url(snapshot, tiered=True)
assert result["method"] == "deterministic"
assert result["title"]["status"] == "FAIL"
mock_client.assert_not_called()
print("测试通过:标题长度超过60个字符 → 第1级评估失败,未进行任何API调用")
def test_suspiciously_short_title_escalates_to_tier2():
"""测试标题长度过短的情况:系统会将其升级到第2级评估。」
snapshot = make_snapshot(title="SEO") # 标题长度仅为3个字符,低于最低要求
mock_response = mock.MagicMock()
mock_response.content = [mockMagicMock(
text='{"needs_tier3": false, "reason": "标题太短,但并不模糊"}'
)}
with mock.patch("anthropic.Anthropic") as mock_client:
mock_client.return_value.messages.create(return_value = mock_response
result = audit_url(snapshot, tiered=True)
assert result["method"] == "haiku"
assert mock_client.return_value.messages.create.call_count == 1
print("测试通过:标题长度过短 → 系统升级到第2级评估,且调用了一次‘haiku’函数")
def test_tiered_false_calls_sonnet_directly():
"""当tiered参数设置为False时,无论 snapshot的内容如何,系统都会直接使用‘sonnet’方法进行评估。」
snapshot = make_snapshot() # 这是一个干净页面,在启用分级评估的情况下应该属于第1级
mock_response = mock.MagicMock()
mock_response.content = [mockMagicMock(text=json.dumps({
"url": "https://example.com/page",
"final_url": "https://example.com/page",
"status_code": 200,
"title": {"value": "正常标题,长度小于60个字符", "length": 27, "status": "PASS"},
"description": {"value": "描述性元描述,长度为4个字符,状态为PASS},
"h1": {"count": 1, "value": "单个H1标题", "status": "PASS"},
"canonical": {"value": "https://example.com/page", "status": "PASS"},
"flags": [],
"human_review": False,
"audited_at": "2026-04-01T00:00:00+00:00",
}))]
with mock.patch("anthropic.Anthropic") as mock_client:
mock_client.return_value.messages.create(return_value = mock_response
result = audit_url(snapshot, tiered=False)
assert result["method"] == "sonnet"
assert mock_client.return_value.messages.create.call_count == 1
print("测试通过:当tiered参数设置为False时,系统会直接使用‘sonnet’方法进行评估")
def test_haiku_api_failure_falls_back_to_tier1():
"""如果‘haiku’方法的评估失败,系统会回退到第1级评估结果,且不会发生崩溃。」
snapshot = make_snapshot(title="SEO") # 这个标题会导致系统进入第2级评估
with mock.patch("anthropic.Anthropic") as mock_client:
mock_client.return_value.messages.create.side_effect = Exception("速率限制")
result = audit_url(snapshot, tiered=True)
assert result["method"] == "haiku-fallback"
print("测试通过:如果‘haiku’方法失败,系统会回退到第1级评估,且不会发生崩溃")
if __name__ == "__main__":
test_clean_page_returns_tier1_no_api_calls()
test_long_titleозвращает_tier1_fail_no_api_call()
test_suspiciously_short_title_escalates_to_tier2()
test_tiered_false_calls_sonnet_directly()
test_haiku_api_failure_falls_back_to_tier1()
print("\n所有测试均通过。")
运行方法:
python test_cost_curve.py
预期输出结果:
PASS: 清空页面时使用第一级处理方式,未调用任何API接口
PASS: 当标题长度超过60个字符时使用第一级处理方式,未调用任何API接口
PASS: 使用简短标题时使用第二级处理方式,此时会调用一次“Haiku”算法
PASS: 当不启用分层处理机制时,直接使用“Sonnet”算法
PASS> 当“Haiku”算法失败时,会回退到第一级处理方式,且系统不会崩溃
将这一模式应用到你的智能助手中
这种成本划分机制并不专门针对SEO任务设计,任何需要处理具有不同复杂度任务的智能助手都可以使用它。
其核心原则就是:在决定使用哪种算法之前,先根据任务的实际需求对它们进行分类。
客户支持智能助手:
-
第一级:对于常见的常见问题,直接通过关键词匹配来处理,无需使用任何算法
-
第二级:对于那些含义模糊的查询,使用“Haiku”算法来进行意图分类
-
第三级:对于需要人工判断的复杂投诉,使用“Sonnet”算法进行处理
代码审核智能助手:
-
第一级:通过编写代码规则或进行语法检查来处理问题,无需使用任何算法
-
第二级:使用“Haiku”算法来检测常见的代码模式错误
-
第三级:对于复杂的架构审核任务,使用“Sonnet”算法进行处理
内容审核智能助手:
-
第一级:通过检查是否在黑名单中来确定如何处理某些内容,无需使用任何算法
-
第二级:对于那些边界模糊的情况,使用“Haiku”算法来进行判断
-
第三级:对于那些需要根据具体背景来做出判断的任务,使用“Sonnet”算法进行处理
在这三种情况下,实现机制都是相同的。原本用于路由的`audit_url()`函数被替换成了`route_task()`函数,而不同级别的处理方式所使用的提示语和升级条件也会相应地发生变化,但回退逻辑始终保持不变。
在编写任何智能助手的代码之前,关键是要先思考这样一个问题:我的输入中,有多少部分是可以通过机械化的方法来处理的?这些部分应该被归入第一级处理范围;其余的部分则需要进入更高级别的处理流程。而“成本划分机制”正好能够用来引导这些任务的分配过程。
总结
完整的实现代码——包括那些在实际生产环境中使用这一模块的SEO审核智能助手——可以在dannwaneri/seo-agent这个仓库中找到。其中`core/`目录采用的是MIT许可证;而与分层路由相关的代码则保存在`premium/cost_curve.py`文件中。
本教程是与DEV.to上发布的文章《在我意识到真正需要的方案之前,我曾经为每次SEO审核支付0.006美元》相配套的内容,该文章详细解释了“成本划分机制”背后的设计思路。