开源自托管 Agent 项目 Hermes Agent 怎么换模型与换供应商:元数据与切换动作拆解
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
**在这个项目里,“换模型”和”换供应商”不是同一个动作,模型元数据也基本不存在仓库里——它运行时从外部注册表拉下来落成本机缓存,而一次成功的切换真正改动的是 provider、base_url、api_key、api_mode 这四项加上一次重新解析出来的上下文窗口。**把这句话记住,后面所有奇怪现象都有解释:为什么同一个模型 id 在两家供应商下显示的窗口不一样,为什么你手工写死的 context_length 换模型后被悄悄丢掉,为什么切完模型工具列表突然少了一半。
先做个消歧。这里说的 Hermes 指 NousResearch/hermes-agent 这个开源自托管 Agent 项目(仓库 LICENSE 采用 MIT,署名 Nous Research),不是 Nous Research 的 Hermes 开源模型系列,也不是其他同名商标或同名库。它是一个会常驻在你机器上、开终端执行命令、连你聊天软件账号、往磁盘写缓存和配置的进程——这个属性到最后一节还会再提,因为它直接影响”换供应商”这件事的风险量级。
站内已经有三篇相邻文章:pi 的 provider 抽象层怎么设计 讲的是另一个项目的分层做法,Agent 模型分层调度 和 多模型 fallback 设计 讲的是与具体实现无关的通用方法论。本篇不重复那些结论,只做一件事:把 hermes-agent 这个具体仓库里的模型/供应商链条按你能跟着走的顺序拆开,落到文件名和配置键上。
一、模型元数据从哪来:仓库里几乎没有一张”模型表”
打开 agent/models_dev.py,第一行注释就把定位说清了:它是 provider 和 model 的主数据库,数据来自 https://models.dev/api.json 这个社区维护的注册表。项目自己不维护完整模型清单,只维护两样东西:一层名字映射,和一套解析出来的数据结构。
名字映射是 PROVIDER_TO_MODELS_DEV 这张字典,把项目内部的供应商名翻译成 models.dev 的 provider id。它不是一对一的,下面从原表里摘几条能说明问题的(表里还列着十几家,这里没抄全):
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openrouter": "openrouter",
"openai": "openai",
"openai-codex": "openai",
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
"moonshot": "kimi-for-coding",
"gemini": "google",
"google": "google",
"xai": "xai",
"xai-oauth": "xai",
# ... 其余若干家省略
}
注意 openai-codex 和 openai 指向同一份目录,xai-oauth 和 xai 也是——代码注释解释了原因:OAuth 只是同一份模型目录的另一条认证与传输路径。但同一个 slug 在这两条路径上的实际可用窗口可以不同,这个矛盾后面由专门的分支处理。
数据结构侧,ModelInfo 和 ProviderInfo 两个 dataclass 把原始 JSON 解析成带类型的字段,分工很干净:ProviderInfo 只管一家供应商的身份(显示名、装 key 的环境变量名、base URL、文档链接),ModelInfo 才是模型这一侧——能力位(reasoning、tool_call、attachment、structured_output、open_weights)、输入输出模态、限额、知识截止、发布日期、状态(alpha / beta / deprecated)。另有一个更薄的 ModelCapabilities 供内部调用,它在读”是否支持视觉”时优先看 modalities.input 里有没有 image,只在这个字段缺失时才退回旧的 attachment 标志——注释直说旧标志可能过时或过宽。同一个能力,来源不同,可信度不同。
取数是三级缓存:内存缓存(1 小时 TTL)→ 磁盘缓存 ~/.hermes/models_dev_cache.json(任何年龄都先返回)→ 网络请求。设计取向写在 fetch_models_dev 的 docstring 里:只要存在任何缓存,调用方永远不阻塞在网络上;陈旧数据先返回,再由一个后台守护线程刷新;刷新失败则进程级退避 5 分钟。延迟敏感的路径可以传 allow_network=False,只吃缓存、绝不发请求。
选择器里能看到哪些模型也由这一层决定。list_agentic_models 只保留 tool_call 为真的条目,再用一组正则把 TTS、embedding、带日期的 preview 快照、纯图像等噪音模型滤掉;这两个列表函数还共用同一份隐藏名单,把一批在现网 Google 端点上已经 404 的过期 slug、以及一批配额小到撑不住 agent 流量的 Gemma 条目挡在目录之外——注释写明这批模型的能力元数据仍然保留,只是不在安装向导和挑选界面里出现。
这对你意味着什么:你在挑选界面看不到某个模型,先别怀疑凭据,多半是它在上游注册表里没标 tool_call,或者名字命中了噪音正则。而你在离线环境里看到的目录,是磁盘缓存里那份旧的。
二、上下文窗口是另一条独立的解析链
窗口大小没跟着模型元数据一起走,它在 agent/model_metadata.py 里有自己的一条长链。get_model_context_length 的 docstring 把顺序从 0 一路编到 9(中间还带 0c、1b 这类插号),优先级大致是:配置里的显式覆盖最高(model.context_length,或 custom_providers 里的按模型覆盖);然后是只在某个多路复用端点上验证过的端点级元数据;然后是持久缓存;再往后是 Bedrock 静态表、自定义端点的 /models、本地推理服务探测、Anthropic 的 /v1/models、一批供应商专属分支(Copilot、Nous、Codex OAuth、GMI、Ollama 原生 /api/show、models.dev 查询)、OpenRouter 的实时目录,最后才是硬编码的家族兜底和一个固定默认值。
几个值得单独记住的点:
持久缓存文件是 ~/.hermes/context_length_cache.yaml,键的形状是 model@base_url。这个键设计本身就是答案:save_context_length 的注释只说了一句——同一个模型名由不同供应商提供时限额可以不同,所以缓存必须按端点分开存。仓库里也有对应的真实落差记录:某家自建门户的 /v1/models 报出来的窗口,比聚合器公共目录里那份更小,代码于是把门户值当权威、把聚合器那份只当兜底。键里带上端点,就是不让这两个值互相污染。
DEFAULT_CONTEXT_LENGTHS 那张按家族排下来的字典不是主数据源。它上面的注释写得很明白:这是”薄兜底”,只放宽泛的家族模式,只有在供应商未知且 models.dev / OpenRouter / Anthropic 全都没命中时才触发。匹配方式是按键长度从长到短的子串匹配,且只判断”键是不是输入的子串”,反向不判断——注释给了原因:反向匹配会让短名字误命中长版本号,把窗口报大。
还有一条硬下限常量 MINIMUM_CONTEXT_LENGTH(具体数值以该常量当前定义为准)。低于它的模型,注释直说无法维持工具调用工作流所需的工作记忆,会话、模型切换和定时任务都应该拒绝。本地服务探测出来的窗口低于这条线时,代码会返回真值给上层去报错,但明确不写进磁盘缓存——不能让一个次门限的窗口被当成合法配置固化下来。
本地端点另有一套反向修正。detect_local_server_type 按顺序探 LM Studio、Ollama、llama.cpp、vLLM 的特征端点判断服务类型;命中持久缓存且端点是本地的,会再做一次实时探测,发现服务重启后窗口变了就丢掉旧值。LM Studio 和 Codex OAuth 整个跳过持久缓存,理由分别是”已加载的窗口是临时的、用户随时能重载”和”目录是账号相关的、一次探测失败不能压制后续复核”。
三、切换动作实际改了什么
入口是 hermes_cli/model_switch.py 里的 switch_model,CLI 和网关共用它。它的解析链分两条路:给了 --provider 就走显式路径,先定供应商再在目标供应商上解别名;没给就走推断路径,依次尝试当前供应商上的别名、跨供应商的别名回退、聚合器上的 vendor:model 转 vendor/model、聚合器目录搜索、配置里声明过该模型的供应商精确匹配,最后才是按模型名猜供应商。
无论走哪条,最后汇到同一段公共路径:解析凭据 → 精确别名可能把 base_url 顶掉 → 定 api_mode → 规范化模型名 → 校验(不通过还有一次”配置里声明过就放行”的兜底)→ 供应商专属分支再覆盖一次 api_mode → 取元数据 → 构造 ModelSwitchResult。这个结果对象里带回的字段就是”切换实际改了什么”的答案:new_model、target_provider、provider_changed、api_key、base_url、api_mode、capabilities、model_info,以及一条给人看的 warning_message。
api_mode 是最容易被忽略的第四项。它决定请求走哪种线路协议(chat_completions、anthropic_messages、codex_responses 之类)。代码里对它做了三层保险:先看这个 host 是否强制某种协议,强制就直接覆盖;否则由供应商加 base_url 推断;再往后 Copilot、OpenCode、Nous 各自还有一次覆盖——Nous 那一层按最终模型名重新判定走 /v1/messages 还是 /chat/completions,注释解释说必须用规范化之后的模型名重算,否则别名清空过的场景会把 Claude 系模型留在 OpenAI 线路上。旁边还留了一条真实故障记录:同一供应商内切模型时沿用了上一轮的旧 api_mode,请求走错线路,服务端在 tools 加 reasoning_effort 上直接 400。
落不落盘是另一件独立的事,由 resolve_persist_behavior 单点决定:--once 和 --session 一律不写配置;--global 一律写;只给了 --provider 而没给持久化标志时默认不写,理由写在注释里——换供应商通常是探索性的,用户是想在这轮对话里试个后端,不是改默认值;其余情况看配置里的 model.persist_switch_by_default,默认是假,也就是说一条朴素的 /model <name> 只影响当前会话。
别名也是两套。MODEL_ALIASES 存的是”厂商 + 家族前缀”这种不带版本号的身份(sonnet、opus、gpt5、glm、kimi 之类),实际值靠上游目录动态解析,新版本发布后不用改代码。DIRECT_ALIASES 是精确映射:先取仓库内置的那几条,再合并配置里的 model_aliases: 段(也认 model.aliases 的字符串简写形式,值里 供应商/模型 的前缀就是供应商),写死模型 id、供应商和 base_url,绕过目录解析——命中后它会覆盖 base_url 并把 api_mode 清空,让后面按新 URL 重新检测。目录里没有的端点靠这条路接入。
供应商本身怎么进来的,看 providers/README.md:每家声明为一个 ProviderProfile(定义在 providers/base.py),注册表在 providers/__init__.py,profile 实体作为插件住在 plugins/model-providers/<name>/,用户目录 $HERMES_HOME/plugins/model-providers/<name>/ 下的同名插件覆盖内置版本。README 里那张表列了可覆盖的钩子:get_hostname() 供 URL 反查、prepare_messages() 做消息预处理、build_extra_body() 和 build_api_kwargs_extras() 处理各家把推理参数放在不同位置的差异、fetch_models() 抓实时目录。真正的”加一家供应商要写什么”写在隔壁 plugins/model-providers/README.md:新建一个目录,放一个 __init__.py(构造 profile 并调 register_provider)和一份 plugin.yaml 清单,那份文档的原话是其余什么都不用改——认证、配置、目录、体检、URL 反查、传输层都从注册表自动接上。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| models.dev 集成层 | 拉取并解析模型/供应商元数据,三级缓存与后台刷新 | agent/models_dev.py | 选择器里模型对不上、离线时看到旧目录 |
| 窗口解析链 | 把模型加端点解析成一个上下文窗口数字 | agent/model_metadata.py | 显示窗口不对、压缩触发得太早或太晚 |
| 切换管线 | 解析别名与供应商、定凭据与线路协议、决定是否落盘 | hermes_cli/model_switch.py | 每次 /model 切换 |
| 供应商注册表与 profile | 声明一家供应商的认证、端点、请求怪癖 | providers/base.py、providers/README.md | 接入自建或未内置的供应商 |
| 供应商插件目录 | 存放各家 profile,用户目录同名覆盖内置 | plugins/model-providers/ | 需要改某家的默认行为 |
| 切换护栏 | 窗口变小时把预压缩提示并进切换警告 | hermes_cli/context_switch_guard.py | 从大窗口切到小窗口模型 |
| 工具面装配 | 按活跃模型的窗口决定是否折叠可延迟的工具 | model_tools.py | 换完模型工具列表变了 |
四、混用多家时的上手与避坑清单
context_length 手工钉死后又换了模型。 为什么会踩:为本地服务钉过一个窗口,之后切到云上模型忘了删,压缩阈值就按错的数算。怎么避:hermes_cli/route_identity.py 里的 should_clear_context_pin 会兜底——配置里记的模型、base_url、供应商与当前运行路线不一致时这个 pin 被丢弃,且失败即丢弃(比较过程出错也返回”丢掉”)。但兜底不等于免责:只在自动探测确实错时才设这个键,切换完看一眼显示的窗口数。
把 context_length 和 max_tokens 当成一回事。 为什么会踩:这两个键名都带 token,配置示例文件专门为此写了一节:
# ── Token limits — two settings, easy to confuse ──────────────────────────
#
# context_length: TOTAL context window (input + output tokens combined).
# Controls when Hermes compresses history and validates requests.
# Leave unset — Hermes auto-detects the correct value from the provider.
# ... (a few lines on when to set it manually omitted here)
#
# max_tokens: OUTPUT cap — maximum tokens the model may generate per response.
# Unrelated to how long your conversation history can be.
怎么避:前者是总窗口、影响压缩时机,后者是单次输出上限、和历史长度无关;两个都建议留空,让探测链自己去解析。
以为同一个模型名在各家窗口一样。 为什么会踩:在 A 家验证过的窗口,换到 B 家就不成立,聚合器和企业网关经常给出比原厂更低的强制上限。怎么避:记住持久缓存是按 model@base_url 存的,别跨端点复用同一个手写覆盖;换端点后让它重新解析一次。
用短别名切换,结果连供应商一起换了。 为什么会踩:没给 --provider 时,短别名在当前供应商上解不出来会去有凭据的供应商里找一家。怎么避:需要确定性时显式带 --provider。另有一个专门的护栏:如果你显式指定的供应商名其实是个指向聚合器的别名,而那个聚合器没配凭据,它会直接报错并提示可用的直连供应商,而不是把你静默切到未认证端点上去吃 401。
从大窗口切到小窗口模型。 为什么会踩:会话已经很长了,新模型装不下。怎么避:merge_preflight_compression_warning 会估算当前会话 token 数,和新模型的压缩阈值比较,命中就把”你的下一条消息会先跑一轮预压缩”这句合并进切换警告——看到这条提示就该考虑先开新会话或先做交接。低于 MINIMUM_CONTEXT_LENGTH 那条下限则直接不给用。
换完模型发现工具列表变了。 为什么会踩:model_tools.py 里的工具装配最后一步会算一个门槛,门槛的分母是活跃模型的上下文长度(_resolve_active_context_length 会去问窗口解析链,注释里提到默认阈值是窗口的 10%);可延迟的 MCP 与插件工具超过门槛就被折叠进 tool_search / tool_describe / tool_call 三个桥接工具,核心工具永不折叠。怎么避:把这当成预期行为而不是 bug;模型换小了、工具面自动收窄,是为了不让工具 schema 吃掉半个窗口。这条是我认为最容易漏的联动。
缓存与目录陈旧。 为什么会踩:外部注册表拉取失败后有 5 分钟进程级退避,磁盘缓存又是”任何年龄都先用”,所以你可能在看一份很旧的目录。怎么避:切换时加 --refresh;仓库里还有一条刷新模型目录的配置命令。另外注意这套机制刻意不把”来源可疑”的值写进磁盘——Nous 的门户目录被当成权威,走 OpenRouter 兜底得到的值不落盘,注释解释说社区维护的目录一次失灵就可能把错误数字永久固化。
私有供应商插件静默覆盖内置。 为什么会踩:用户目录下同名的 model-provider 插件按后写者胜覆盖内置版本。怎么避:接手一台别人配过的机器时,先看 $HERMES_HOME/plugins/model-providers/ 下有没有目录,再去怀疑代码。
五、边界与代价
这套设计放弃了自持一份完整模型目录。代价很直白:主源在外部社区注册表,冷启动且无缓存又断网时只能落到宽泛的家族子串匹配或固定默认值;仓库里还散着一批手写反制——某系列在上游被报小、某个 slug 在上游过期但仍被列出、某家开放权重模型配额小到跑不动 agent 流量——每条都带着注释和判断依据。这条链需要人持续跟进上游变化,不是一次写完就稳定的东西。
它也用可预测性换了覆盖面。子串最长匹配、URL 到供应商的猜测、从错误文本里用正则抠出真实上限、按状态码顺序探测本地服务类型——这些启发式让它能接上形态各异的端点,也意味着边界情况下的解析结果需要你亲自验证,而不是从文档里推出来。
有几件事它明确不管。它不替你决定该用哪家:tool_call 过滤只过滤目录,模型实际能不能撑住多轮工具调用得你自己试。唯一写死的一条模型提醒恰好也是最好的消歧材料——切到 Nous 自家那两代 Hermes 聊天模型时,_check_hermes_model_warning 会附一条警告,直说这些模型不具备 agent 工作流需要的工具调用能力、并不是给这个 Agent 项目当驱动用的;但它只警告,不阻止你切过去。它也不做请求级的智能路由——切换是一个显式动作,不是按任务难度自动选模型的路由器。各家的计费口径、额度与限流规则本文不展开,那部分各家规则不同且会调整,以官方最新说明为准;机制上这套代码只负责把请求送到你指定的端点。
还有一条代价藏在”自动探测”这个卖点背面:这条链是会主动往外发请求的。目录拉取走公网注册表,窗口解析会带着你的 key 去敲目标端点的 /models、Ollama 的 /api/show、llama.cpp 的 /v1/props 这类路径,本地服务类型判断也是一轮轮 HTTP 探测。也就是说,你在配置里写下的每个 base_url,迟早都会实际收到一次带凭据的请求——写错一个域名,等于把 key 发给了那个域名的主人。装第三方供应商插件同理:插件里的 __init__.py 是被直接 import 执行的代码,不是声明式配置。
最后一条代价与技术无关但更重要:这是一个常驻进程,会开终端执行命令、连你的聊天软件账号、往磁盘写缓存与配置。换供应商在操作上只是改几个键,在风险上等于把一份新凭据交给一台长期在线、有命令执行能力的机器——凭据落在配置文件、环境变量和本机缓存里。评估它应该按”给长期在线主机发凭据”的标准来做,而不是按”改个 SDK 参数”。相关的权限收敛思路可以参考站内的 最小权限设计。
收尾:一份切换后的自检清单
切完一次模型或供应商,按这四条过一遍就基本不会踩坑:显示出来的上下文窗口是不是你预期的数(不对就去看是哪一层给的值);这次切换是会话内还是写进了配置(不确定就看有没有带持久化标志);线路协议有没有被目标 host 强制成另一种(表现是首个请求 400);工具列表是不是因为窗口变小被折叠了。
想继续往下读,顺序建议是:agent/models_dev.py 看元数据来源和缓存策略,agent/model_metadata.py 里 get_model_context_length 的 docstring 看完整解析顺序,hermes_cli/model_switch.py 里 switch_model 看两条解析路径,providers/README.md 加 plugins/model-providers/README.md 看接一家新供应商要写什么。四份读完,这条链你就能自己改。要先建立整体判断标准再钻细节,可以配合站内的 开源项目选型方法 一起看。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 装机实录与必选配置 和 自托管开源 Agent 项目 Hermes Agent 终端界面拆解。