effect AI 中的 EmbeddingModel:从 eff-718 变更集看核心服务、批处理语义与 OpenAI 提供者实现
2026/9/13 8:26:57 网站建设 项目流程

effect AI 中的 EmbeddingModel:从 eff-718 变更集看核心服务、批处理语义与 OpenAI 提供者实现

【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code

本文以 effect-smol 仓库中的变更集eff-718-embedding-model-surface.md为核心,完整解读其宣布的功能:在effect核心包中新增不稳定的EmbeddingModel服务(含服务标签、请求/响应/提供者类型与运行时构造器),并在@effect/ai-openai@effect/ai-openai-compat两个提供者包中落地 OpenAI 嵌入模型实现。读完后,你将理解embed/embedMany的批处理(batching)语义、确定性排序与索引校验机制,以及model/make/layer/withConfigOverride各构造器的用法差异。

变更集说了什么

变更集文件 .repos/effect-smol/.changeset/pre/eff-718-embedding-model-surface.md 声明了三个包的 patch 版本变更,并列出五点内容:

  1. effect中新增不稳定的EmbeddingModel模块 API 表面,包括服务(service)、请求(request)、响应(response)和提供者(provider)类型;
  2. effect中实现不稳定的EmbeddingModel运行时构造器,具备RequestResolver批处理、embed/embedManyspan、提供者错误传播、确定性排序,以及空输入embedMany的快速路径(fast-path)行为;
  3. effect中新增并对齐EmbeddingModel行为测试,覆盖嵌入使用、批处理、排序和错误处理;
  4. @effect/ai-openai中新增OpenAiEmbeddingModel,包括 model / make / layer 构造器、配置覆盖,以及带确定性重排(deterministic reordering)的提供者输出索引校验;
  5. @effect/ai-openai-compat中新增 OpenAI 兼容的EmbeddingModel提供者支持,包括配置覆盖、layer 构造器和输出索引校验。

下面逐条对照源码展开。

核心服务:effect/unstable/ai/EmbeddingModel 的 API 表面

全部类型定义在 EmbeddingModel.ts,并通过桶文件 unstable/ai/index.ts 以export * as EmbeddingModel from "./EmbeddingModel.ts"的形式导出,即从effect/unstable/ai导入后可直接使用EmbeddingModel命名空间。该模块标记@since 4.0.0,且位于unstable/ai子路径下——即这是一套仍在演进中的 API,导入路径为effect/unstable/ai而非稳定入口。

服务标签

  • EmbeddingModel:嵌入模型服务标签,实现为Context.Service,tag 名为"effect/unstable/ai/EmbeddingModel"。程序通过它获取嵌入能力。
  • Dimensions:提供当前嵌入向量维度的服务,tag 为"effect/unstable/ai/EmbeddingModel/Dimensions",服务值类型为number。它独立于嵌入服务本身存在,便于下游(如向量存储层)在不做任何嵌入请求的前提下拿到维度配置。

数据模型

模块用Schema.Class定义了可序列化的数据模型:

  • EmbeddingUsage:嵌入操作的 token 用量元数据,仅含一个可选字段inputTokens: Schema.optional(Schema.Finite)。文档注释明确说明:当提供者不上报用量,或embedMany([])绕过了提供者时,该值为undefined
  • EmbedResponse:单次嵌入请求的响应,形状为{ vector: Array<Finite> }
  • EmbedManyResponse:批量嵌入请求的响应,形状为{ embeddings: Array<EmbedResponse>, usage: EmbeddingUsage },且文档明确embeddings保持批次顺序(preserves batch order)。

提供者契约

两个面向提供者实现者的 TypeScript 接口:

// 提供者输入:待嵌入的字符串数组 export interface ProviderOptions { readonly inputs: ReadonlyArray<string> } // 提供者输出:位置对齐的结果向量 + token 用量 export interface ProviderResponse { readonly results: Array<Array<number>> readonly usage: { readonly inputTokens: number | undefined } }

以及一个标签化请求类,供RequestResolver机制使用:

export class EmbeddingRequest extends Request.TaggedClass("EmbeddingRequest")< { readonly input: string }, // 请求数据:单条输入文本 EmbedResponse, // 成功类型:单条向量响应 AiError.AiError // 失败类型:AiError 体系 > {}

服务接口本身只有三个成员:

