Cherry Studio ai-core 提供商调用观测(Per-Provider-Call Observation)指南
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
引言
Cherry Studio 的多提供商桌面客户端依赖自研的@cherrystudio/ai-core运行时来统一调度文本、图像、嵌入和重排序调用。本指南围绕该包最新的 patch 变更——observe-ai-core-provider-calls——展开:它为图像、嵌入和重排序运行时引入了可选的按提供商调用(per-provider-call)观测钩子,携带调用身份(invocation identity)、可用时的用量(usage)以及按调用计量的耗时和性能投影。通过本文,你将掌握如何通过onProviderCall回调捕获每次提供商调用的元数据,理解其底层实现与事件模型,并将其接入 Cherry Studio 的用量与计费体系。
变更内容一览
- 变更包:
@cherrystudio/ai-core(packages/aiCore) - 变更类型:
patch(向后兼容的增量增强) - 核心能力:为以下三种运行时新增可选的观测钩子,不改变现有调用行为:
generateImage(图像生成)embedMany(批量嵌入)rerank(重排序)
- 事件携带字段:调用身份(
requestId)、提供商与模型标识、可用时的用量(usage)、完成耗时(timeCompletionMs)与完成时间戳(completedAt),用于按调用级别的用量和性能投影。
该变更对应的 changeset 文件为.changeset/observe-ai-core-provider-calls.md,声明了对@cherrystudio/ai-core的 patch 级改动。
钩子类型与事件模型
在packages/aiCore/src/core/runtime/types.ts中定义了完整的观测契约:
RuntimeProviderCallHandler:观测回调类型,签名(event: RuntimeProviderCallEvent) => void。RuntimeProviderCallEvent:按模态区分的判别联合(discriminated union),分别对应嵌入、图像与重排序:
export type RuntimeProviderCallEvent = | { modality: 'embedding' requestId: string providerId: string modelId: string usage?: { tokens: number } metrics: { timeCompletionMs: number } completedAt: number } | { modality: 'image' requestId: string providerId: string modelId: string imageCount: number usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } metrics: { timeCompletionMs: number } completedAt: number } | { modality: 'rerank' requestId: string providerId: string modelId: string metrics: { timeCompletionMs: number } completedAt: number }各字段含义:
| 字段 | 说明 |
|---|---|
modality | 调用模态:embedding/image/rerank |
requestId | 调用身份,格式ai-core:<modality>:<uuid>,由运行时生成 |
providerId | 执行器配置的提供商 ID |
modelId | 实际执行调用的模型 ID |
usage | 提供商返回的用量(可用时) |
imageCount | 图像模态特有:本次调用生成的图片数量 |
metrics.timeCompletionMs | 从调用开始到完成的耗时(毫秒) |
completedAt | 完成时间戳(毫秒) |
嵌入模态的usage.tokens表示批次令牌数;图像模态的usage为可选的输入/输出/总令牌数;重排序模态目前不携带 usage。
参数接入方式
onProviderCall作为可选参数注入到三个方法的高阶参数类型中,见packages/aiCore/src/core/runtime/types.ts:
export type generateImageParams = Omit<Parameters<typeof generateImage>[0], 'model'> & { model: string | ImageModelV3 experimental_download?: Experimental_DownloadFunction onProviderCall?: RuntimeProviderCallHandler } export type EmbedManyParams = Omit<Parameters<typeof embedMany>[0], 'model'> & { model: string | EmbeddingModelV3 onProviderCall?: RuntimeProviderCallHandler } export type RerankParams<VALUE extends JSONObject | string = string> = Omit< Parameters<typeof rerank<VALUE>>[0], 'model' > & { model: string | RerankingModelV3 onProviderCall?: RuntimeProviderCallHandler }这些类型都保留了对model的字符串或模型对象二选一支持,字符串 ID 会通过执行器的提供商注册表解析为具体模型。
底层实现原理
RuntimeExecutor(packages/aiCore/src/core/runtime/executor.ts)负责实际的钩子注入,三个方法的实现各有特点:
图像生成:generateImage
通过 AI SDK 的wrapImageModel中间件包装解析后的图像模型:
const observedModel = onProviderCall ? wrapImageModel({ model: resolvedModel, middleware: { specificationVersion: 'v3', wrapGenerate: async ({ doGenerate, model: activeModel }) => { const startedAt = performance.now() const result = await doGenerate() emitProviderCall(onProviderCall, { modality: 'image', requestId: `ai-core:image:${crypto.randomUUID()}`, providerId: this.config.providerId, modelId: activeModel.modelId, imageCount: result.images.length, ...(result.usage ? { usage: result.usage } : {}), metrics: { timeCompletionMs: Math.max(0, Math.round(performance.now() - startedAt)) }, completedAt: Date.now() }) return result } } }) : resolvedModel批量嵌入:embedMany
通过wrapEmbeddingModel中间件实现,同样的计时与事件发射模式:
const observedModel = onProviderCall ? wrapEmbeddingModel({ model: embeddingModel, middleware: { specificationVersion: 'v3', wrapEmbed: async ({ doEmbed, model }) => { const startedAt = performance.now() const result = await doEmbed() emitProviderCall(onProviderCall, { modality: 'embedding', requestId: `ai-core:embedding:${crypto.randomUUID()}`, providerId: this.config.providerId, modelId: model.modelId, ...(result.usage ? { usage: result.usage } : {}), metrics: { timeCompletionMs: Math.max(0, Math.round(performance.now() - startedAt)) }, completedAt: Date.now() }) return result } } }) : embeddingModel重排序:rerank
与上述两个不同,重排序直接在_rerank调用成功后发射事件(目前 AI SDK 尚未提供等价的 wrap 中间件):
const startedAt = performance.now() const result = await _rerank<VALUE>({ model: rerankingModel, ...options }) emitProviderCall(onProviderCall, { modality: 'rerank', requestId: `ai-core:rerank:${crypto.randomUUID()}`, providerId: this.config.providerId, modelId: rerankingModel.modelId, metrics: { timeCompletionMs: Math.max(0, Math.round(performance.now() - startedAt)) }, completedAt: Date.now() })注意:rerank仅在提供商调用成功返回后才发射事件;若调用抛错,事件不会发出。
安全发射机制
所有模态的事件发射都经由统一的emitProviderCall辅助函数:
function emitProviderCall(handler: RuntimeProviderCallHandler | undefined, event: RuntimeProviderCallEvent): void { try { handler?.(event) } catch { // Usage observation is best-effort and must never change a successful AI result. } }这段注释明确了设计意图:用量观测是尽力而为(best-effort)的,绝不允许改变已经成功的 AI 调用结果。即使观测处理器内部抛出异常,也会被静默吞掉,原调用结果不受任何影响。
测试用例验证
packages/aiCore/src/core/runtime/__tests__/providerCall.test.ts使用@test-utils提供的 mock 模型(createMockProviderV3、createMockEmbeddingModel、createMockImageModel、createMockRerankingModel)对观测行为进行了系统性验证:
- 嵌入:每次 SDK 批次发射一个事件。向
embedMany传入 5 条文本、模型maxEmbeddingsPerCall = 2,AI SDK 内部拆分为 3 次底层doEmbed调用,观测事件同样为 3 个,且每个事件的usage.tokens分别为[2, 2, 1],3 个requestId互不相同。这印证了事件是按每次实际提供商调用(而非整个高层请求)粒度发射的。 - 图像:每次 SDK 批次发射一个事件。
n = 5、maxImagesPerCall = 2时底层调用 3 次,事件 3 个,imageCount为[2, 2, 1]。 - 重排序:仅在成功后发射。正常调用发射 1 个事件;当
doRerankmock 为 reject(抛provider failed)时,事件列表为空。 - 观测处理器抛错不影响结果。
onProviderCall内主动throw new Error('analytics unavailable'),embedMany依然正常 resolve,返回的embeddings与usage完整保留。
在 Cherry Studio 主进程中的接入实践
观测钩子并非孤立能力,而是已被 Cherry Studio 主进程的 AI 服务(src/main/ai/AiService.ts)实际消费。其接入模式如下:
捕获上下文的工厂函数
createProviderCallHandler将每次事件落盘为一条用量记录:
function createProviderCallHandler(context: AiUsageCaptureContext): RuntimeProviderCallHandler { return (event: RuntimeProviderCallEvent) => { aiUsageRecordService.recordInvocation({ requestId: event.requestId, context, modality: event.modality, ...(event.modality === 'embedding' && event.usage ? { usage: { inputTokens: event.usage.tokens, totalTokens: event.usage.tokens } } : event.modality === 'image' && event.usage ? { usage: { ...(event.usage.inputTokens !== undefined ? { inputTokens: event.usage.inputTokens } : {}), ...(event.usage.outputTokens !== undefined ? { outputTokens: event.usage.outputTokens } : {}), ...(event.usage.totalTokens !== undefined ? { totalTokens: event.usage.totalTokens } : {}) } } : {}), ...(event.modality === 'image' ? { imageCount: event.imageCount } : {}), metrics: event.metrics, completedAt: event.completedAt }) } }它在generateImage、embedMany、rerank三处调用点分别以imageUsageContext和usageContext传入(AiService.ts第 975、1150、1197 行附近)。
计费与用量记录的完整性
src/main/ai/hooks/billingHook.ts定义了可计费操作的覆盖矩阵AI_USAGE_RECORD_OPERATION_COVERAGE,明确标注了三种模态的捕获路径为ai-core-handler:
export const AI_USAGE_RECORD_OPERATION_COVERAGE = { streamText: { status: 'recorded', modality: 'language', capture: 'language-middleware' }, generateText: { status: 'recorded', modality: 'language', capture: 'language-middleware' }, embedMany: { status: 'recorded', modality: 'embedding', capture: 'ai-core-handler' }, generateImage: { status: 'recorded', modality: 'image', capture: 'ai-core-handler' }, rerank: { status: 'recorded', modality: 'rerank', capture: 'ai-core-handler' } } as const语言模态(streamText/generateText)通过语言模型中间件捕获,而嵌入、图像、重排序正是通过本文介绍的ai-core-handler路径捕获。
请求 ID 命名空间
docs/references/ai/ai-usage-records.md记录了请求 ID 的命名空间约定,其中 aiCore 提供商处理器的事件 ID 格式为:
ai-core:<modality>:<uuid>这与此前executor.ts中的实现完全吻合。
自定义接入示例
若要自行接入观测钩子,可按以下模式调用:
import { RuntimeExecutor } from '@cherrystudio/ai-core' const executor = RuntimeExecutor.create('openai', provider, { apiKey: 'sk-...' }) // 图像生成观测 await executor.generateImage({ model: 'dall-e-3', prompt: 'a cat', n: 1, onProviderCall: (event) => { console.log(event.modality, event.requestId, event.imageCount, event.metrics.timeCompletionMs) } }) // 批量嵌入观测 await executor.embedMany({ model: 'text-embedding-3-small', values: ['document 1', 'document 2'], onProviderCall: (event) => { if (event.modality === 'embedding' && event.usage) { console.log(`tokens: ${event.usage.tokens}`) } } }) // 重排序观测(仅在成功后触发) await executor.rerank({ model: 'reranker', query: 'query', documents: ['a', 'b'], onProviderCall: (event) => { console.log(event.modality, event.metrics.timeCompletionMs, event.completedAt) } })局限性与注意事项
rerank模态目前不携带usage字段;事件仅在调用成功后发射,失败调用不会产生观测事件。embedding/image事件仅在提供商返回了usage时才会带上usage字段(通过条件展开...(result.usage ? { usage: result.usage } : {}))。- 观测处理器为同步函数,建议在其中执行轻量逻辑(如记录日志、写入用量库);重活应异步化,避免拖慢主流程。
- 钩子是可选的:不传
onProviderCall时,wrapImageModel/wrapEmbeddingModel包装与计时逻辑整体跳过,对调用路径零开销。
相关文件索引
- 变更声明:
.changeset/observe-ai-core-provider-calls.md - 运行时实现:
packages/aiCore/src/core/runtime/executor.ts - 事件与参数类型定义:
packages/aiCore/src/core/runtime/types.ts - 观测行为测试:
packages/aiCore/src/core/runtime/__tests__/providerCall.test.ts - 主进程接入:
src/main/ai/AiService.ts - 计费覆盖矩阵:
src/main/ai/hooks/billingHook.ts - 用量记录文档:
docs/references/ai/ai-usage-records.md
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考