开源 Agent 套件 ECC 的模型抽象层:四件套各管什么

2026-07-29

本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。

这套抽象层最值得看的地方,不是它支持了几家服务商,而是它明确地把「不做的事」留在了层外——没有重试、没有降级、没有成本归一、没有流式,接口只剩三个必须实现的方法。 一个装在编码 Agent 之上的增强套件,真正需要的模型抽象往往就这么薄;很多项目在这一层堆了太多东西,最后换个服务商反而更难。

ECC 是 MIT 许可证的开源项目,仓库地址 https://github.com/affaan-m/ECC 。它的主体是围绕编码 Agent 的一大堆资产:agents 目录下 67 个 agent、skills 目录下 281 个技能、commands 目录下 94 个命令。而 src/llm/ 这个 Python 包是另一条线——它不服务于那套 agent 资产,而是一个可以单独 import 的、服务商无关的调用层。本篇只拆这个包。

站内已经有三篇讲通用方法论的:模型路由策略讲什么请求该分给谁,API 聚合与中转对比讲接入方式怎么选,多模型 fallback 设计讲降级链路怎么搭。那三篇给的是判断框架,本篇不重复框架,只做一件事:把一个你能当场 clone 下来逐行核对的项目摊开,看这类设计落到代码里到底是什么样子,以及它落到哪一步就主动停手了。

一、这层要解决的问题:把「换一家」压缩成改一个值

任何跑在别人机器上的 Agent 工具都躲不开一个现实:用户手上的模型来源五花八门。有人有官方 key,有人只能走 OpenAI 兼容的中转端点,有人干脆在本机跑 Ollama。如果调用代码直接写死某家 SDK,这三类用户你只能服务一类。

ECC 的做法是在 src/llm/core/interface.py 里定义一个抽象基类,所有服务商实现都从它派生:

class LLMProvider(ABC):
    provider_type: ProviderType

    @abstractmethod
    def generate(self, input: LLMInput) -> LLMOutput: ...

    @abstractmethod
    def list_models(self) -> list[ModelInfo]: ...

    @abstractmethod
    def validate_config(self) -> bool: ...

    def supports_tools(self) -> bool:
        return True

    def supports_vision(self) -> bool:
        return False

必须实现的只有三个:发一次请求、报出可用模型、自检配置。能力探测 supports_toolssupports_vision 给了默认值,实现可以不管。还有一个 get_default_model,基类里直接抛 NotImplementedError,错误信息带上类名——这是个小设计取向:它不给一个假的默认模型名,宁可让你在运行时炸掉。

同一个文件里还定义了统一的异常族:LLMError 作为基类,带 messageprovidercodedetails 四个字段,往下分出 AuthenticationErrorRateLimitErrorContextLengthErrorModelNotFoundErrorToolExecutionError。这五个是上层唯一需要认识的失败类型,各家 SDK 抛出来的原生异常不会漏到调用方。

二、四件套怎么摆

组成部分它负责什么仓库位置你什么时候会碰到它
抽象接口与异常族定义三个必须实现的方法、能力探测默认值、统一失败类型src/llm/core/interface.py接一家新服务商、或写失败分支时
数据类型LLMInput / LLMOutput / Message / ToolDefinition / ToolCall / ToolResult / ModelInfo,全部是冻结 dataclasssrc/llm/core/types.py组装请求、解析返回、定义工具时
解析器决定这次到底实例化哪一家,兼管外部注册src/llm/providers/resolver.py配置来源出问题、或要接自定义实现时
服务商实现把统一入参翻成各家协议,再把各家返回翻回统一出参src/llm/providers/ollama.pyclaude.pyopenai.pyastraflow.pyatlas.py排查「为什么这家行为不一样」时
交互式选择器命令行里选一次,落盘成配置文件src/llm/cli/selector.py第一次配置、或想知道配置写去哪了
工具执行与循环工具注册表、执行器,以及一个基础的 ReAct 循环src/llm/tools/executor.py要让模型真的调工具时

类型这块值得单看。ToolDefinition 只声明一次工具,然后自带两个翻译方法:to_openai_tool() 包成 {"type": "function", "function": {...}} 的结构,to_anthropic_tool() 则把参数 schema 换成 input_schema 这个键名。工具定义的写法本身是另一个话题,站内 Agent 工具设计有专门展开;这里的关键点只有一个——协议差异被收在类型里,而不是散在每个 provider 的 generate 中间。

LLMInputLLMOutput 都是 frozen=True 的 dataclass,各自带 to_dict()。两个 to_dict() 的最后一行都是 return result | self.metadata,也就是说 metadata 字典参与合并且优先级更高。这个细节后面会在避坑清单里再提一次。

三、解析器:谁决定这次跑哪一家

src/llm/providers/resolver.py 是整个包里最短也最容易被忽略的一块。它维护一张 ProviderType 到实现类的映射表,枚举里目前有 claude、openai、ollama、astraflow、astraflow_cn、atlas。选择逻辑就这么几行:

def _resolve_provider_type(provider_type: ProviderType | str | None) -> ProviderType | str:
    if provider_type is not None:
        return provider_type

    env_provider = os.environ.get("LLM_PROVIDER")
    if env_provider:
        return _strip_env_value(env_provider).lower()

    saved_config = _read_saved_llm_config()
    return saved_config.get("LLM_PROVIDER", "claude").lower()

优先级一目了然:调用时显式传的参数最高,其次是 LLM_PROVIDER 环境变量,再次是落盘的 .llm.env 文件,最后兜底成 claude。_read_saved_llm_config 自己手写了一个极简的 env 解析——跳过空行和以 # 开头的行、按第一个等号切分、剥掉首尾成对的引号。没引第三方 dotenv 库,也没做变量插值。

get_provider(provider_type=None, **kwargs) 拿到类型后直接 provider_cls(**kwargs),把额外关键字原样透传给构造函数。另外还导出了 register_provider(provider_type, provider_cls),允许外部往映射表里塞自己的实现。注意这个函数的第一个参数类型是 ProviderType——枚举本身是闭集,你要接第七家,要么改枚举,要么复用一个已有的枚举值。

配置从哪来的另一半在 src/llm/cli/selector.py。它是个纯命令行的两步选择器:先列服务商,再列该服务商下的模型,最后 save_configLLM_PROVIDERLLM_MODEL 两行写进当前目录的 .llm.env。选择器里给了一份内置的候选清单作为默认值,同时留了 providersmodels_per_provider 两个参数让调用方自己传。选中 ollama 时它会额外打印一段自托管算力的说明,文案里把话讲得很死:那个链接是被动的,不会发起询价、不会预留算力,而单独的 ecc ito find 桥接才会真的提交一次带认证的询价请求。这种把「点了会发生什么」写在提示里的做法,比事后在文档角落补一句要诚实。

四、一次调用走完的样子:以本地实现为例

src/llm/providers/ollama.py 是这几个实现里最容易读的,因为它连 SDK 都没用,直接 urllib.request{base_url}/api/chat。构造函数从 OLLAMA_BASE_URLOLLAMA_MODEL 两个环境变量取值,各自有内置默认。

请求组装只有三件事:模型名取 input.model 否则用默认;messages 逐条调 msg.to_dict()"stream" 硬编码成 False。温度参数有个条件——只有当 input.temperature != 1.0 时才塞进 options,等于默认值就干脆不发,让服务端用自己的。同样的判断也出现在 astraflow 的共用基类和 atlas 实现里;但 openai.py 那条是无条件把 temperature 塞进请求参数的,同一个包里的几条实现在这件事上并没有对齐。

返回解析同样直白:文本从 message.content 取,工具调用如果存在就逐条转成 ToolCallstop_reason 取自 done_reason。翻译的终点永远是 LLMOutput

失败处理是这层最有代表性的一段:

        except Exception as e:
            msg = str(e)
            if "401" in msg or "connection" in msg.lower():
                raise AuthenticationError(f"Ollama connection failed: {msg}", provider=ProviderType.OLLAMA) from e
            if "429" in msg or "rate_limit" in msg.lower():
                raise RateLimitError(msg, provider=ProviderType.OLLAMA) from e
            if "context" in msg.lower() and "length" in msg.lower():
                raise ContextLengthError(msg, provider=ProviderType.OLLAMA) from e
            raise

分类依据是对异常文本做子串匹配,匹配不上就原样往上抛。同样形状的三段判断在 Claude 实现和 OpenAI 兼容实现里几乎一字不差地重复出现。这是个典型的工程折中:各家 SDK 的异常类型体系互不相同,要精确映射就得引入每家的异常类并逐一对齐,而字符串匹配零依赖、加一家不用改公共代码——代价是分类会错,下一节就说这个。

顺带一提各家的差异确实被收在了实现内部。Claude 那条实现会把所有 system 角色的消息抽出来合并成顶层 system 参数并挂上缓存标记,还会对特定模型前缀改传 thinking 而不传 temperature;两个 OpenAI 兼容端点共用一个基类,靠 api_key_envbase_url_envmodel_env 这几个类属性区分。这些细节调用方一概看不见。各家的接口规则不同且会调整,以官方最新说明为准。

五、边界与代价:这层明确不管的事

这是判断要不要抄这套结构的关键。以下每一条都是我在文件里读到的现状,不是缺陷指控——薄有薄的道理,但你得知道自己要补什么。

不管流式。 LLMInput 里有 stream 字段,LLMInput.to_dict() 也会把它序列化出去,但没有一个服务商实现是走 to_dict() 组装请求的——各家都在 generate 里手搭参数字典,谁都没读这个字段。真正落进请求体的只有本地实现硬编码的 "stream": False。想要流式输出,这层给不了。

不管重试和降级。 generate 就是一次请求。RateLimitError 只是被分类然后抛出,退避、切换备用服务商这些全部留给上层。想要降级链路得自己在外面套一圈,思路可以参考多模型 fallback 设计