export interface Service { readonly resolver: RequestResolver.RequestResolver<EmbeddingRequest> readonly embed: (input: string) => Effect.Effect<EmbedResponse, AiError.AiError> readonly embedMany: (input: ReadonlyArray<string>) => Effect.Effect<EmbedManyResponse, AiError.AiError> }

其中resolver是内部批处理机制的载体(见下一节),而embed/embedMany是用户直接消费的 API。

运行时构造器 make:批处理、span 与错误传播

make是将提供者的批量实现embedMany适配为EmbeddingModel.Service的构造器(EmbeddingModel.ts 第 202-250 行)。它接收唯一参数{ embedMany: (options: ProviderOptions) => Effect.Effect<ProviderResponse, AiError> },其内部实现覆盖了变更集宣布的全部运行时语义:

1. RequestResolver 批处理

const resolver = RequestResolver.make<EmbeddingRequest>((entries) => Effect.flatMap( params.embedMany({ inputs: entries.map((entry) => entry.request.input) }), (response) => Effect.map(mapProviderResults(entries.length, response.results), (embeddings) => { for (let i = 0; i < entries.length; i++) { entries[i].completeUnsafe(Exit.succeed(embeddings[i])) } }) ) ).pipe( RequestResolver.withSpan("EmbeddingModel.resolver") )

embed的实现只是发起一个EmbeddingRequest并交给 resolver:

embed: (input) => Effect.request(new EmbeddingRequest({ input }), resolver).pipe( Effect.withSpan("EmbeddingModel.embed") )

由此得到关键行为:并发的单次embed调用会被RequestResolver自动聚合成一次提供者的embedMany调用embedMany的直接调用则把整个输入数组原样传给提供者,不经过 resolver。resolver 上的withSpan("EmbeddingModel.resolver")embed/embedMany各自的Effect.withSpan,对应变更集中提到的"embed / embedMany spans"——每次嵌入操作与每次底层批量请求都会产出可观测性 span。

2. 空输入快速路径

embedMany: (input) => (input.length === 0 ? Effect.succeed( new EmbedManyResponse({ embeddings: [], usage: new EmbeddingUsage({ inputTokens: undefined }) }) ) : params.embedMany({ inputs: input }) .pipe(/* mapProviderResults + 包装为 EmbedManyResponse */)

embedMany([])不会触碰提供者,直接返回空嵌入列表和inputTokens: undefined的用量。这一行为被行为测试中"provider should not be called"的用例锁定(见后文)。

3. 错误传播与提供者输出校验

mapProviderResults(第 252-269 行)是核心层唯一的校验点,规则是位置解释(positional interpretation):提供者响应必须恰好为每个请求输入返回一个结果,数量不符即失败:

const mapProviderResults = ( inputLength: number, results: Array<Array<number>> ): Effect.Effect<Array<EmbedResponse>, AiError.AiError> => { const embeddings = new Array<EmbedResponse>(inputLength) if (results.length !== inputLength) { return Effect.fail( invalidProviderResponse( `Provider returned ${results.length} embeddings but expected ${inputLength}` ) ) } // 按位置逐条包装为 EmbedResponse ... }

失败值统一通过AiError.make({ module: "EmbeddingModel", method: "embedMany", reason: new AiError.InvalidOutputError({ description }) })构造。模块头注释中的 "Gotchas" 也明确:提供者响应被按位置解释,数量不匹配时embedembedMany都会以AiError.InvalidOutputError失败。而提供者自身抛出的AiError(如网络错误、UnknownError等)则原样向上传播,不做包装。

行为测试印证

测试文件 EmbeddingModel.test.ts 与变更集宣称的"embedding usage、batching、ordering、error handling"一一对应,每个测试都值得注意:

测试用例验证的行为
embed returns a vector单次embed("hello")触发一次携带["hello"]的提供者调用,返回[1, 2, 3]向量
embedMany returns ordered vectors with usageembedMany(["hello","world"])保序返回并透传inputTokens: 9
concurrent embed calls are batched into one provider embedMany call三个并发embedEffect.all(..., { concurrency: "unbounded" }))只产生一次提供者调用,且该调用收到["a","b","c"]
provider AiError propagates through embed提供者Effect.fail(error)的错误对象原样传出(assert.strictEqual(result, error)
embed/embedMany各有一个InvalidOutputError用例提供者少返结果时,error.reason._tag"InvalidOutputError"
embedMany([]) bypasses provider提供者闭包内Effect.die,断言其从未被调用
round trips usage with undefined input tokens through JSONEmbeddingUsageinputTokens: undefined编码为{}再解码回来仍等价,保证 JSON 序列化往返安全

提供者实现一:@effect/ai-openai 的 OpenAiEmbeddingModel

openai/src/OpenAiEmbeddingModel.ts 由桶文件 openai/src/index.ts 导出(export * as OpenAiEmbeddingModel from "./OpenAiEmbeddingModel.ts"),包名为@effect/ai-openai。该模块把 OpenAI embeddings API 适配成核心服务,其 README 也明确它"包含 language model 和 embedding model 层"。

模型标识与四个构造器

  • Model类型:"text-embedding-ada-002" | "text-embedding-3-small" | "text-embedding-3-large";所有构造器的model参数都是(string & {}) | Model形式——即优先从这三个字面量中自动补全,同时允许任意自定义字符串(如新的 embedding 模型 ID);
  • model(model, options):返回一个AiModel.Model<"openai", EmbeddingModel | Dimensions, OpenAiClient>描述符,内部是layer(...)Layer.succeed(EmbeddingModel.Dimensions, options.dimensions)Layer.merge——即同时提供嵌入服务与维度服务,且options中的dimensions必填;
  • make({ model, config }):当环境中已有OpenAiClient时 effectfully 构造EmbeddingModel.Service,其R依赖为OpenAiClient
  • layer({ model, config })Layer.effect(EmbeddingModel.EmbeddingModel, make(options)),用于层组合,要求OpenAiClient由其他层供给;
  • withConfigOverridedual(2, ...)实现的配置覆盖工具,同时支持pipe(data-first)与withConfigOverride(effect, overrides)(data-last)两种写法,作用域内覆盖字段优先于已有Config

配置合并顺序

Config服务存储的是"去掉input字段后的 OpenAI create-embedding 请求体"(Partial<Omit<CreateEmbeddingRequest.Encoded, "input">>,另加索引签名)。每次请求时的合并逻辑在make内部:

const makeConfig = Effect.contextWith((services: Context.Context<never>) => Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) }) )

