把多智能体评审做成可复用引擎:Venus 的工程实践

Venus 最开始是一个 Express 全栈应用。页面、上传、配置和评分代码都在一起,选一张照片,就能让模型评完并展示结果。这个阶段的好处是验证链路短,哪里不合适,都能一起调整。

评审流程跑通后,我想让同一套能力接到其他应用,也想按角色更换模型。抽离时,Express 反而不是主要障碍,Web 框架基本只出现在路由层。需要仔细拆开的,是模型端点、视觉与推理参数,以及混在一起的应用配置和核心配置。

现在,venus-core 负责评估,venus-lite 负责让用户使用它。这篇记录从原型到 SDK 的几处关键取舍:模型差异在哪里消化,结果如何成为可校验的契约,以及流式输出怎样变成应用能消费的反馈。

摄影标准与对抗评审的动机,放在了 《让 AI 的摄影评分接受质疑:Venus 的多智能体评审实践》。本文可以独立阅读,实现说明以写作时的 Core 0.16.0 及 Lite 代码为准。

一、先划清边界,再把核心拿出来

Venus 接收照片后,先确定摄影门类,再由初评者(Proposer)提出评分、批判者(Critic)质疑,必要时修正,最后交给仲裁者(Arbiter)裁决。后续阶段要用到前面的结果,走的是带条件分支的工作流,并没有把几个模型并行调用后取平均。

要抽离的就是这条工作流,以及它使用的标准和结果结构。图片怎么上传、凭证从哪里来、页面如何展示,仍然属于应用侧。

flowchart TB
    L[Venus Lite 与其他应用] --> W[Web Adapter]
    D[直接调用 SDK] --> E[Engine 流程编排]
    W --> E
    E --> A[Agents 角色任务]
    A --> P[Providers 模型协议适配]
    P --> V[外部模型服务]
    S[评分标准与 Schema] -.-> E
    S -.-> A
  • Engine 决定调用顺序、条件分支、事件和最终结果组装。
  • Agent 组织本轮任务的提示词与上下文,复用基础调用能力。
  • Provider 处理模型协议、请求参数和返回值差异。
  • Adapter 把核心能力接到 HTTP 框架。

从三条路线,到显式注入 Provider

早期独立化路线图比较过三种接入方式:

路线 简单场景怎么用 需要承担的代价
只走 OpenAI-compatible 协议 配置 baseURLapiKey 特殊参数和非兼容协议仍需单独处理
统一 Provider 接口 先创建 Provider,再交给引擎 调用方多一步初始化;项目需要维护协议适配
协议优先,保留 Provider 扩展口 引擎直接接收连接参数,复杂场景再注入 Provider 协议模式和注入模式两条路径都要维护

路线图最初倾向第三种,随着接入增加,我转向了第二种。从 0.6.0 开始,Core 要求显式传入 Provider 实例,Engine 不再接收连接参数。兼容协议仍然可以用,只是初始化移到了 Provider 工厂中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {
createOpenAIChatProvider,
createVenusEngine,
} from '@theogony/venus-core';

const provider = createOpenAIChatProvider({
baseURL: process.env.VLM_BASE_URL!,
apiKey: process.env.VLM_API_KEY!,
});

const engine = createVenusEngine({
provider,
defaultModel: process.env.VLM_MODEL!,
});

const result = await engine.evaluate(
'https://example.com/photo.jpg',
'portrait',
);

console.log(result.dimensions);
console.log(result.arbitrationNotes);

示例假定环境变量已配置,图片地址需替换为模型可以访问的实际地址。Core 不负责加载 .env;环境变量、配置文件或密钥服务,都是调用方的选择。

调用方要多创建一个对象,Engine 则不必再管模型服务怎么连接、底层使用哪家 SDK。这些细节留在 Provider,Engine 只组织摄影评估;各角色仍可以覆盖默认 Provider、模型和推理配置。

Core 独立后,我用 TypeScript 与 Bun 开发、测试和构建,分发 ESM/CJS 及类型声明。Bun 是开发工具选择,调用方不必更换应用运行时。

二、多模型接入的难点,不是换一个 baseURL

OpenAI-compatible API 降低了最初的接入成本,我也把它作为默认路径。接入时仍要逐项处理行为差异,不能只看请求格式是否相似。

请求参数和响应片段都需要适配

模型服务对推理能力的表达并不统一:有的使用 reasoning_effort,有的使用 enable_thinking,有的使用 thinking.type,预算字段和支持的档位也不同。Core 对外提供相对统一的 effort 和可选预算配置,再由 Provider 根据端点转换。

关闭推理也得专门处理。 一些模型默认开启推理。引擎没有配置 reasoning 时,Provider 若只是省略参数,请求仍可能处于推理模式。Core 为此做过修正,对相应端点显式发送关闭字段。

