Chapter 12

Provider 与 Plugin 生态

30 个 ProviderProfile 怎么处理 Provider quirks;Plugin Hook 体系; 一份内置 plugin 的全貌。

本章约 5,000 字 阅读 ~25 分钟 关键词:ProviderProfile · lazy discovery · plugin hooks · MCP host

最后这一章 Part V 收尾。我们看 Hermes 怎么让用户切换 30 个不同的 LLM provider 而不改一行代码, 以及 plugin 系统怎么让所有功能扩展都不动 core。读完这一章你应该能在不分叉仓库的情况下 扩展 Hermes 到任何你想要的形态。

12.1Provider 系统:为什么需要它

Hermes 用户的需求:

所有这些都不该需要改 Hermes 源码。Provider 系统让"换模型"变成配置问题

12.2ProviderProfile:声明式 Provider

每个 provider 在 plugins/model-providers/<name>/__init__.py 里。最简形态:

plugins/model-providers/openrouter/__init__.py
from providers import register_provider
from providers.base import ProviderProfile

register_provider(ProviderProfile(
    name="openrouter",
    display_name="OpenRouter",
    description="Aggregator — 300+ models behind one API",
    base_url="https://openrouter.ai/api/v1",
    env_vars=("OPENROUTER_API_KEY",),
    aliases=("or",),
    fallback_models=(
        "anthropic/claude-opus-4.6",
        "openai/gpt-5.2",
        "deepseek/deepseek-v4",
    ),
))

这是纯声明式。没有自定义类、没有方法重载——只是几个字段。Hermes 主代码读这些字段构造 HTTP request。

ProviderProfile 的关键字段:

字段作用
name唯一标识
display_name用户看到的名字
base_urlOpenAI 兼容 endpoint
env_vars从哪个 env var 读 API key
aliases命令行简写 oroai
fallback_models用户不指定时用哪些
auth_methodapi_key / oauth / none
quirksProvider-specific 处理(下面讲)

12.3处理 Quirks:方法重载

简单 provider(OpenRouter、智谱、OpenAI 自家)声明式就够了。但有怪癖的需要重载方法。 看 plugins/model-providers/deepseek/__init__.py:

plugins/model-providers/deepseek/__init__.py
"""DeepSeek provider profile.

DeepSeek's V4 family defaults to thinking-mode ON when extra_body.thinking
is unset. The API then returns reasoning_content and starts enforcing the
contract that subsequent turns echo it back; combined with how Hermes
replays history this lands on HTTP 400 reasoning_content must be passed
back error.

This profile overrides build_api_kwargs_extras to mirror the wire shape
that DeepSeek's OpenAI-compat endpoint expects:

    {"reasoning_effort": "<low|medium|high|max>",
     "extra_body": {"thinking": {"type": "enabled" | "disabled"}}}
"""
from __future__ import annotations
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile

def _model_supports_thinking(model: str | None) -> bool:
    m = (model or "").strip().lower()
    if not m: return False
    if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
        return True
    if m == "deepseek-reasoner":
        return True
    return False

class DeepSeekProfile(ProviderProfile):
    """DeepSeek — extra_body.thinking + top-level reasoning_effort."""

    def build_api_kwargs_extras(self, *, model: str | None = None,
                                  **context: Any) -> dict[str, Any]:
        body: dict[str, Any] = {}
        if _model_supports_thinking(model):
            # 显式传 thinking enable,避免后续 echo back 报错
            body["extra_body"] = {
                "thinking": {"type": "enabled"}
            }
        return body

register_provider(DeepSeekProfile(
    name="deepseek",
    display_name="DeepSeek",
    description="DeepSeek API (reasoning models supported)",
    base_url="https://api.deepseek.com/v1",
    env_vars=("DEEPSEEK_API_KEY",),
    fallback_models=("deepseek-v4-pro", "deepseek-chat"),
))

注意

  1. 顶部那段注释价值连城——它是踩坑后写下的实战 lesson。 "如果不传 thinking enable,DeepSeek V4 返回 reasoning_content 但下一轮你不 echo back 会 400"。 这种知识只有踩过坑的人才知道,开放源码项目的注释是这种知识的最佳载体。
  2. 重载是方法粒度,不是子类化整个 client。build_api_kwargs_extras 只在某一步被调用,返回的 dict 合并进 API 请求 body。其他全部继承默认。
  3. 每个 quirk 单独一个 hook 方法:build_api_kwargs_extrasnormalize_responsebuild_auth_headersfilter_tools……Hermes 留了大约 10 个扩展点。

12.4真实 Quirk 四例

不同 provider 的 quirk 五花八门。挑四个 Hermes 实际处理过的:

Quirk 1: Gemini 不支持 tool_choice="required"