即三层优先级从低到高:构造器传入的config< 作用域内withConfigOverride注入的Configmodel字段永远取构造时的值(config类型上已Omitmodel,避免被覆盖)。

输出索引校验与确定性重排

这是变更集中"provider output index validation with deterministic reordering"的落点,实现在mapProviderResponse(第 210-250 行)。OpenAI 响应体data数组的每条记录自带index字段,并不保证返回顺序与请求顺序一致,因此提供者按索引把结果写回目标位置:

for (const entry of response.data) { if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index >= inputLength) { return Effect.fail(invalidOutput("Provider returned invalid embedding index: " + entry.index)) } if (seen.has(entry.index)) { return Effect.fail(invalidOutput("Provider returned duplicate embedding index: " + entry.index)) } if (!Array.isArray(entry.embedding)) { return Effect.fail(invalidOutput("Provider returned non-vector embedding at index " + entry.index)) } seen.add(entry.index) results[entry.index] = [...entry.embedding] // 按 index 落位,实现确定性重排 }

校验链完整覆盖五种异常:条目总数不符(Provider returned N embeddings but expected M)、索引非整数或越界、索引重复、embedding不是数组、以及全部处理完后seen.size仍不等于输入数。任一失败都构造AiErrormodule: "OpenAiEmbeddingModel", reason: InvalidOutputError)。

这里有一个值得强调的语义细节:模块文档注释指出"该服务期望数值型嵌入向量,若提供者返回 base64 编码的嵌入(即请求了encoding_format: "base64"),服务将以InvalidOutputError失败"——Array.isArray(entry.embedding)对 base64 字符串直接判否。使用方应默认使用浮点输出格式,不要在该层上启用 base64。

用量映射同样直接:usage: { inputTokens: response.usage?.prompt_tokens },OpenAI 的prompt_tokens即核心层EmbeddingUsage.inputTokens