temperature 同样不能到处原样传递。有的模型在推理模式下不接受它,有的自行固定取值。统一填一个低温度,既不能保证评分稳定,也可能直接导致请求失败。

响应侧也有差异。reasoning 可能是增量文本、累计文本或摘要。例如,MiniMax 的一条流式接入路径返回累计的 reasoning,Provider 需要提取新增部分;如果直接拼接,页面就会不断重复前文。这里的 reasoning 只指服务实际返回的文本,不假定它包含完整内部思考过程。

按角色选模型时,我仍会逐项确认它的视觉、推理和结构化输出能力。

工厂统一创建方式,不抹平能力差异

Core 提供 createOpenAIChatProvidercreateOpenAIResponsesProvidercreateAnthropicProvidercreateGeminiProvider 等工厂,封装对应 SDK 与协议实现,返回统一的 LLMProvider

自定义 Provider 由 defineProvider 组装,调用实现保持不变。它会合并能力声明;如果没有显式覆盖流式能力声明,就根据是否提供 chatStream 来判断。工厂不会补上模型原本没有的图片能力,也不替调用方验证厂商的兼容性承诺。

这个接口也方便测试:替换成一个按顺序返回预设响应的 Provider,就能验证初评、质疑和裁决的流程,不必真的调用模型。

三、让结构化结果成为协作契约

下一轮 Agent 要接着读这些结果,前端也要按维度展示评分。字段缺失或含义对不上,整条链路都会受影响,不能只把结构化输出当作展示格式。

提示词、Schema 和界面从同一份配置取字段

Venus 的八个摄影门类各有不同的维度和子类型。字段没对齐,就会出现提示词要求输出一个维度、Schema 等着另一个键名、前端又显示第三种名称的问题。

Core 用门类配置集中记录维度键、显示名称和子类型。提示词从这里生成字段说明与输出示例,Zod 根据门类创建结果 Schema,元数据接口则向前端提供展示信息。

flowchart LR
    G[门类配置] --> P[提示词字段与示例]
    G --> Z[动态 Zod Schema]
    G --> M[前端展示元数据]
    S[摄影评分标准] --> P
    R[Agent 角色职责] --> P

这份配置统一的是字段和类型,详细的摄影标准仍然单独维护。Zod 则负责运行时校验:维度键是否正确、子类型是否在枚举内、分值是否处于 0–10,以及是否符合 0.1 的步进精度。

合法 JSON,不等于合格结果

json_object 主要约束 JSON 格式。对象即使语法合法,也可能漏掉维度、使用错误的子类型,或者把分数写成字符串。Core 按 Provider 声明的能力采用两条路径:

输出模式 Agent 调用策略 本地处理
json_schema 传入从 Zod 转换的严格 Schema,Agent 层单次调用 解析 JSON 并做 Zod 校验,失败直接报错
json_object 普通 JSON 输出 解析并校验;格式不合格时反馈错误,在有限次数内重新生成

即使服务端支持严格 Schema,我也保留了本地校验。不同端点支持的约束不同,Zod 中的自定义校验也不能全部转换成服务端理解的 JSON Schema。最终报告组装后,还要再验证公共结果契约。

目前 Chat Provider 使用 json_object 路径,其他 Provider 按端点能力选择,不能只凭 Responses 等接口名称判断。

这里有两种容易混淆的“修正”:格式修复处理少字段、类型错误;评审修正处理判断是否合理。Schema 可以拒绝 11 分,却不能证明 8 分比 7 分更符合照片。

组图扩展检验了契约是否够用

当前 Core 支持 2–10 张图片:joint 把整组当作一个系列评估,compare 输出组内排名和理由。整组图片共同进入每轮评审,不是逐张打分后求平均或排序。

我直接在已有角色上扩展组方法,沿用 Provider 路由、reasoning 配置和底层调用逻辑,没有另建一套 Group Agent。需要改动的主要是提示词、Schema 与结果结构。

组图还要处理结果与输入照片的对应关系,单图没有这个问题。我在 Schema 中校验图片下标和排名的完整性,避免重复、越界或遗漏。这类数组元素之间的约束,仍要靠本地校验来检查。

includePerImage 决定是否要求逐图明细。不需要时,提示词和 Schema 都去掉这部分,而不是生成后再从响应里删除。严格结构化路径由 Schema 约束,普通 JSON 路径则依赖模型遵守要求,多余内容仍有可能出现。

四、流式输出:收到字符,不代表用户已经获得反馈

一次评估要等多轮模型调用。如果页面始终只有一个转圈,用户不知道模型还在工作、卡在哪一步,还是请求已经失败。