GeminiProfile 重载:

class GeminiProfile(ProviderProfile):
    def build_api_kwargs_extras(self, **ctx):
        kwargs = ctx.get("_api_kwargs", {})
        if kwargs.get("tool_choice") == "required":
            # Gemini 不支持 — 改 auto
            kwargs["tool_choice"] = "auto"
            logger.warning("Gemini doesn't support tool_choice=required; "
                           "falling back to auto")
        return {}

Quirk 2: Anthropic prompt cache 显式标记

大部分 provider 自动 cache;Anthropic 要显式 cache_control。AnthropicProfile:

class AnthropicProfile(ProviderProfile):
    def supports_cache_control(self) -> bool:
        return True

    def annotate_cache_breakpoints(self, messages):
        # 在 system message 末尾、tools 末尾、context 末尾标 4 个 breakpoint
        ...

Quirk 3: Codex Responses API (OpenAI 新协议)

OpenAI 推 Responses API 替代 Chat Completions。CodexProfile:

class CodexProfile(ProviderProfile):
    def api_mode(self) -> str:
        return "codex_responses"     # 主 loop 看到这个会走不同 path

    def normalize_response(self, raw_response):
        # 把 Responses API 的 {output: [...]} 格式转成
        # 经典 Chat Completions 的 {choices: [{message: ...}]}
        ...

conversation_loop 不知道这事——它只看经典格式。Provider profile 负责适配。

Quirk 4: Unique tool names 强制(DeepSeek / Kimi / MiMo)

这些 provider 不允许 tools 列表里有重名工具,否则 400。Hermes 默认确保 unique, 但 long-running gateway 可能因为 cache pollution 出现重名。Profile 在 filter_tools hook 里做去重:

def filter_tools(self, tools):
    seen = set()
    result = []
    for t in tools:
        n = t["function"]["name"]
        if n in seen:
            logger.warning("Dropping duplicate tool name %s for strict provider", n)
            continue
        seen.add(n)
        result.append(t)
    return result

12.5Lazy Discovery

30 个 provider 全部都 import 太费启动时间。Hermes 用 lazy discoveryproviders/__init__.py:

providers/__init__.py:1-100
_REGISTRY: dict[str, ProviderProfile] = {}
_ALIASES: dict[str, str] = {}
_discovered = False

def register_provider(profile: ProviderProfile) -> None:
    """Register a provider profile by name and aliases.

    Later registrations with the same name replace earlier — so user
    plugins under $HERMES_HOME/plugins/model-providers/ can override
    bundled profiles without editing repo code.
    """
    _REGISTRY[profile.name] = profile
    for alias in profile.aliases:
        _ALIASES[alias] = profile.name

def get_provider_profile(name: str) -> ProviderProfile | None:
    """Look up a profile by name or alias."""
    if not _discovered:
        _discover_providers()
    canonical = _ALIASES.get(name, name)
    return _REGISTRY.get(canonical)

def _discover_providers() -> None:
    """Populate registry by importing every provider plugin.

    Order (later overrides earlier):
      1. Bundled: <repo>/plugins/model-providers/<name>/
      2. User: $HERMES_HOME/plugins/model-providers/<name>/
      3. Legacy: providers/<name>.py
    """
    global _discovered
    if _discovered: return
    _discovered = True

    # 1. Bundled
    if _BUNDLED_PLUGINS_DIR.is_dir():
        for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
            if not child.is_dir() or child.name.startswith(("_", ".")):
                continue
            _import_plugin_dir(child, "bundled")

    # 2. User
    user_dir = _user_plugins_dir()
    if user_dir is not None:
        for child in sorted(user_dir.iterdir()):
            if not child.is_dir() or child.name.startswith(("_", ".")):
                continue
            _import_plugin_dir(child, "user")

    # 3. Legacy: providers/{name}.py — 老路径兼容
    for legacy in (Path(__file__).parent.glob("*.py")):
        if legacy.name in ("__init__.py", "base.py"): continue
        importlib.import_module(f"providers.{legacy.stem}")

设计点:

12.630 个 Provider

Hermes 仓库的 plugins/model-providers/:

plugins/model-providers/
├── anthropic              # Claude 直连
├── openai-codex           # OpenAI Responses API
├── openrouter             # 聚合器
├── deepseek               # DeepSeek V4 + R1
├── gemini                 # Google
├── xai                    # Grok
├── nous                   # Nous Portal
├── alibaba                # 通义千问
├── alibaba-coding-plan
├── kilocode               # 国产 coding 专用
├── kimi-coding            # 月之暗面
├── minimax                # MiniMax
├── novita                 # AI 云
├── ollama-cloud           # 本地 + 云端 Ollama
├── nvidia                 # NIM
├── stepfun                # 阶跃星辰
├── xiaomi                 # MiMo
├── zai                    # 智谱 GLM
├── huggingface
├── opencode-zen
├── arcee
├── azure-foundry          # Azure 系
├── bedrock                # AWS
├── copilot                # GitHub Copilot
├── gmi                    # GMI
├── ai-gateway
├── qwen-oauth             # 通义 OAuth flow
├── custom                 # 兜底 — 任意 OpenAI 兼容

