把多智能体评审做成可复用引擎: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 协议 | 配置 baseURL、apiKey |
特殊参数和非兼容协议仍需单独处理 |
| 统一 Provider 接口 | 先创建 Provider,再交给引擎 | 调用方多一步初始化;项目需要维护协议适配 |
| 协议优先,保留 Provider 扩展口 | 引擎直接接收连接参数,复杂场景再注入 Provider | 协议模式和注入模式两条路径都要维护 |
路线图最初倾向第三种,随着接入增加,我转向了第二种。从 0.6.0 开始,Core 要求显式传入 Provider 实例,Engine 不再接收连接参数。兼容协议仍然可以用,只是初始化移到了 Provider 工厂中:
1 | import { |
示例假定环境变量已配置,图片地址需替换为模型可以访问的实际地址。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 提供 createOpenAIChatProvider、createOpenAIResponsesProvider、createAnthropicProvider、createGeminiProvider 等工厂,封装对应 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_chunk。Lite 尚未接入逐项变化的评分面板,仍忽略这类事件;它实时展示阶段轨道与 reasoning,收到最终完成事件后才显示评分报告。
模型 JSON、业务事件和网络分包是三件事
调研时,我考虑过等完整 JSON、增量解析,以及让模型改成逐行生成 NDJSON。对 Venus 来说,报告本来就是一个嵌套对象,我选择保留它的结构,在 Provider 层解析中间状态,再把这些状态封装成业务事件。
1 | 模型服务的流式输出 |
这里有两种不同的“不完整”:
- 模型可能只生成了
{"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 | const imageUrl = 'https://example.com/photo.jpg'; |
partial 是当前的部分对象,不是等待拼接的文本,也不应假定它已经满足完整 Schema。尤其不能把 Proposer 的中间分数当作 Arbiter 的最终裁决。
SSE 与 NDJSON:同一套事件,两种封装
HTTP Adapter 同时支持 SSE 和 JSON Lines;后者在这里按 NDJSON 形式使用,每行一个 JSON 对象。
SSE 使用 text/event-stream。Venus 把业务事件序列化进单行 data:,以空行分隔事件。下面仅示意封装,省略时间戳等字段:
1 | 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 的 req、res,也不操作 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 因此提供 beforeEvaluate 和 beforeEvaluateGroup,在请求参数校验后、调用引擎前执行。下面节选单图钩子;resolveImageForKimi 是 Lite 自己的上传辅助函数,连接配置也由应用提供:
1 | import type { AdapterHooks } from '@theogony/venus-core'; |
组图钩子返回处理后的图片数组。它们是 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 管,上传和页面状态则留在应用。比起代码放在哪个目录,这才是抽离对我有用的地方。
流式交付让我对这种边界有了更具体的认识。模型正在吐字符、某轮结果已经完成、整次评估得到了裁决,是三个不同的状态。只有把它们分清楚,应用才知道什么时候可以预览,什么时候可以展示最终报告,什么时候必须告诉用户这次没有完成。