Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

模型抽象与 Provider 兼容层

本章核心源码run_agent.py(559-581 API 模式检测)、agent/model_metadata.pyagent/usage_pricing.py

定位:本章拆解 Hermes 如何用一套编排逻辑适配 Anthropic、OpenAI、DeepSeek、本地 Ollama 等数十个 LLM provider——从 API 模式自动检测到上下文窗口探测到成本追踪。 前置依赖:第 4 章(AIAgent 初始化阶段 2/4)。适用场景:想理解 Hermes 如何做到"换个 model 字段就能切 provider",或需要接入新的 LLM provider。

为什么模型抽象是 Run Anywhere 的基础

"Run Anywhere"不只是指运行在不同的操作系统上——它同样意味着运行在不同的 LLM provider 上。Hermes 的用户群体横跨:

  • Anthropic API 直连用户(需要 prompt caching、extended thinking)
  • OpenAI API 用户(需要 Responses API、reasoning tokens)
  • OpenRouter 中转用户(需要统一计费、多模型切换)
  • 本地模型用户(Ollama、LM Studio、vLLM、llama.cpp)
  • 国产模型用户(DeepSeek、DashScope/Qwen、GLM、Kimi、MiniMax)
  • 企业平台用户(GitHub Copilot、Hugging Face Inference)

每个 provider 的 API 格式、认证方式、上下文限制、定价模型都不同。Hermes 需要在编排层之下建立一个兼容层,让 AIAgent.run_conversation() 不需要知道底层是 Claude 还是 GPT 还是 Qwen。

三种 API 模式

Hermes 将所有 LLM provider 归类为三种 API 模式(run_agent.py:559-581):

graph LR
    subgraph "API Mode Detection"
        INPUT["model + provider + base_url"] --> CHECK{"检测逻辑<br/>run_agent.py:559-581"}
        CHECK -->|"provider == anthropic<br/>或 URL 含 api.anthropic.com"| AM["anthropic_messages"]
        CHECK -->|"provider == openai-codex<br/>或直连 OpenAI"| CR["codex_responses"]
        CHECK -->|"其他所有情况"| CC["chat_completions"]
    end

    AM --> SDK_A["Anthropic SDK<br/>Messages API"]
    CR --> SDK_O["OpenAI SDK<br/>Responses API"]
    CC --> SDK_C["OpenAI SDK<br/>Chat Completions API"]

    style CC fill:#9cf,stroke:#333,stroke-width:2px

检测逻辑详解

# run_agent.py:559-581(简化)
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
    self.api_mode = api_mode                         # 显式指定优先
elif self.provider == "openai-codex":
    self.api_mode = "codex_responses"
elif self.provider == "anthropic" or "api.anthropic.com" in base_url:
    self.api_mode = "anthropic_messages"
elif base_url.rstrip("/").endswith("/anthropic"):
    self.api_mode = "anthropic_messages"             # MiniMax、DashScope 的 Anthropic 兼容端点
else:
    self.api_mode = "chat_completions"               # 默认

# 直连 OpenAI 时自动切换到 Responses API
if self.api_mode == "chat_completions" and self._is_direct_openai_url():
    self.api_mode = "codex_responses"                # run_agent.py:580-581
API 模式使用场景选择原因
chat_completionsOpenRouter、大多数第三方、本地模型最广泛兼容的标准,几乎所有 provider 都支持
anthropic_messagesAnthropic 直连、MiniMax /anthropic 端点、DashScope /anthropic 端点原生支持 prompt caching(cache_control blocks)、extended thinking
codex_responsesOpenAI 直连GPT-5.x 的 tool calling + reasoning 需要 Responses API

一个有趣的细节:URL 以 /anthropic 结尾的第三方端点(run_agent.py:569-573)被自动识别为 Anthropic 兼容模式。这是因为 MiniMax 和 DashScope 等国产 provider 提供了 Anthropic Messages API 兼容端点,Hermes 通过 URL 约定自动适配。

检测的时序约束

API 模式检测必须在 LLM 客户端构造之前完成(第 4 章的阶段 2 在阶段 4 之前),因为不同模式需要不同的 SDK 客户端:

  • anthropic_messagesbuild_anthropic_client() 构造 Anthropic SDK 客户端
  • chat_completions / codex_responses → 构造 OpenAI SDK 客户端

Provider 路由

当用户没有显式指定 provider 和 base_url 时,Hermes 通过 resolve_provider_client() 自动路由:

# run_agent.py:761-763(简化)
from agent.auxiliary_client import resolve_provider_client
_routed_client, _ = resolve_provider_client(
    self.provider or "auto", model=self.model
)

这里要区分两层职责:

  • 主路由器agent/auxiliary_client.pyresolve_provider_client(),负责根据 provider、model、base_url、认证方式实际构造客户端
  • agent/model_metadata.py 里的 _URL_TO_PROVIDER 只是辅助表,用于在模型元数据解析时根据 base URL 推断 provider

后者不是“真正决定客户端怎么建”的主路由器,它更像是元数据层的补充信号:

# agent/model_metadata.py:180-197
_URL_TO_PROVIDER: Dict[str, str] = {
    "api.openai.com": "openai",
    "api.anthropic.com": "anthropic",
    "api.z.ai": "zai",
    "api.moonshot.ai": "kimi-coding",
    "dashscope.aliyuncs.com": "alibaba",
    "openrouter.ai": "openrouter",
    "generativelanguage.googleapis.com": "gemini",
    "inference-api.nousresearch.com": "nous",
    "api.deepseek.com": "deepseek",
    # ... 共 16 个端点映射
}

上下文窗口探测

上下文窗口长度决定了 Hermes 的压缩策略、消息截断和 prompt 构建。但不同 provider 的同一个模型可能有不同的上下文限制(例如 Claude Opus 4.6 在 Anthropic 直连是 1M tokens,在 GitHub Copilot 只有 128K)。

get_model_context_length() 不是线性的“查 10 张表”,而是一条带分支的解析链model_metadata.py:838-965):

优先级来源代码行
0用户显式配置(config.yaml 的 context_length)860-861
1持久化缓存(context_length_cache.yaml)868-872
2活跃端点 /models API(仅真正的自定义未知端点)879-895
3自定义本地端点的直接查询与早期 fallback 分支897-909
4Anthropic /v1/models API911-917
5provider-aware 查找(先 Nous,再 models.dev)919-939
6OpenRouter 实时 API 元数据942-944
7硬编码默认值(DEFAULT_CONTEXT_LENGTHS)947-955
8本地端点的最后一次查询机会958-962
9默认 fallback(128K)965

最关键的顺序修正是第 5、6 步:provider-aware 查找先于 OpenRouter 的通用元数据。这正是 Hermes 用来处理“同一个模型在不同 provider 上上下文窗口不同”的关键细节。

本地服务器自动探测(model_metadata.py:258-314)

当 base_url 指向本地地址时,Hermes 通过探针请求自动识别服务器类型:

# agent/model_metadata.py:258-314(简化)
def detect_local_server_type(base_url: str) -> Optional[str]:
    # LM Studio: /api/v1/models
    # Ollama: /api/tags(检查返回体包含 "models" 键)
    # llama.cpp: /v1/props(检查 "default_generation_settings")
    # vLLM: /version

Ollama 的探测有一个细节:LM Studio 在 /api/tags 路径也返回 200 但内容不同({"error": "Unexpected endpoint"}),所以 Hermes 不仅检查状态码,还验证返回体包含 "models" 键(model_metadata.py:283-290)。

错误驱动的上下文探测(model_metadata.py:574-599)

当正常探测失败时,Hermes 还能从 API 错误消息中提取上下文限制:

# agent/model_metadata.py:574-598
def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
    patterns = [
        r'(?:max|limit)\s*(?:context\s*)?(?:length|size)?\s*(?:is|of|:)?\s*(\d{4,})',
        r'context\s*(?:length|size)\s*(?:is|of|:)?\s*(\d{4,})',
        r'(\d{4,})\s*(?:token)?\s*(?:context|limit)',
    ]

配合 CONTEXT_PROBE_TIERSmodel_metadata.py:73-79)的阶梯探测机制:

CONTEXT_PROBE_TIERS = [128_000, 64_000, 32_000, 16_000, 8_000]

当首次 API 调用因为上下文超限失败时,Hermes 从错误消息中解析出实际限制,或者按阶梯降级重试。发现的限制会被持久化到 context_length_cache.yamlmodel_metadata.py:538-556),后续请求直接使用缓存值。

Token 估算:粗略与精确

Hermes 使用两层 token 估算:

粗略估算(model_metadata.py:968-999)

# agent/model_metadata.py:968-972
def estimate_tokens_rough(text: str) -> int:
    """Rough token estimate (~4 chars/token) for pre-flight checks."""
    return len(text) // 4

