插件化架构设计:打造可扩展的LLM评测框架
发布时间:2026/10/22 12:06:47 锦皓数字建站

做 LLM 应用的测试评估最折磨人的不是写用例而是每次换一个模型、加一个评测维度、接一种数据源都得把整个调度流程推倒重来。DeepSeek Harness 最初是一套内部跑批的评测框架专门用来批量跑 DeepSeek 模型的 prompt、采集输出、做量化对比。用着用着就发现凡是和真实业务沾边的评测需求都长得不一样有的团队要接线上日志有的要对比多版本模型回答还有的要输出合规审核报告。改到第八轮需求的时候我决定不再往里堆代码而是把它改造成一个插件化的架构代号就叫 Cordis。这篇文章不聊怎么调 prompt也不聊模型本身的效果只聊 Harness 的插件架构怎么设计、接口怎么定、插件怎么写、以及我实际踩过的坑。适合正在搭 LLM 评测工具、写 Agent 测试平台、或者盘算着把内部脚本改造成可扩展系统的开发者。看完之后你至少能回答一个问题一个评测框架到底哪些部分应该让外部插件来实现哪些必须自己牢牢攥在手里。1. Cordis 插件架构的整体设计与思路拆解1.1 为什么 Harness 必须插件化在说 Cordis 之前先回顾一下 Harness 最初的样子。它其实是一个 Python 写的批处理调度程序核心功能很简单读一批 JSONL 用例每条用例包含 system prompt 和 user prompt调用 DeepSeek 模型拿到回复然后按几个固定的规则去判断输出好不好最后把结果汇总成一个 CSV。这套东西在模型评估的早期阶段是够用的因为评测维度固定、数据格式统一、输出目标明确。问题出在项目接入真实业务之后。第一个变化是数据源开始分化。有的团队拿到的用例是 JSONL 文件有的存在数据库里还有的是从线上接口实时拉。第二个变化是评测规则从“字符串包含”变成了多模型交叉评分、可引用性检查、语气合规检测。第三个变化是输出报告不再是一张 CSV而是不同客户要不同格式。每次出现这类新需求我都得改动 Harness 的主流程然后担心改坏原来的核心逻辑。这种改动的隐形成本在代码量小的时候看不出来当主流程超过一千行每个新需求都变成一次小型的重构。做插件化的第一动机不是为了让代码“优雅”而是为了让四个团队能各自交付自己的部分而不互相打断。A 团队负责新增数据源B 团队负责调整评测逻辑C 团队负责输出报告D 团队维护核心调度。如果没有清晰的扩展边界这四个团队的改动最终会挤进同一个文件里合并冲突只是时间问题。Cordis 要解决的核心问题就是把这四类改动变成各自独立的插件单元。1.2 三个扩展点什么是核心什么是边界插件架构设计的第一步是划分哪些逻辑是框架自己必须做的哪些是可以交给插件扩展的。我当时画了一条线调度编排、插件生命周期、数据在插件之间的传递这三件事必须由 Harness 自己管而数据源怎么读、评测逻辑怎么判、报告长什么样这三件事完全可以交给插件。最终 Cordis 确立了三个扩展点扩展点职责输入输出source把原始用例转换为统一的 Case 对象配置项、原始数据Case 列表evaluator对模型输出做评测并产生打分结果Case、模型输出EvaluationResultreporter把评测结果汇总为指定格式的报告评测结果集合报告文件或数据这三个扩展点覆盖了绝大多数评测类工具的核心链路数据进、模型跑、结果判、报告出。选择这三个点而不是更多是因为它们之间的边界稳定。数据源不会去关心评测逻辑怎么打分评测插件也不需要知道用例到底存在哪里报告插件只消费结果数据。边界清晰插件之间就不需要互相知道细节。有一个我后来才想明白的点不要在插件架构里过早引入“中间件”或“过滤器”这种概念。我一开始想在 source 和 evaluator 之间加一层预处理插件用来清洗文本、去掉敏感词。结果写了两版发现所谓预处理本质上就是一种特殊的 evaluator 或者数据转换逻辑单独给它一个扩展点只会让插件之间的调用关系变得复杂。最终我把预处理逻辑放进了 evaluator 的内部实现里框架本身不感知。2. 核心细节解析生命周期、配置与事件总线2.1 插件生命周期从注册到销毁的五个状态Cordis 的插件生命周期是我改动次数最多的一块。最初版本只有两个状态加载和卸载。但实际跑起来发现插件在初始化时要做的事情比想象中多得多要读配置、要连数据库、要预热模型、要检查依赖项是否就绪。如果把这些全部塞进加载阶段任何一个小步骤失败都会导致整个框架启动失败而且很难定位是插件自身的问题还是外部依赖的问题。于是我把生命周期划分为五个状态每个状态过渡时都有一个明确的钩子方法状态钩子方法典型操作失败处理DISCOVEREDdiscover扫描插件目录、解析 manifest忽略不合法插件REGISTEREDregister注册到插件注册表、绑定事件依赖缺失时跳过INITIALIZEDinit读取配置、建立连接重试三次后标记失败STARTEDstart启动后台任务、加载数据失败则回滚初始化STOPPEDstop / destroy释放连接、清理文件记录清理警告顺序上我特意把 register 和 init 分开。register 只做元信息登记不碰任何真实资源所以即使在配置错误的情况下框架也能拿到插件的依赖信息。init 才去创建真实连接这时候插件如果失败框架可以把它单独标记为不可用而不会拖垮整个启动过程。生命周期状态机的实现大概长这样。核心是一个 PluginManager它维护着一份插件 ID 到状态的映射并且提供 ensure_state 方法让所有插件一起走到某个状态而不是各跑各的_STATE_ORDER { PluginState.DISCOVERED: 0, PluginState.REGISTERED: 1, PluginState.INITIALIZED: 2, PluginState.STARTED: 3, } class PluginManager: def __init__(self): self._registry {} # plugin_id - PluginInstance self._states {} # plugin_id - PluginState def discover(self, plugin_dir): for manifest in scan_manifests(plugin_dir): if validate_manifest(manifest): self._registry[manifest.id] PluginEntry.from_manifest(manifest) self._states[manifest.id] PluginState.DISCOVERED def start_plugin(self, plugin_id): entry self._registry[plugin_id] if self._states[plugin_id] ! PluginState.INITIALIZED: raise RuntimeError(fplugin {plugin_id} not initialized) try: entry.instance.start(self.context_for(plugin_id)) self._states[plugin_id] PluginState.STARTED except Exception as e: self._states[plugin_id] PluginState.FAILED self._deactivate(plugin_id) raise PluginStartError(plugin_id, e) def ensure_state(self, plugin_id, target: PluginState): while _STATE_ORDER[self._states[plugin_id]] _STATE_ORDER[target]: self._advance(plugin_id)这里最需要注意的一个细节是生命周期钩子与外部资源的关系。init 阶段建立数据库连接之后如果 start 阶段失败一定要有一种机制能把连接释放掉否则进程退出时会出现大量“zombie connection”。我在实现时让每个插件实例都持有自己的资源清单stop 时统一清理。2.2 配置驱动的插件声明写在 YAML 里的依赖关系生命周期管理的是插件“何时被拉起、何时被停止”而插件自身的配置怎么声明、怎么校验是另一块同样容易踩坑的细节。Cordis 里每个插件的目录下都有一份 plugin.yaml用来告诉 Harness 这个插件叫什么、提供什么能力、依赖哪些配置项。我坚持用声明式配置而不是在代码里写死是因为插件一旦多起来配置管理就会变成新的噩梦。一个评测插件可能需要知道模型输出的 JSON 路径一个数据源插件可能需要数据库的 URI这些信息不应该散落在各个插件自己的常量文件里而是应该在启动时由一个统一配置中心下发。一份典型的 plugin.yaml 长这样id: file-source name: JSONL File Source version: 0.3.2 entry: source.py:FileSourcePlugin extension_point: source config_schema: paths: type: array items: type: string required: true description: JSONL 文件路径列表 encoding: type: string default: utf-8 dependencies: - id: utils-json version: 0.1.0我特意给每个插件加了一个 config_schema 字段并以此做启动前的配置校验。这块一开始没有做后来吃了大亏有一次数据源插件在 init 阶段才去读 paths 配置结果某个环境变量没传插件报了一个模棱两可的 KeyError排查了半天才发现是配置缺失。加了 schema 校验后Harness 在启动阶段就把配置错误拦截下来并且能给出明确的报错信息“file-source 插件缺少 paths 配置项”。这一点对于多团队协作的项目尤其重要因为不同团队对“配置错误”的理解完全不同。依赖声明也很关键。Cordis 支持在 manifest 里写插件依赖关系启动时 PluginManager 会按拓扑序加载。这里我不建议做得太重不要去自己实现一套包管理器只需要做到“加载前检查依赖是否存在、版本是否匹配”就够了。真正的 Python 包依赖交给虚拟环境和包管理工具去做不要在 Harness 里再做一层。2.3 事件总线让插件之间不要互相 import插件架构里最容易失控的一件事是插件之间直接互相调用。数据源插件想通知评测插件“批次数据已经准备好了”最简单的做法就是评测插件 import 数据源插件的模块。一开始没问题插件多了以后就是一团乱麻。Cordis 引入了一个轻量的事件总线让插件之间只通过事件通信。事件总线的设计非常轻核心就是两个动作事件发布和订阅。from collections import defaultdict class EventBus: def __init__(self): self._subscribers defaultdict(list) def emit(self, event: str, **payload): for handler in self._subscribers[event]: handler(event, payload) def subscribe(self, event: str): def decorator(fn): self._subscribers[event].append(fn) return fn return decorator def unsubscribe(self, event: str, fn): self._subscribers[event] [ h for h in self._subscribers[event] if h ! fn ]用事件总线之后数据源插件发布 case_loaded、evaluator 插件订阅这个事件框架本身完全不用关心哪两个插件产生了协作。每接入一个新插件只需要知道它感兴趣的事件名即可。坏处是事件满天飞的时候调试会变得痛苦你不知道哪个事件是谁发的。我的建议是给事件名强制添加命名空间比如 data_source.file.case_loaded、evaluator.similarity.scored这样 grep 日志时能快速定位。3. 实操过程落地一个典型插件的完整流程3.1 第一步接口定义与语义约定在动手写插件之前我必须先定义好每个扩展点的接口。这个步骤不能省因为插件的作者可能不是同一个团队的人接口语义一旦含糊后续的协作成本会直线上升。Cordis 对插件作者暴露的接口很简单三个基类分别对应三个扩展点# harness/interfaces.py from abc import ABC, abstractmethod class SourcePlugin(ABC): abstractmethod def load(self, ctx) - list[Case]: ... class EvaluatorPlugin(ABC): abstractmethod def evaluate(self, case: Case, model_output: str, ctx) - EvaluationResult: ... class ReporterPlugin(ABC): abstractmethod def render(self, results: list[EvaluationResult], ctx) - ReportOutput: ...为了减少模板代码我对每个接口都提供了一组默认实现。比如 SourcePlugin 基类里内置了分批读取的逻辑插件作者只需要实现一个迭代器即可EvaluatorPlugin 基类里内置了并发调度、超时控制、重试逻辑。这样做的目的是让新插件能够以最小代价接入框架而不是要求插件作者把整个框架的内部机制都理解一遍。3.2 第二步实现一个数据源类插件以 file-source 这个插件为例它的职责是读取一个 JSONL 文件把每一行数据转换为统一的 Case 对象。Case 对象是 Cordis 里最重要的数据契约它的字段包含 case_id、system_prompt、user_prompt、expected_output、tags 等。不管底层数据是 JSONL、数据库还是线上日志对 Harness 核心调度来说它们都是 Case 列表。file-source 的实现大致如下# file_source.py import json from harness.interfaces import SourcePlugin from harness.models import Case class FileSourcePlugin(SourcePlugin): def __init__(self): self.paths [] self.encoding utf-8 def init(self, ctx): conf ctx.config self.paths conf[paths] self.encoding conf.get(encoding, utf-8) def load(self, ctx): for path in self.paths: with open(path, r, encodingself.encoding) as f: for line_no, line in enumerate(f, 1): line line.strip() if not line: continue record json.loads(line) yield self._to_case(record, path, line_no) def _to_case(self, record, path, line_no): return Case( case_idstr(record.get(id, f{path}:{line_no})), system_promptrecord.get(system_prompt, ), user_promptrecord.get(user_prompt, ), expected_outputrecord.get(expected_output, ), tagsrecord.get(tags, []), )注意到这个插件的 load 方法是一个生成器这是 Cordis 的一个约定数据源插件必须支持流式加载。因为真实场景中一次评测可能包含几十万条用例如果全部读到内存里机器会直接被打爆。生成器可以让 Harness 一条条消费用例评测完一批就释放一批。实现时还有两个细节要注意。第一JSON 解析不能失败就中断整个任务我建议在插件内部做容错解析失败的行要记录到 skip_log 里并继续下一行。第二插件里不要直接 print要使用 ctx.logger这样才能被 Harness 的日志系统统一收集。3.3 第三步实现评测与报告插件并完成装配测评插件是 Cordis 里变化最多的部分。我从一开始就意识到不能让评测插件只是简单调一个函数因为评测逻辑可能是模型调用、规则判断、甚至人工复核的混合体。所以 EvaluatorPlugin 接口返回的是 EvaluationResult它本身是一个数据类包含 score、reason、details、evaluator_name 等字段。这样设计的好处是评测结果可以被后续的报告插件任意加工而不需要关心评测逻辑内部是怎么算出来的。下面是一个简单的相似度评测插件的实现# similarity_eval.py from difflib import SequenceMatcher from harness.interfaces import EvaluatorPlugin from harness.models import EvaluationResult class SimilarityEvaluator(EvaluatorPlugin): def evaluate(self, case, model_output, ctx): if not case.expected_output: return EvaluationResult( scoreNone, reasoncase 缺少期望输出, passedNone, ) sim SequenceMatcher(None, case.expected_output, model_output).ratio() threshold ctx.config.get(threshold, 0.7) return EvaluationResult( scoresim, reasonfsimilarity{sim:.3f}, threshold{threshold}, passedsim threshold, )报告插件则消费所有评测结果把它们渲染成指定格式。这里要特别注意报告插件不应该自己去拉数据而应该从 Harness 传入的结果集合中取数。报告插件的 render 方法只有一次调用但它的内部可能要把结果按源文件分组、按标签聚合、做统计直方图等。完成插件的编写后剩下的就是装配。把插件的目录路径、启用的配置、事件订阅关系写进一份 launch.yaml然后交给 Harness 启动harness: plugins: - path: ./plugins/file-source enabled: true - path: ./plugins/similarity-eval enabled: true config: threshold: 0.65 - path: ./plugins/markdown-reporter enabled: true config: output_path: ./reports/result.md run_id: bench-20250411启动后Harness 会按照 manifest 声明顺序执行生命周期钩子并不断驱动数据流source 产出 Case、evaluator 消费 Case 和模型输出、reporter 聚合结果。模型输出这一环由 Harness 核心负责它会调用 DeepSeek 模型拿到回复后再交给 evaluator插件作者不需要关心模型调用细节。4. 常见问题与排查技巧实录4.1 插件加载失败的典型原因实际用下来插件加载失败的原因集中在这几类我整理了一张速查表现象可能原因排查方法插件被跳过日志没有报错manifest 格式不合法DISCOVERED 阶段被过滤用 schema 校验工具检查 plugin.yaml启动时报 config_schema 校验失败plugin.yaml 的配置项与实际传入不一致查看校验错误中列出的缺失字段init 阶段重试三次后失败外部依赖未就绪数据库、模型服务手动测试依赖连通性然后再启动插件版本冲突依赖检查不过插件的依赖版本声明过于宽泛或过窄检查依赖声明版本区间统一到一个版本有一个我反复遇到的坑插件升级后manifest 里声明了新的必填配置项但老的 launch.yaml 里没有更新导致启动校验失败。这个问题看起来低级实际很常见因为插件和配置是独立演进的。我的解决办法是在报错信息里同时打印“当前配置缺失项”和建议新增的默认值这样使用者可以照着改。4.2 插件间的数据传递Context 与 Result 的生命周期陷阱Cordis 里每个插件在执行时都会拿到一个 PluginContext里面包含了配置、日志、事件总线等。这个对象在插件运行期间是复用的所以我在设计时特别小心不允许插件在 ctx 上修改共享状态。否则两个插件同时写同一个配置字段结果会变得不可预测。但真正让很多人踩坑的是 EvaluationResult 的生命周期。评测插件返回的 EvaluationResult 是可变对象报告插件拿到它之后如果直接修改 score 字段会污染原始数据。尤其当多个报告插件并发读取同一批结果时会出现拿到不同数值的情况。我们的约定是EvaluationResult 一旦生成就不可变报告插件需要加工数据时复制一份再处理。4.3 调试插件时的三个实用技巧第一个技巧是给插件加一个 dry-run 模式。我一直认为这种工具型架构的调试成本主要在“环境搭建”而不是代码本身。插件如果能在不跑完整任务的条件下单独执行调试效率会高非常多。我的做法是给每个插件加一个自检方法插件只加载自己的配置跑一遍核心逻辑然后把输出 dump 成一个 JSON 文件。第二个技巧是在插件代码里增加性能埋点。评测任务通常要跑很久如果某个插件太慢整个 pipeline 都会被拖住。Cordis 提供了一个 TimerContext插件作者可以在关键路径上包一层把耗时记录到日志里。上线几天后你就能看出哪个 stage 是瓶颈而不用靠猜。第三个技巧是准备一个 mock 模型。很多评测插件需要真实调用模型才能返回结果这会大幅拖慢开发速度。我给 Harness 加了一个 mock_model 基础设施插件它不调用任何真实模型而是从预设的响应列表里读数据。写评测插件的时候先用 mock 模型跑通逻辑再切换到真实模型能节省大量时间。5. 从架构师视角复盘这次设计5.1 设计上的三次让步第一个让步是事件总线。我最初的设想是插件间所有通信都走事件这样彻底解耦。但实际用下来有些场景用事件反而绕。比如 reporter 需要产出的报告依赖 source 插件的原始文件名如果这个信息也要通过事件传递就得定义一个“文件名事件”很别扭。后来我允许插件通过 ctx 读取一部分共享上下文数据事件只用来通知“时机”不负责传业务数据。虽然不那么纯粹但更务实。第二个让步是配置中心。最初我想做一个分布式配置中心所有插件的配置都通过 API 下发。后来发现单机的评测任务根本用不到这么复杂的机制。最终配置中心退化为一个 YAML 文件加环境变量覆盖谁用谁知道简单的东西才不容易坏。第三个让步是插件版本管理。我原本打算在 manifest 里写完整的语义化版本校验前一段时间真的给每个插件都写了 version、changelog、贡献者列表。后来发现大多数插件只有团队内部使用根本不需要那么重的元信息。最终轻量化到只有 id、entry、extension_point 三项是必填其余都是可选。5.2 如果再写一遍我会保留与舍弃什么如果重新写一遍我会保留三件事生命周期状态机、配置 schema 校验、mock 模型的思路。这三件事解决的都是真实项目中必然会出现的痛点。我会舍掉的是过度设计的“可扩展性”。一开始我把 Cordis 设计得过分灵活插件可以覆盖框架的几乎所有行为结果带来了大量配置项和分支逻辑。后来发现框架的默认行为应该是最简洁、最易用的路径插件是用来扩展而非替代默认行为的。过度的灵活性等于没有默认推荐对团队协作反而是坏事。最后再分享一个小经验。插件架构做得好不好最直接的检验方式不是看抽象图而是看接入一个新需求需要动多少现有代码。Cordis 改完之后后面给 Harness 加了云端真机评测、语义缓存、报告推送等若干新能力全部走的是新插件接入没有一次需要改动核心调度代码。对一个评测工具来说“不因为新需求而流血”比任何架构指标都更实在。如果你也在做类似的工具建议从最小的三个扩展点开始先跑通再扩充别一开始就追求把所有逻辑都塞进插件里。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。