30 个 provider,全部走同一份核心代码。切换提供商=改配置

# config.yaml
model:
  provider: anthropic
  model: claude-opus-4.7
  fallback_provider: openrouter
  fallback_model: openai/gpt-5.2

2026 年的 Provider 现状

到本书写作时(2026 年 5 月),主流 provider 的能力梯度大致是这样:

Provider当前主力模型定位价格区间 (input/M)
AnthropicClaude Opus 4.7 / Sonnet 4.6SOTA reasoning + 长上下文$15 / $3
OpenAIGPT-5.2 / GPT-5 / o3SOTA reasoning,强工具调用$15 / $2
GoogleGemini 3 Pro / Flash多模态、便宜 Flash$7 / $0.4
DeepSeekV4 / R2开源权重,性价比之王$0.6 / $2
智谱 (z.ai)GLM-5中文友好,本地可部署~$1
MoonshotKimi K2 / Reasoning长上下文 + reasoning$2
AlibabaQwen 3.5 / Thinking开源权重 + reasoning$1 / 开源免费
xAIGrok 4实时数据接入$5
本地 (Ollama / vLLM)Llama 4 / Qwen 3完全离线电费
2026 选模型策略 主对话:Claude Opus 4.7 或 GPT-5.2(顶级 reasoning)。
辅助任务(curator review、auxiliary client、压缩):Sonnet 4.6 / Gemini Flash / DeepSeek(便宜)。
fallback:选一个不同 provider的,防 Anthropic / OpenAI 整厂宕机时全挂。
国内场景:智谱 GLM-5、Kimi K2、Qwen 3.5 都已能产线用。

Provider 厂商的 Agent SDK

2025–2026 各家除了卖模型,也都推出"配套 Agent SDK"。Hermes 不强制用——但了解它们对比 Hermes 设计有帮助:

SDK核心抽象位置
OpenAI Agents SDKHandoff(Agent 之间交接)取代 2024 的 Assistants API
Anthropic Claude Agent SDKTool use + Sub-agents2025 中发布
Google Vertex Agents声明式 yaml 编排企业向
Microsoft Agent Framework (MAF)Actor 模型替代 AutoGen

和 Hermes 的关系:这些是"模型厂家的 framework",绑定特定 provider。 Hermes 选择不依赖任何 SDK,直接用 OpenAI 兼容协议——为的是不被任何一家锁定。

12.7通用 Plugin 系统

第 7 章已经介绍过 Plugin Hook。这里完整看 PluginManager 的实现。 hermes_cli/plugins.py:

hermes_cli/plugins.py (摘要)
class PluginManager:
    def __init__(self):
        self._hooks: dict[str, list[Callable]] = {h: [] for h in VALID_HOOKS}
        self._plugin_tool_names: set = set()
        self._cli_subcommands: dict[str, dict] = {}

    def discover_plugins(self):
        """Find & load all plugins from:
        - bundled plugins/ (only if has plugin.yaml)
        - $HERMES_HOME/plugins/
        - project-local ./.hermes/plugins/
        - pip entry points (group: 'hermes_plugins')
        """
        for directory in [_BUNDLED, _USER, _PROJECT_LOCAL]:
            for plugin_dir in directory.iterdir():
                manifest = plugin_dir / "plugin.yaml"
                if not manifest.exists():
                    continue
                self._load_plugin(plugin_dir)

        # pip entry points
        for ep in entry_points(group="hermes_plugins"):
            self._load_entry_point(ep)

    def _load_plugin(self, plugin_dir: Path):
        manifest = yaml_load(plugin_dir / "plugin.yaml")
        kind = manifest.get("kind", "general")
        if kind == "model-provider":
            # model provider 走另一条 discovery,这里跳过
            return

        # 导入 __init__.py 拿 register()
        spec = importlib.util.spec_from_file_location(...)
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)

        if not hasattr(module, "register"):
            logger.warning("Plugin %s lacks register() — skipping", plugin_dir)
            return

        ctx = PluginContext(self, plugin_dir, manifest)
        module.register(ctx)

PluginContext

给 plugin 用的 helper API。可以注册 hook、tool、CLI subcommand:

class PluginContext:
    def register_hook(self, hook_name, callback):
        self._manager._hooks[hook_name].append(callback)

    def register_tool(self, name, toolset, schema, handler, ...):
        from tools.registry import registry
        registry.register(name=name, toolset=toolset, ...)
        self._manager._plugin_tool_names.add(name)

    def register_cli_command(self, name, parser_fn, handler):
        # 让 hermes <plugin_name> <subcmd> 可用
        self._manager._cli_subcommands[name] = {
            "parser_fn": parser_fn,
            "handler": handler,
        }

12.8真实 plugin 一例:Kanban

Hermes 自带的 kanban plugin 是多 agent 任务板。它展示了 plugin 能做到多大规模:

plugins/kanban/
├── plugin.yaml
├── __init__.py            # register(ctx)
├── kanban_cli.py          # hermes kanban <verb> CLI
├── kanban_tool.py         # LLM tools: kanban_show, kanban_complete, ...
├── kanban_dispatcher.py   # 长跑后台 daemon
├── kanban_db.py           # SQLite 持久化
├── dashboard/             # Web UI(FastAPI + React)
│   ├── api.py
│   ├── dist/              # 编译好的 React
│   └── manifest.json
└── systemd/
    └── hermes-kanban-dispatcher.service

它做的事:

整个 plugin 大约 3000 行代码——不动 Hermes core 一行。 这就是 plugin 系统的威力上限。

12.9"Plugin 不动 core" 的铁律

Hermes AGENTS.md 里有:

Plugins MUST NOT modify core files (run_agent.py, cli.py, gateway/run.py, hermes_cli/main.py, etc.). If a plugin needs a capability the framework doesn't expose, expand the generic plugin surface (new hook, new ctx method) — never hardcode plugin-specific logic into core.
Hermes AGENTS.md, "Teknium rule, May 2026"

这条规则的原因和历史很重要:

早期 Hermes 在 main.py 里硬编码了 honcho(一个 memory plugin)的 argparse 子命令。每次 honcho 改命令格式,要发 PR 改 main.py。PR #5295 移除了这 95 行 硬编码,换成一个通用的"plugin 可注册 CLI subcommand"机制。

从此规则确定:core 永远不知道任何特定 plugin 的存在。 plugin 需要新能力,扩 PluginContext,加新 hook。

设计原则 这条规则保证 Hermes 长期可维护。它的精神是 "开放扩展,封闭修改" (Open/Closed Principle) 的严格执行。 任何 plugin 系统都该这么做。否则 plugin 互相干扰、core 越改越乱、最终重写。

12.10"无新 in-tree memory provider" 的政策

同样的精神扩展到 memory plugin。AGENTS.md 里:

No new in-tree memory providers (policy, May 2026): the set of built-in memory providers under plugins/memory/ is closed. New memory backends must ship as standalone plugin repos that users install into ~/.hermes/plugins/ (or via pip entry points).
Hermes AGENTS.md

意思:哪怕你写了世界最强的新 memory provider,不收进 Hermes 主仓库。 你必须独立发包。

动机:

这是对生态的承诺。Hermes 不想做"所有功能都在我这"的胖框架, 它想做"thin core + 强生态"的瘦平台。

12.11本章带走的

章末练习

  1. Easy ProviderProfile 是声明式的——但 quirk 需要重载方法。这种"声明 + 重载"的混合设计有什么好处?
  2. Easy 你想接一个新的 OpenAI 兼容 endpoint(你自己 host 的 vLLM)。 写出最简的 ~/.hermes/plugins/model-providers/myvllm/__init__.py
  3. Medium Lazy discovery 有一个 trade-off:第一次 get_provider_profile() 调用慢 (扫描 30 个目录)。设计一个 caching 策略让冷启动也快——比如"启动时只读 manifest, 用到 profile 时再 import 模块"
  4. Medium Hermes 不允许"两个 plugin 注册同名工具(不带 override)"。但两个 plugin 都需要 fetch_url 这种工具怎么办? 讨论:是 plugin 应该用 namespace(fetch_url_v2),还是 core 应该提供工具别名机制?
  5. Hard Plugin Hook 系统有一个被忽略的顺序依赖问题:两个 plugin 都注册了 transform_tool_result,先后顺序决定最终结果。 读 invoke_hook 的实现(第 7 章末有引),讨论这个顺序是怎么定的。 然后写一段约 200 字的"plugin 协作 best practices"。
  6. Hard "Plugin 不动 core 铁律"听起来很纯粹,但有边界例子:plugin 需要在 run_conversation 的某个具体行号插入 hook,core 还没暴露这个 hook。怎么演进?写一份"扩展 Plugin Hook 集"的提案模板: 命名、参数、调用时机、回调返回值语义。