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 版本变更,并列出五点内容:
- 在
effect中新增不稳定的EmbeddingModel模块 API 表面,包括服务(service)、请求(request)、响应(response)和提供者(provider)类型; - 在
effect中实现不稳定的EmbeddingModel运行时构造器,具备RequestResolver批处理、embed/embedManyspan、提供者错误传播、确定性排序,以及空输入embedMany的快速路径(fast-path)行为; - 在
effect中新增并对齐EmbeddingModel行为测试,覆盖嵌入使用、批处理、排序和错误处理; - 在
@effect/ai-openai中新增OpenAiEmbeddingModel,包括 model / make / layer 构造器、配置覆盖,以及带确定性重排(deterministic reordering)的提供者输出索引校验; - 在
@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" 也明确:提供者响应被按位置解释,数量不匹配时embed和embedMany都会以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 usage | embedMany(["hello","world"])保序返回并透传inputTokens: 9 |
concurrent embed calls are batched into one provider embedMany call | 三个并发embed(Effect.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 JSON | EmbeddingUsage的inputTokens: 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由其他层供给;withConfigOverride:dual(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注入的Config;model字段永远取构造时的值(config类型上已Omit掉model,避免被覆盖)。
输出索引校验与确定性重排
这是变更集中"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仍不等于输入数。任一失败都构造AiError(module: "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: 1在index: 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映射——差异集中在三点:
- 模型标识完全开放:
export type Model = string,不做字面量约束,因为 OpenAI 兼容端点(自托管或第三方网关)可暴露任意模型名; - 配置类型更宽:
ConfigOptions = Partial<Omit<CreateEmbeddingRequestJson, "input">>,model参数的类型为(string & {}) | Model(两者等价于string,保留与核心Model类型的并集写法),config额外带索引签名{ readonly [x: string]: unknown }以容忍兼容端点的扩展字段; - 合并顺序文档化:
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 模块中嵌入能力的第一块拼图,其设计要点可以归纳为三层:
- 核心层(
effect):EmbeddingModel服务 +Dimensions服务 +EmbeddingUsage/EmbedResponse/EmbedManyResponse数据模型 +make构造器;make用RequestResolver实现并发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),仅供参考