文章目录
-
- 一、前言:一个模型打天下,月底账单吓一跳
- 二、概念对齐:路由到底解决什么问题
-
- [2.1 没有路由的世界](#2.1 没有路由的世界)
- [2.2 路由的答案:调用之前问一句"这活儿该给谁"](#2.2 路由的答案:调用之前问一句"这活儿该给谁")
- [2.3 三层结构:注册表、路由器、挂载点](#2.3 三层结构:注册表、路由器、挂载点)
- [2.4 一个关键澄清:教学环境的"模拟"](#2.4 一个关键澄清:教学环境的"模拟")
- 三、动手做:徒手写一个调度台
-
- [3.1 注册表:模型池的价目表](#3.1 注册表:模型池的价目表)
- [3.2 路由器:两条规则,全部可解释](#3.2 路由器:两条规则,全部可解释)
- [3.3 挂载点:llm_call,一处收编,处处路由](#3.3 挂载点:llm_call,一处收编,处处路由)
- [3.4 记账器:每次调用一张小票](#3.4 记账器:每次调用一张小票)
- [3.5 三个新工具:让 Alex 自己会调度](#3.5 三个新工具:让 Alex 自己会调度)
- 四、跑起来:亲眼看路由在派单
-
- [演示 1:简单任务,派给便宜档](#演示 1:简单任务,派给便宜档)
- [演示 2:复杂任务,自动上旗舰 + 汇报账单](#演示 2:复杂任务,自动上旗舰 + 汇报账单)
- 亲手试试"钉住再恢复"
- 原理小结:成本面板的账
- 五、执行脚本(本期新增完整代码)
- 六、总结
一、前言:一个模型打天下,月底账单吓一跳
写到这里,"从零搭建 AI 助手"系列走到第 13 期。Alex 现在的样子:有记忆、有人设、有技能库、会规划、能派子代理、组了团队、接了 MCP、上一期还装上了 Hooks 安检------25 个工具,每次调用都有审计。
但作为一个"持续迭代"的项目,还有一块一直没人管:钱。
回看代码,一个有点扎心的事实:每次 LLM 调用都写死了同一个模型。你问"现在几点",发给 deepseek-chat;你说"分析一下这个架构的利弊",还是发给 deepseek-chat。更要命的是第 07 期的 compact 压缩、第 09 期的子代理、第 10 期的队友------这些"后台杂活"也全用同一个模型,一次不多、一次不少,按同一个价计费。
这在 toy project 里不算什么。但真实世界的 Agent 不是这么运转的:查时间的活儿,给个便宜快的小模型绰绰有余;做架构评审的活儿,才配得上旗舰推理模型。用一个模型打天下,等于让商务舱司机去跑滴滴,还按商务舱报销。
本文是系列第 13 期,我们徒手给 Agent 装一个"调度台":一个模型注册表(价目表)、一个路由器(按任务特征派单)、一个记账器(每次调用算成本落盘)。你会看到:
- 路由器怎么"看"任务:关键词 + 长度,两条简单可解释的规则
- 为什么所有 LLM 调用必须收编进
llm_call一个入口 - 后台任务为什么不路由,而是固定便宜档
- 成本 telemetry 实测:12 次调用 0.037,若全用最贵模型 0.269,省 86%
老规矩,本文结构:概念对齐 → 动手写代码 → 跑起来看效果 → 完整代码 → 总结。系列累积式迭代:step13 = step12 + 多模型路由。

二、概念对齐:路由到底解决什么问题
2.1 没有路由的世界
| 痛点 | 具体表现 |
|---|---|
| 简单任务烧贵钱 | "现在几点"和"审查这段代码"用同一个模型、同一个价 |
| 后台任务不明智 | compact 压缩旧对话、子代理小任务,也走主力模型 |
| 换供应商 = 大手术 | model=MODEL 散落四处,想换 Claude 得满屏改 |
| 用户插不上手 | 想指定"这题用强模型做"?只能改配置重启 |
| 账单是笔糊涂账 | 月底只知道花了多少,不知道花在哪、值不值 |
本质问题一句话:Agent 已经有了"能力分级"(工具/子代理/团队),但"思考"还是一口价大锅饭。
2.2 路由的答案:调用之前问一句"这活儿该给谁"
| 维度 | 单模型写死(第 12 期) | 多模型路由(本期) |
|---|---|---|
| 模型选择 | 全部调用 model=MODEL | 路由器按任务特征自动选档 |
| 简单任务成本 | 烧主力模型的钱 | light 档,实测省 86% |
| 后台任务 | 同一个贵模型 | compact/子代理/队友固定 light 档 |
| 换供应商 | 满屏 model=XXX | 注册表加一行 + 改 base_url |
| 用户控制 | 改环境变量重启 | 聊天里说"用 claude-sonnet"即可 |
| 成本可见性 | 月底一笔总账 | usage_log.jsonl 按模型/任务记账 |
最贴切的类比是出租车调度台:车队(模型池)在注册表里明码标价,派单规则(路由策略)在调度员手里------接机这种短途派经济型,跨城商务派商务舱;跑完每单,计价器(记账器)都打一张小票进台账。乘客(用户)随时可以指定车型,但默认情况让调度台拍板。
2.3 三层结构:注册表、路由器、挂载点
和第 12 期 Hooks 一脉相承的"三层"设计:
| 层 | 组件 | 职责 |
|---|---|---|
| 注册表层 | MODEL_REGISTRY | 模型池价目表:档位/单价/强项/真实端点名 |
| 路由层 | route_model / resolve_model | 派单:manual 钉住 > 后台固定 light > auto 规则 |
| 挂载点 | llm_call | 所有 LLM 调用的唯一入口:路由在前,记账在后 |
连续三期其实在做同一件事------把散落的直接调用收编成唯一入口 :第 11 期 MCP 把外部工具收进 execute_tool,第 12 期 Hooks 在 execute_tool 前后设卡,本期 llm_call 把四处直接 client.chat.completions.create 的地方(main 工具循环 / compact 压缩 / 子代理 / 队友)全部收编。位置决定职责,这就是挂载哲学。
2.4 一个关键澄清:教学环境的"模拟"
只有一个 DeepSeek API key,怎么演示 Claude 和本地模型?答案:路由和计费机制先行,端点映射教学模拟 。注册表里 claude-sonnet、local-qwen 都有自己的档位和单价,但 api_model 字段映射到 DeepSeek 端点------按注册表单价计费,跑的是真实 DeepSeek。生产环境接入真实供应商,只需给 claude-sonnet 换 Anthropic base_url、给 local-qwen 换 Ollama base_url,注册表和主逻辑一行不改。这正是"路由层抽象供应商差异"的意义。
三、动手做:徒手写一个调度台
3.1 注册表:模型池的价目表
python
MODEL_REGISTRY: dict[str, dict] = {
"deepseek-chat": {
"tier": "light", # 轻量档:便宜、快
"input_price": 0.27, # $/百万输入 token
"output_price": 1.10, # $/百万输出 token
"strengths": "便宜、快。适合日常问答、简单工具调用、后台任务",
"api_model": "deepseek-chat", # 真实端点模型名
},
"deepseek-reasoner": {
"tier": "heavy", # 旗舰档:强、贵
"input_price": 0.55,
"output_price": 2.19,
"strengths": "深度推理(思维链)。适合代码审查、复杂分析、技术决策",
"api_model": "deepseek-reasoner",
},
"claude-sonnet": { "tier": "heavy", "input_price": 3.00, "output_price": 15.00, ... },
"local-qwen": { "tier": "light", "input_price": 0.0, "output_price": 0.0, ... },
}
TIER_DEFAULT = {"light": "deepseek-chat", "heavy": "deepseek-reasoner"}
BACKGROUND_MODEL = "deepseek-chat" # 后台任务固定走 light 档
注意两个设计:单价是数据不是代码 ------换模型、调价只改注册表,路由和记账逻辑零改动;档位(light/heavy)是逻辑概念------同一个档位可以挂多个模型,路由器只认档位,具体用谁由 TIER_DEFAULT 决定。
3.2 路由器:两条规则,全部可解释
python
HEAVY_KEYWORDS = [
"审查", "分析", "设计", "规划", "重构", "对比", "评估",
"排查", "debug", "架构", "优化", "决策", "长文", "报告",
]
HEAVY_LENGTH_THRESHOLD = 400 # 判定"长消息"的字符阈值
def route_model(messages: list[dict]) -> tuple[str, str]:
"""自动路由:按'最后一条用户消息'的任务特征选模型。"""
user_texts = [m["content"] for m in messages if m["role"] == "user" and m.get("content")]
text = user_texts[-1] if user_texts else ""
hits = [k for k in HEAVY_KEYWORDS if k in text.lower()]
if hits:
return TIER_DEFAULT["heavy"], f"命中复杂关键词 {hits[:3]}"
if len(text) > HEAVY_LENGTH_THRESHOLD:
return TIER_DEFAULT["heavy"], f"消息超长({len(text)} 字符 > {HEAVY_LENGTH_THRESHOLD})"
return TIER_DEFAULT["light"], "简单任务(无复杂关键词,消息不长)"
为什么用关键词 + 长度这种"土办法"?教学版追求可解释、可动手 :每个决策带 reason 记入路由历史,你随时能问"它为什么选这个模型";想加规则(比如"英文消息走另一个模型"),列表里加一个词就行。生产框架里路由器会用 embedding 或小模型打分,但思路完全一样------从消息特征预测任务难度,再映射到档位。
外面再包一层 resolve_model,处理两条特殊路径:
python
def resolve_model(messages, task="main"):
if task != "main":
# 后台任务(compact/子代理/队友):固定走便宜档
return BACKGROUND_MODEL, f"后台任务({task})固定走 light 档省钱"
if ROUTER_STATE["mode"] == "manual" and ROUTER_STATE["pinned_model"]:
return ROUTER_STATE["pinned_model"], f"manual 模式:钉住 {ROUTER_STATE['pinned_model']}"
return route_model(messages)
优先级:manual 钉住 > 后台固定 light > auto 规则 。后台任务为什么不给路由器自主决策?因为 compact 压缩、子代理小任务都是受限小任务 ------再强的推理力也用不上,旗舰模型纯属浪费。固定便宜档,路由策略本身就是省钱策略。
3.3 挂载点:llm_call,一处收编,处处路由
本期最核心的改动------所有 LLM 调用的统一入口:
python
def llm_call(messages, task="main", tools=None, max_tokens=4096):
"""所有 LLM 调用的统一入口 = 路由 → 调用 → 记账。"""
# ① 路由:决定这次调用用哪个模型
model_id, reason = resolve_model(messages, task=task)
_record_route(model_id, reason, task) # 打印 + 路由历史
api_model = MODEL_REGISTRY[model_id]["api_model"] # 逻辑名 → 端点名
# ② 调用:按注册表映射发往真实端点
kwargs = {"model": api_model, "messages": messages, "max_tokens": max_tokens}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
response = client.chat.completions.create(**kwargs)
# ③ 记账:成本 telemetry(token × 单价 → 美元)
log_usage(model_id, response.usage.prompt_tokens,
response.usage.completion_tokens, task)
return response, model_id
然后把第 12 期四处直接调用全部换掉:
python
# 主循环(原):response = client.chat.completions.create(model=MODEL, ...)
response, model_id = llm_call(messages, task="main", tools=TOOLS)
# compact 压缩(原):resp = client.chat.completions.create(...)
resp, _ = llm_call(compact_messages, task="compact", max_tokens=600)
# 子代理(原):response = client.chat.completions.create(...)
response, _ = llm_call(sub_messages, task="subagent", tools=sub_tools, max_tokens=2048)
# 队友(原):response = client.chat.completions.create(...)
response, _ = llm_call(config["messages"], task="team", tools=sub_tools, max_tokens=2048)
四处调用点,一个 task 标签区分身份:main 走 auto 规则,其余三个固定 light 档。一处收编,处处路由------这和第 12 期"钩子挂在工具循环里"是同一个哲学:想管住所有调用,先让所有调用走同一条路。
3.4 记账器:每次调用一张小票
python
def log_usage(model_id, prompt_tokens, completion_tokens, task="main") -> dict:
"""每次 LLM 调用记账:token 数 × 注册表单价 → 美元成本。"""
cfg = MODEL_REGISTRY[model_id]
cost = (prompt_tokens / 1e6 * cfg["input_price"]
+ completion_tokens / 1e6 * cfg["output_price"])
entry = {
"ts": datetime.now().isoformat(timespec="seconds"),
"model": model_id, "tier": cfg["tier"], "task": task,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"cost_usd": round(cost, 8),
}
with USAGE_LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
return entry
成本公式就是价目表的自然语言翻译:cost = prompt_tokens/1e6 × input_price + completion_tokens/1e6 × output_price。单价来自注册表,所以记账器对"模型换了、价格变了"完全无感------它只管乘法。
3.5 三个新工具:让 Alex 自己会调度
python
list_models() # 查看模型池:档位/单价/强项 + 当前路由状态
switch_model(mode="manual", model_id="claude-sonnet", task="接下来的写作任务")
switch_model(mode="auto") # 恢复自动路由
cost_report() # 成本报表:各模型 token 与费用 + 假想最贵账单对比
为什么 LLM 自己需要这些工具?当用户说"这题用强模型做",Alex 能自己调 switch_model 钉住旗舰档,做完再切回 auto------模型选择从"用户改配置"升级成"Agent 可操作的技能"。cost_report 更妙:报表里有一行"假想账单"------同样的 token 全用注册表里最贵的模型要花多少、路由替你省了多少,数据自己会说话。
四、跑起来:亲眼看路由在派单
启动 python code/step13_model_router.py,面板上多了模型池和路由模式区块,然后两段自动演示开跑。
演示 1:简单任务,派给便宜档
第一段演示让 Alex 处理一个简单问题:
[路由] 简单任务(无复杂关键词,消息不长) → deepseek-chat(light档, $0.27/$1.10 每百万token)
[Alex] 现在是 ...
看仔细:没有关键词、消息不长,路由器把活派给了 light 档的 deepseek-chat------一次普通的日常问答,花的每一分钱都是"经济舱"价。
演示 2:复杂任务,自动上旗舰 + 汇报账单
第二段演示让 Alex 分析对比两个方案:
[路由] 命中复杂关键词 ['分析'] → deepseek-reasoner(heavy档, $0.55/$2.19 每百万token)
[工具调用] cost_report
[Alex] 本次会话成本统计:实际花费 $0.037;如果这些调用全部使用最贵的
claude-sonnet,大约需要 $0.269------多模型路由为您节省了约 86% 的成本...
同一个会话,两档模型:简单问题走 deepseek-chat,复杂分析自动跳 deepseek-reasoner,然后 Alex 自己调 cost_report 工具向用户汇报账单------成本可视化不是给程序员看的日志,是 Agent 能拿出来的报表。
亲手试试"钉住再恢复"
聊天里输入两句话:
- "接下来用 claude-sonnet 回答" → Alex 调 switch_model 钉住 heavy 贵档,之后每轮路由面板显示 manual 模式
- "做完切回自动路由" → 调 switch_model(mode="auto"),恢复按任务派单
注意一个细节:manual 钉住期间,compact 压缩、子代理这些后台调用仍然走 light 档------用户钉的是"主对话",后台杂活不听这一套。优先级设计在真实场景里的价值就在这:你愿意为主对话付旗舰价,不代表该为压缩旧对话付旗舰价。
原理小结:成本面板的账
┌────────────────────────────────────────────────┐
│ 💰 Alex 成本面板(第 13 期新增) │
│ 总调用: 12 次 │
│ deepseek-chat: 8 次 $0.031 ← light 档 │
│ deepseek-reasoner: 4 次 $0.006 ← heavy 档 │
│ 实际总成本: $0.037 │
│ 假想全用 claude-sonnet: $0.269 │
│ 路由省下: $0.231(约 86%) │
└────────────────────────────────────────────────┘
注意省钱怎么来的:light 档不只便宜 2 倍,是 12 次调用里 8 次都走了它 ------高频任务命中便宜档,低频硬骨头才上旗舰,组合拳才是省 86% 的原因。而"哪类任务高频、哪类烧钱"这个结论,是 usage_log.jsonl 一笔笔记账攒出来的:省钱是路由策略带来的,结论是 telemetry 数据告诉你的。
五、执行脚本(本期新增完整代码)
完整项目在 GitHub(文末),step13_model_router.py = step12 全部代码 + 这个调度台。注册表 + 路由器 + llm_call + 记账器 + 三个新工具的核心代码:
python
#!/usr/bin/env python3
# ↑ step13_model_router.py 第 7e 节(多模型路由层,本期核心新增)
# ---- ① 注册表:模型池价目表(单价是数据,不是代码) ----
MODEL_REGISTRY: dict[str, dict] = {
"deepseek-chat": {"tier": "light", "input_price": 0.27, "output_price": 1.10,
"api_model": "deepseek-chat", ...},
"deepseek-reasoner": {"tier": "heavy", "input_price": 0.55, "output_price": 2.19,
"api_model": "deepseek-reasoner", ...},
"claude-sonnet": {"tier": "heavy", "input_price": 3.00, "output_price": 15.00,
"api_model": "deepseek-chat", # 教学模拟;生产换 Anthropic
...},
"local-qwen": {"tier": "light", "input_price": 0.0, "output_price": 0.0,
"api_model": "deepseek-chat", # 教学模拟;生产换 Ollama
...},
}
TIER_DEFAULT = {"light": "deepseek-chat", "heavy": "deepseek-reasoner"}
BACKGROUND_MODEL = "deepseek-chat"
ROUTER_STATE = {"mode": "auto", "pinned_model": None,
"current_model": None, "history": []}
HEAVY_KEYWORDS = ["审查", "分析", "设计", "规划", "重构", "对比", "评估",
"排查", "debug", "架构", "优化", "决策", "长文", "报告"]
HEAVY_LENGTH_THRESHOLD = 400
# ---- ② 路由器:auto 规则 / manual 钉住 / 后台固定 ----
def route_model(messages):
user_texts = [m["content"] for m in messages
if m["role"] == "user" and m.get("content")]
text = user_texts[-1] if user_texts else ""
hits = [k for k in HEAVY_KEYWORDS if k in text.lower()]
if hits:
return TIER_DEFAULT["heavy"], f"命中复杂关键词 {hits[:3]}"
if len(text) > HEAVY_LENGTH_THRESHOLD:
return TIER_DEFAULT["heavy"], f"消息超长({len(text)} 字符 > {HEAVY_LENGTH_THRESHOLD})"
return TIER_DEFAULT["light"], "简单任务(无复杂关键词,消息不长)"
def resolve_model(messages, task="main"):
if task != "main": # 后台任务:受限小任务,固定便宜档
return BACKGROUND_MODEL, f"后台任务({task})固定走 light 档省钱"
if ROUTER_STATE["mode"] == "manual" and ROUTER_STATE["pinned_model"]:
return ROUTER_STATE["pinned_model"], f"manual 模式:钉住 {ROUTER_STATE['pinned_model']}"
return route_model(messages)
# ---- ③ 挂载点:所有 LLM 调用的统一入口 ----
def llm_call(messages, task="main", tools=None, max_tokens=4096):
model_id, reason = resolve_model(messages, task=task) # ① 路由
_record_route(model_id, reason, task)
api_model = MODEL_REGISTRY[model_id]["api_model"] # 逻辑名 → 端点名
kwargs = {"model": api_model, "messages": messages, "max_tokens": max_tokens}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
response = client.chat.completions.create(**kwargs) # ② 调用
log_usage(model_id, response.usage.prompt_tokens, # ③ 记账
response.usage.completion_tokens, task)
return response, model_id
# ---- ④ 记账器:token × 单价 → usage_log.jsonl ----
def log_usage(model_id, prompt_tokens, completion_tokens, task="main"):
cfg = MODEL_REGISTRY[model_id]
cost = (prompt_tokens / 1e6 * cfg["input_price"]
+ completion_tokens / 1e6 * cfg["output_price"])
entry = {"ts": datetime.now().isoformat(timespec="seconds"),
"model": model_id, "tier": cfg["tier"], "task": task,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"cost_usd": round(cost, 8)}
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
with USAGE_LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
return entry
# ---- ⑤ 三个新工具(模型选择成为 Agent 技能) ----
def list_models(): ... # 模型池 + 路由状态
def switch_model(mode, model_id=None, task=None): ...
# auto 恢复 / manual+model_id 钉住
def cost_report(): ... # 按模型/任务聚合 + 假想最贵账单对比
四个调用点的收编替换、三个工具的 JSON schema、models / cost 命令、两个面板、两段自动演示的完整代码,见仓库 code/step13_model_router.py 第 7e 节和第 8-10 节。
六、总结
核心要点
- 路由 = 调用和模型之间的决策层:manual 钉住 > 后台固定 light > auto 规则(关键词 + 长度),每个决策带 reason,可解释可插手
- llm_call 是唯一入口:main / compact / subagent / team 四处调用点全部收编------想管住所有调用,先让所有调用走同一条路
- 后台任务不路由是设计不是偷懒:受限小任务用不上旗舰推理力,固定 light 档,路由策略本身就是省钱策略
- 成本 telemetry:token × 注册表单价落盘 usage_log.jsonl,按模型/任务记账------知道钱花在哪,才知道该给哪类任务配什么档
- 供应商解耦:单价和端点名都是注册表里的数据,教学模拟换生产端点,主逻辑一行不改
一行代码记住本期
python
def llm_call(messages, task="main", tools=None, max_tokens=4096):
model_id, reason = resolve_model(messages, task=task) # ① 路由:选模型
response = client.chat.completions.create( # ② 调用:发真实端点
model=MODEL_REGISTRY[model_id]["api_model"],
messages=messages, max_tokens=max_tokens, ...)
log_usage(model_id, response.usage.prompt_tokens, # ③ 记账:算成本
response.usage.completion_tokens, task)
return response, model_id
适用场景
- Agent 服务多类任务:高频简单问答 + 低频深度分析,成本敏感
- 需要跨供应商(DeepSeek/Claude/本地 Ollama)混用,不想被一家锁死
- 团队/项目需要成本归因:哪个功能烧 token、哪个任务该升档,数据说话
- 想让用户(或 Agent 自己)动态控制"这题用什么模型"
下期预告
第 14 期我们讲目标驱动 Agent:给一个模糊目标"帮我优化这个仓库的 README",Agent 自己拆解、自己跑控制循环、自己验收------goal contract + checkpoint,让 Agent 拿着一个目标自己跑到终点。多模型路由管住了每次"思考"的成本,目标驱动让 Agent 真正"自主"起来。
感谢各位看官的一路陪伴,大家都再接再厉!