【Agent开发第十三期】多模型路由:给 Agent 装一个“调度台“,实测省 86% 成本

文章目录

一、前言:一个模型打天下,月底账单吓一跳

写到这里,"从零搭建 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 节。

六、总结

核心要点

  1. 路由 = 调用和模型之间的决策层:manual 钉住 > 后台固定 light > auto 规则(关键词 + 长度),每个决策带 reason,可解释可插手
  2. llm_call 是唯一入口:main / compact / subagent / team 四处调用点全部收编------想管住所有调用,先让所有调用走同一条路
  3. 后台任务不路由是设计不是偷懒:受限小任务用不上旗舰推理力,固定 light 档,路由策略本身就是省钱策略
  4. 成本 telemetry:token × 注册表单价落盘 usage_log.jsonl,按模型/任务记账------知道钱花在哪,才知道该给哪类任务配什么档
  5. 供应商解耦:单价和端点名都是注册表里的数据,教学模拟换生产端点,主逻辑一行不改

一行代码记住本期

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 真正"自主"起来。

感谢各位看官的一路陪伴,大家都再接再厉!

相关推荐
康实训1 小时前
智慧养老实训室|职业院校数字化实训基地
大数据·人工智能·实训室·实训室建设
一次旅行2 小时前
DeepSeek‑V4.1‑Flash‑1017完整解读:模型升级+Search/Fetch搜索API开放,Agent开发者要注意哪些坑
前端·网络·人工智能
Jsundoku2 小时前
第三章 机器学习 -- 特征工程
人工智能·机器学习
GIS数据转换器4 小时前
地下空间综合管廊运营管理平台
大数据·开发语言·人工智能·经验分享·perl
dozenyaoyida9 小时前
AI与大模型新闻日报 | 2026-10-17
人工智能·ai·chatgpt·新闻
遇码10 小时前
流式调用中途断连别直接报错:瞬态故障识别、退避重试与中文根因诊断的搭建方法
大数据·人工智能·ai·rust·数据分析·大模型·agent
救救孩子把10 小时前
AI面试八股文-21_21-多模态Agent面试题-Vision-Language-Agent
人工智能·面试·职场和发展·llava·多模态agent
今年下半年10 小时前
AI智能体项目实战记录(02):初始化 Python 项目
开发语言·人工智能·python·fastapi