4 chars/token 的粗略估算用于预检——在 API 调用之前快速判断消息是否会超过上下文窗口。这比调用 tokenizer 快几个数量级。estimate_request_tokens_rough() 还包含了 tool schema 的估算(model_metadata.py:981-999),因为 50+ 个工具的 schema 可以占用 20-30K tokens。

精确追踪(usage_pricing.py:420-478)

API 调用返回后,normalize_usage() 将三种 API 格式的 usage 统一为 CanonicalUsage

# agent/usage_pricing.py:420-478
def normalize_usage(response_usage, *, provider=None, api_mode=None) -> CanonicalUsage:
    if mode == "anthropic_messages" or provider_name == "anthropic":
        # Anthropic: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens
        ...
    elif mode == "codex_responses":
        # Codex: input_tokens 包含缓存; input_tokens_details.cached_tokens 分离
        ...
    else:
        # Chat Completions: prompt_tokens 包含缓存; prompt_tokens_details.cached_tokens 分离
        ...

三种 API 对缓存 token 的表示方式完全不同:

字段AnthropicCodex ResponsesChat Completions
输入 tokeninput_tokensinput_tokens(含缓存)prompt_tokens(含缓存)
缓存读取cache_read_input_tokensinput_tokens_details.cached_tokensprompt_tokens_details.cached_tokens
缓存写入cache_creation_input_tokensinput_tokens_details.cache_creation_tokensprompt_tokens_details.cache_write_tokens

CanonicalUsageusage_pricing.py:28-44)将这些差异统一为 5 个标准字段:input_tokensoutput_tokenscache_read_tokenscache_write_tokensreasoning_tokens

成本追踪

计费路由(usage_pricing.py:306-330)

resolve_billing_route() 将 model + provider + base_url 映射到计费路线:

# agent/usage_pricing.py:306-330
def resolve_billing_route(model_name, provider=None, base_url=None) -> BillingRoute:
    if provider_name == "openai-codex":
        return BillingRoute(..., billing_mode="subscription_included")  # 订阅制,零成本
    if "openrouter.ai" in base:
        return BillingRoute(..., billing_mode="official_models_api")    # 从 OpenRouter API 获取定价
    if provider_name == "anthropic":
        return BillingRoute(..., billing_mode="official_docs_snapshot")  # 使用硬编码快照
    if "localhost" in base:
        return BillingRoute(..., billing_mode="unknown")                # 本地模型,无法计费

多源定价查找(usage_pricing.py:390-417)

get_pricing_entry() 按优先级查找定价数据:

  1. 订阅制(如 GitHub Copilot):直接返回零成本
  2. OpenRouter API:从 /models 端点获取实时定价
  3. 自定义端点 /models:从端点元数据中提取定价
  4. 官方文档快照:使用硬编码的定价表

官方文档快照(usage_pricing.py:83-287)

_OFFICIAL_DOCS_PRICING 是一个精心维护的定价快照,覆盖了主流 provider 的 17 个模型:

# agent/usage_pricing.py:83-96
_OFFICIAL_DOCS_PRICING = {
    ("anthropic", "claude-opus-4-20250514"): PricingEntry(
        input_cost_per_million=Decimal("15.00"),
        output_cost_per_million=Decimal("75.00"),
        cache_read_cost_per_million=Decimal("1.50"),
        cache_write_cost_per_million=Decimal("18.75"),
        source="official_docs_snapshot",
    ),
    # ... Google Gemini, OpenAI GPT-4.1, DeepSeek, etc.
}

使用 Decimal 而非 float 是刻意的——金额计算不能有浮点误差。每个条目都附带了 source_urlpricing_version,让用户可以验证定价来源。

成本估算(usage_pricing.py:481-557)

# agent/usage_pricing.py:529-536
if entry.input_cost_per_million is not None:
    amount += Decimal(usage.input_tokens) * entry.input_cost_per_million / _ONE_MILLION
if entry.cache_read_cost_per_million is not None:
    amount += Decimal(usage.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_MILLION

CostResultstatus 字段告诉调用方这个成本的可信度:"actual"(provider API 提供)、"estimated"(从快照/元数据计算)、"included"(订阅制)、"unknown"(无法计算)。

CodexAuxiliaryClient:辅助任务的端点适配

当主 agent 使用 Codex Responses API(codex_responses 模式)时,存在一个端点形状不匹配的问题:辅助任务(压缩摘要、标题生成、会话搜索等)的代码使用的是 client.chat.completions.create() 接口,但 Codex 端点只暴露 Responses API,不支持 /chat/completions 路径。

CodexAuxiliaryClientagent/auxiliary_client.py:424)通过适配器模式解决这个问题:

# agent/auxiliary_client.py:424-439
class CodexAuxiliaryClient:
    """OpenAI-client-compatible wrapper that routes through Codex Responses API.
    Consumers can call client.chat.completions.create(**kwargs) as normal."""

    def __init__(self, real_client: OpenAI, model: str):
        self._real_client = real_client
        adapter = _CodexCompletionsAdapter(real_client, model)
        self.chat = _CodexChatShim(adapter)

调用方仍然写 client.chat.completions.create(messages=..., model=...),但 _CodexCompletionsAdapter 内部将 chat.completions 请求翻译为 Responses API 调用,再将 Responses 格式的返回值包装回 chat.completions 的响应形状。

这不是简单的 provider 路由——第 18 章前面讨论的 resolve_provider_client() 决定"用哪个 provider",而 CodexAuxiliaryClient 解决的是"同一个 provider 内两种 API 端点形状不兼容"的问题。对应的异步版本 AsyncCodexAuxiliaryClientauxiliary_client.py:462)通过 asyncio.to_thread() 桥接同步适配器,供 web_tools、session_search 等异步消费方使用。

类似的适配器还有 AnthropicAuxiliaryClient(同文件),用于将 chat.completions 调用翻译为 Anthropic Messages API 格式。三个辅助客户端适配器共同确保了:无论主 agent 使用哪种 API 模式,辅助任务代码都只需要一套 chat.completions 接口

Provider 前缀处理(model_metadata.py:22-61)

用户配置模型时可能使用各种格式:anthropic/claude-opus-4.6local:qwen3.5:27bdeepseek:latest_strip_provider_prefix() 需要区分 provider 前缀和 Ollama 的 model:tag 格式:

# agent/model_metadata.py:44-61
def _strip_provider_prefix(model: str) -> str:
    prefix, suffix = model.split(":", 1)
    if prefix_lower in _PROVIDER_PREFIXES:
        # Don't strip if suffix looks like an Ollama tag (e.g. "7b", "latest", "q4_0")
        if _OLLAMA_TAG_PATTERN.match(suffix.strip()):
            return model   # "qwen3.5:27b" → 保持原样
        return suffix      # "local:my-model" → "my-model"
    return model           # "some-model:tag" → 保持原样

_PROVIDER_PREFIXES 包含 30+ 个已知的 provider 名称(model_metadata.py:25-35),_OLLAMA_TAG_PATTERN 匹配 Ollama tag 的常见格式(数字+b、latest、q 量化标记等)。

设计启示

  1. 模式检测而非配置声明:大多数用户只配置 model 和 api_key,API 模式由 provider + base_url 自动推导。这减少了配置出错的可能性,同时允许高级用户通过 api_mode 字段显式覆盖
  2. 10 级 fallback 链:上下文窗口探测不依赖单一数据源。任何一级失败都有下一级接管,最差情况回退到 128K 的安全默认值。错误驱动的探测(从 API 错误中解析限制)是最后的兜底
  3. 统一 token 语义:三种 API 对缓存 token 的表示差异很大,但 CanonicalUsage 将它们统一为 5 个标准字段。这让上层代码(成本计算、usage 显示、压缩决策)不需要知道底层 API 的差异

设计赌注回扣:本章直接服务于 Run Anywhere 赌注——三种 API 模式的自动检测、30+ provider 的路由映射、10 级上下文探测 fallback,让用户只需改一个 model 配置就能在 Anthropic、OpenAI、本地 Ollama、国产 DeepSeek 之间无缝切换。


版本演化说明

本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 模型兼容层在 v0.5.0-v0.8.0 之间扩张很快:API 模式、provider-aware 上下文窗口探测和价格快照都不是一次成形的。可以明确确认的是,models.dev / Anthropic /v1/models 这一类 provider-aware 元数据能力在 v0.7.0 发布窗口已经出现,并在 v0.8.0 继续强化。v0.8.x 新增了 CodexAuxiliaryClient 适配器,解决了主 agent 使用 Codex Responses API 时辅助任务仍需 chat.completions 接口的端点形状不匹配问题。