最早,我实时输出 reasoning,结构化结果仍等每个阶段完成后一次性返回。模型虽然在流式生成 JSON,应用却要等完整文本才能解析,用户仍看不到结果的中间状态。

Core 现已提供用于结构化预览的 result_chunkLite 尚未接入逐项变化的评分面板,仍忽略这类事件;它实时展示阶段轨道与 reasoning,收到最终完成事件后才显示评分报告。

模型 JSON、业务事件和网络分包是三件事

调研时,我考虑过等完整 JSON、增量解析,以及让模型改成逐行生成 NDJSON。对 Venus 来说,报告本来就是一个嵌套对象,我选择保留它的结构,在 Provider 层解析中间状态,再把这些状态封装成业务事件。

1
2
3
4
5
6
7
8
9
10
模型服务的流式输出
↓ Provider:提取 reasoning、保留 content、增量解析 JSON
StreamChunk
↓ Agent:透传中间片段,结束后校验完整结果
↓ Engine:补充角色与阶段信息
EvaluationStreamEvent
↓ Adapter:封装 HTTP 响应
SSE / JSON Lines
↓ 调用方:缓冲网络分包、还原事件
进度、文本、中间预览与最终报告

这里有两种不同的“不完整”:

  • 模型可能只生成了 {"dimensions":{"lighting_quality":,它还不是完整 JSON。
  • 服务端发出的业务事件即使完整,浏览器的一次 reader.read() 也可能只拿到半条,或一次拿到多条;中文字符还可能跨字节块拆开。

前者需要增量 JSON 解析,后者需要网络缓冲与字符解码,不能每次网络读取后就直接 JSON.parse()

中间预览与最终结果必须有不同语义

Core 在 Provider 层使用 vectorjson 解析模型的 JSON 文本。每收到一个文本片段,Provider 就尝试从解析器取出当前可解析的部分对象;取不到时仍保留原始 content。Agent 则累积完整文本,在本轮结束后重新解析并校验。

Engine 把这些片段转为业务事件:

事件 调用方可以怎样理解
reasoning_chunk 本轮的推理文本片段
result_chunk 本轮的部分结构化对象,只能作为中间预览
agent_complete 本轮结果已完成并通过校验,但未必是最终裁决
evaluation_complete 单图评估的最终报告

流模式控制事件粒度:values 只提供里程碑,updates 额外提供 reasoning 与 JSON 中间状态。切换模式不改变评分算法,也不决定是否启用推理,后者仍由 reasoning 配置控制。

如果 Provider 没有实现 chatStream,Agent 会退回非流式调用,无法提供逐字反馈。

调用方可以直接消费异步迭代器,不经过 HTTP。接着前面的引擎初始化示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const imageUrl = 'https://example.com/photo.jpg';

for await (const event of engine.evaluateStream(imageUrl, {
genre: 'portrait',
mode: 'updates',
})) {
switch (event.type) {
case 'reasoning_chunk':
console.log(event.agent, event.content);
break;
case 'result_chunk':
console.log('中间预览:', event.partial);
break;
case 'evaluation_complete':
console.log('最终结果:', event.data);
break;
case 'error':
console.error(event.error.message);
break;
}
}

partial 是当前的部分对象,不是等待拼接的文本,也不应假定它已经满足完整 Schema。尤其不能把 Proposer 的中间分数当作 Arbiter 的最终裁决。

SSE 与 NDJSON:同一套事件,两种封装

HTTP Adapter 同时支持 SSE 和 JSON Lines;后者在这里按 NDJSON 形式使用,每行一个 JSON 对象。

SSE 使用 text/event-stream。Venus 把业务事件序列化进单行 data:,以空行分隔事件。下面仅示意封装,省略时间戳等字段:

1
2
data: {"type":"reasoning_chunk","agent":"proposer","content":"开始观察光影"}

NDJSON 使用 application/x-ndjson,同样的业务事件直接按行写出:

1
{"type":"reasoning_chunk","agent":"proposer","content":"开始观察光影"}

Lite 使用 fetch 发起 POST,提交图片地址、门类和上下文,再读取 SSE 响应流。它不使用原生 EventSource,也不会自动获得其重连行为。NDJSON 则便于脚本、CLI 或服务端逐行处理,无需解析 SSE 封装。

NDJSON 的一行可以是完整 JSON,里面的 partial 却可能只是部分评估结果。 SSE 和 NDJSON 都不要求模型逐行生成报告。

五、用 Venus Lite 检验 SDK 的边界

Core 提供了事件和结果,上传、配置、请求生命周期和展示仍要由应用处理。我用 Lite 把这些工作接起来:它使用 Nuxt 4、Vue 3 和 Nitro,通过 Core 的 Nitro Adapter 接入评估。

应用准备输入,Adapter 翻译 HTTP

Core 的 API 不接收 Express 的 reqres,也不操作 Hono 或 Nitro 的上下文。HTTP 层由三种 Adapter 承担:公共模块集中处理请求校验、钩子执行、错误映射与事件格式化,各 Adapter 绑定对应框架的路由和响应。

适配器通过 @theogony/venus-core/hono/express/nitro 子路径导出,框架作为可选 peer dependency。调用方只需安装所用 Adapter 对应的框架,直接使用引擎则不需要。

Lite 服务端根据运行时配置创建 Provider 和引擎,模型密钥也留在服务端。

用户选择图片后,客户端提取 EXIF,再用服务端签发的 STS 临时凭证直传 OSS。上传逻辑还负责 SHA-256 去重与分片上传。这些都是应用能力,不应成为评分引擎的前置依赖。

EXIF 作为可选上下文传入 Core,不能代替照片本身,也不能因为缺少 EXIF 就推断作品存在问题。

特殊图片准备放在生命周期钩子中

Lite 的 Kimi 接入路径需要先把图片上传到文件接口,换成 ms:// 地址,再交给评估流程。这不是摄影评估本身的工作,也不是每个调用方都需要,我把它留在应用侧。

Adapter 因此提供 beforeEvaluatebeforeEvaluateGroup,在请求参数校验后、调用引擎前执行。下面节选单图钩子;resolveImageForKimi 是 Lite 自己的上传辅助函数,连接配置也由应用提供:

1
2
3
4
5
6
7
8
9
10
11
12
import type { AdapterHooks } from '@theogony/venus-core';

const hooks: AdapterHooks = {
beforeEvaluate: async (params) => ({
...params,
imageUrl: await resolveImageForKimi(
params.imageUrl,
baseURL,
apiKey,
),
}),
};

组图钩子返回处理后的图片数组。它们是 Adapter 的扩展点,不是每轮 Agent 调用都会执行的插件系统;直接调用 engine.evaluate() 也不会自动触发。

钩子的网络请求和资源管理由应用负责。Core 检查 HTTP 图片 URL 是否指向私有主机,Lite 的预上传路径还检查远程获取目标与重定向。这些措施能缓解风险,但不能替代部署环境的网络访问策略。

传给模型的图片、备注和元数据仍然是不可信输入。

消费流,还要处理结束、取消和重试

Lite 的单图、组图联合与组图对比页面,共用 useEvaluationStream 中的流读取逻辑。上传、EXIF、分享图片生成等副作用也各有对应的 composables,组件负责组合交互和展示。

流消费者把尚未读完整的数据留在缓冲区,用 TextDecoder 的流式模式处理跨块字符,流结束后再处理剩余数据。它区分最终结果、错误事件与意外结束:连接关闭却没收到完成事件,不能算评估成功。当前解析器只针对 Venus 的单行 data: 封装,不是通用 SSE 客户端。

页面通过 AbortController 在离开组件或开始新评估时中止旧请求。但浏览器请求中止,不代表服务端到模型服务的整条调用链都已取消,也不能保证模型停止计费。

如果一轮输出在结束时才校验失败,前面的片段可能已经展示。重新生成时,消费方需要识别这是一次新尝试,或按约定重置旧内容,不能接着追加。当前 Core 事件还没有单独的重试尝试标识,消费方仍需谨慎处理。

测试契约,不把模型分数写死

已有测试也按这些边界分开:

层次 已有测试关注的问题
Agent 与 Engine 用 Mock Provider 固定响应,检查修正分支、结果校验、模型路由和流事件
Adapter 请求是否合法、钩子是否执行、SSE 与 JSON Lines 是否正确封装
Lite 流分包、尾部缓冲、步骤状态,以及配置和组件消费行为
真实 Provider 集成 实际接入下的事件粒度、结果结构与字段范围

真实 Provider 测试需要显式开启,避免默认测试和构建依赖外部 API。测试不会断言某张照片必须得到某个具体分数。

这些测试说明软件有没有按契约工作,不能说明摄影评得准不准。后者需要固定照片集、重复评估和人工核对,是评审设计那篇文章讨论的另一组实验。

结语:复用的是一条有边界的评估链路

Core 抽出来后,评估流程更容易测试、接入和修改,我也更容易判断一个问题该改在哪里。连接和参数有差异,就去看 Provider;结果形状不对,检查 Schema;调用顺序由 Engine 管,上传和页面状态则留在应用。比起代码放在哪个目录,这才是抽离对我有用的地方。

流式交付让我对这种边界有了更具体的认识。模型正在吐字符、某轮结果已经完成、整次评估得到了裁决,是三个不同的状态。只有把它们分清楚,应用才知道什么时候可以预览,什么时候可以展示最终报告,什么时候必须告诉用户这次没有完成。

参考资料与实现