行为测试 openai/test/OpenAiEmbeddingModel.test.ts 用内存版HttpClient打桩验证了关键路径,例如 "reorders embeddings by provider index" 用例中,mock 响应故意以index: 1index: 0之前返回,断言最终按请求顺序得到[[10,11],[20,21]];"model provides dimensions service" 用例则验证model("text-embedding-3-small", { dimensions: 1536 })提供的Dimensions服务值为1536

提供者实现二:@effect/ai-openai-compat 的 OpenAI 兼容适配

openai-compat/src/OpenAiEmbeddingModel.ts(包@effect/ai-openai-compat)的结构与 OpenAI 官方提供者几乎同构——同样的Config服务、model/make/layer/withConfigOverride四件套、同样的按索引校验重排逻辑与prompt_tokens → inputTokens映射——差异集中在三点:

  1. 模型标识完全开放export type Model = string,不做字面量约束,因为 OpenAI 兼容端点(自托管或第三方网关)可暴露任意模型名;
  2. 配置类型更宽ConfigOptions = Partial<Omit<CreateEmbeddingRequestJson, "input">>model参数的类型为(string & {}) | Model(两者等价于string,保留与核心Model类型的并集写法),config额外带索引签名{ readonly [x: string]: unknown }以容忍兼容端点的扩展字段;
  3. 合并顺序文档化make的 JSDoc 明确写为"selected model, constructor config, then scoped Config, so scoped overrides take precedence",与官方提供者的合并代码({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) })完全一致。

mapProviderResponse中的五类校验(数量、索引范围、重复索引、非数组向量、覆盖数)与官方提供者逐条对齐,错误文案也相同。

一个完整的最小用法示例

综合以上源码,一个基于effect4.x(unstable AI 模块)+@effect/ai-openai的用法大致如下(依赖注入关系均与源码R类型一致):

import { Effect, Layer } from "effect" import { OpenAiClient, OpenAiEmbeddingModel } from "@effect/ai-openai" import { EmbeddingModel } from "effect/unstable/ai" // 1. 维度服务 + 嵌入服务(R: OpenAiClient) const embedLayer = OpenAiEmbeddingModel.model("text-embedding-3-small", { dimensions: 1536 }) // 2. OpenAiClient 由 OpenAiClient.layer 提供(apiKey 经 Redacted 包装) const clientLayer = OpenAiClient.layer({ apiKey: Effect.Redacted.make("sk-...") }) // 3. 组合层 const app = Layer.provide(embedLayer, clientLayer) // 4. 使用:并发 embed 会被自动批量成一次 API 调用 const program = Effect.gen(function*() { const model = yield* EmbeddingModel.EmbeddingModel const dims = yield* EmbeddingModel.Dimensions // 1536 const [single, batch] = yield* Effect.all([ model.embed("hello"), model.embedMany(["a", "b", "c"]) ]) console.log(single.vector, batch.usage.inputTokens) }).pipe(Effect.provide(app))

若某个工作流需要临时调整请求参数(例如改dimensions或补user标识),无需重建服务,直接:

program.pipe( OpenAiEmbeddingModel.withConfigOverride({ dimensions: 256 }) )

作用域内的覆盖字段会优先于构造器config,作用域结束后恢复原配置。

小结

eff-718 变更集落地的是 effect 4.0.0 不稳定 AI 模块中嵌入能力的第一块拼图,其设计要点可以归纳为三层:

  • 核心层(effectEmbeddingModel服务 +Dimensions服务 +EmbeddingUsage/EmbedResponse/EmbedManyResponse数据模型 +make构造器;makeRequestResolver实现并发embed的自动批处理,用withSpan提供观测点,用mapProviderResults强制位置对齐并以InvalidOutputError拒绝数量不符的响应,embedMany([])走零成本快速路径;
  • OpenAI 官方提供者(@effect/ai-openai:三个字面量模型 + 任意字符串、model/make/layer/withConfigOverride构造器、model < 构造器 config < 作用域 Config 的合并顺序、以及按index字段做校验与确定性重排的输出映射(base64 向量会被拒绝);
  • OpenAI 兼容提供者(@effect/ai-openai-compat:同构实现,模型名全开放,配置类型容忍扩展字段,校验逻辑与官方提供者一致。

由于这些模块位于unstable/ai子路径并标记@since 4.0.0,引用时应留意后续版本中 API 仍可能有调整;具体行为以 EmbeddingModel.ts、openai 提供者 与 openai-compat 提供者 的 JSDoc 及其配套测试为准。

【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询