不管成本归一。 LLMOutput.usage 是个 dict[str, int],各家塞什么就是什么:Claude 那条实现塞的是 input_tokensoutput_tokens 和两个缓存相关的键,OpenAI 兼容那条塞的是 prompt_tokenscompletion_tokenstotal_tokens。键名不统一,你要做用量统计得自己在外面对齐。

基本不管异步。 generate 是同步方法。整个包里唯一的 asyncReActAgent.run,但它循环内部调的仍然是同步的 provider.generate——换句话说 await 它并不会让出事件循环。

模型清单是写死的。 list_models 返回的是构造函数里硬编码的那份 ModelInfo 列表的浅拷贝,不会去问服务端。本地实现返回的三条固定条目,跟你本机实际拉了哪些模型没有关系。

配置自检不探活。 validate_config 在本地实现里只判断 base_url 字符串非空,在 Claude 实现里只判断 client 上的 api_key 非空。它回答的是「配置项填了吗」,不是「连得上吗」。

能力标注有两套且可能打架。 类级的 supports_tools() 默认返回 True,而本地实现里那几条 ModelInfosupports_tools 字段全是 False。同一个问题问 provider 对象和问 ModelInfo 条目会得到不同答案,读哪个由调用方自己决定。

工具那块也一样薄。ToolExecutor.execute 捕获所有异常并转成 is_error=TrueToolResultReActAgent 就是一个「调模型、有工具调用就执行、把结果塞回消息列表、再调」的定长循环,跑满上限后返回一个固定文案的输出。够跑通,离生产级的编排还有距离。

六、上手与避坑清单

配置文件是按当前工作目录找的。 解析器里那个常量就是相对路径 .llm.envPath(env_path).is_file() 判断的是进程 cwd 下有没有这个文件。你在项目根目录选好配置,换个子目录起进程就读不到了,仓库自带的解析器测试也正是靠切换工作目录来验证这条路径的。避法:要么固定进程 cwd,要么直接用 LLM_PROVIDER 环境变量——它的优先级本来就更高。

那个配置文件不在忽略清单里。 仓库 .gitignore 里列的是 .env 及其若干变体,没有覆盖 .llm.env。文件内容只有服务商名和模型名两行、不含密钥,但它会跟着你的提交进仓库。避法:自己补一条忽略规则。密钥本身怎么放另说,API 密钥安全管理那篇讲得更细。

选择器存了模型名,但解析器不读它。 save_config 写了 LLM_PROVIDERLLM_MODEL 两行,而 _resolve_provider_type 只取 LLM_PROVIDER。你在命令行里认认真真选的那个模型,不会自动生效在后续请求上——实际用的是各实现内部的默认模型。避法:模型名显式放进 LLMInput.model,别指望配置文件替你传。

metadata 会盖掉标准字段。 to_dict() 最后一行的 result | self.metadata 是右侧优先的字典合并。你往 metadata 里塞一个叫 modeltemperature 的键,就会把上面正经组装好的值顶掉,而且不报错。避法:metadata 只放服务商私有的、确定不与标准字段重名的键。

别拿异常类型直接当用户提示。 前面那段字符串匹配里,"connection" 命中的是 AuthenticationError——本地 Ollama 服务没起来,用户看到的会是一条鉴权失败。避法:日志和提示都带上原始 message,异常类型只用来决定程序分支,不用来生成人读的文案。

透传的构造参数没有统一签名。 get_provider**kwargs 原样交给构造函数,而各家收的参数并不一致:本地实现收 base_urldefault_model,Claude 实现收 api_keybase_url。写一个「通用」的调用点然后传一堆关键字,换家就 TypeError。避法:调用点按目标实现写死参数名。

温度参数在各实现之间行为不一致。 本地实现、astraflow 基类、atlas 三条都只在温度不等于默认值时才把它发出去——你显式把它设成那个默认值,请求里反而没有这个字段,最终生效的是服务端默认;而 openai.py 那条是无条件发送。同一个入参,换一家可能换语义,这类不一致恰恰是抽象层最难被外部察觉的地方。避法:看清你实际用的是哪条实现再依赖这个参数,别把它当成一定会发出去的开关。

它会往你的工作目录写文件。 选择器的 save_config 直接以写模式打开目标文件并覆盖,没有确认提示;persist=True 时还会顺手改当前进程的 os.environ。这是这类套件的常见行为,谈不上恶意,但在共享目录或 CI 里跑之前你得知道。

收个尾

判断要不要把这套结构搬进自己项目,就问三个问题:你的失败分支需不需要比五个异常类更细?你要不要流式和异步?你的用量统计能不能忍受各家键名不一致。三个都答「不需要」,这层照抄就够用;只要有一个答「需要」,你补的代码大概率会比这层本身还多。

接着读的顺序建议是:src/llm/core/types.py 先看全,因为四件套里其余三块全都在跟这些类型打交道;然后 src/llm/providers/resolver.py,它决定了你的配置到底从哪来;最后挑一个你实际要用的服务商实现从头读到尾。src/llm/tools/executor.py 可以放在最后,它跟前面几块是相对独立的两件事。

本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。