跳到正文
原文
Google AI:DEV 作者专属(RSS)· Priyanka-Chettri·· 5 小时前AI 评分44

LLM 提供商抽象:从单一提供商到多提供商

Provider Abstraction for LLMs: From One Provider to Many

AI 导读

在 LLM 应用中直接调用 OpenAI SDK 会让业务代码与厂商耦合,改用统一的 LLMProvider 接口(含 generate 与 generateStructured)后,OpenAIProvider 和 AnthropicProvider 可各自实现同一契约。

正文

While building an application with LLMs, the most straightforward thing to do is call the provider directly.

Let's say I'm using OpenAI.

My application needs to generate a heading from a conversation.

I could simply write:

@Injectable()
export class ConversationService {
  async generateHeading(conversation: string) {
    const response = await openai.responses.create({
      model: "...",
      input: `
        Generate a short heading for this conversation:

        ${conversation}
      `,
    });

    return response.output_text;
  }
}

And there's absolutely nothing wrong with this if my application is small and I know I'm only going to use OpenAI.

But let's say the application grows.

Now I have multiple places using an LLM:

ConversationService
        ↓
OpenAI SDK

BondService
        ↓
OpenAI SDK

SummaryService
        ↓
OpenAI SDK

My application is slowly becoming coupled to OpenAI.

Then one day I decide:

What if I want to try Anthropic instead?

Now I have a problem.

Anthropic doesn't necessarily expose the same SDK methods as OpenAI.

So code like:

openai.responses.create(...)

would have to change.

And if OpenAI-specific code is spread throughout my application, switching providers means changing multiple services.

But if I think about it, my business logic doesn't actually care about OpenAI.

ConversationService just wants:

"Generate a heading."

BondService might just want:

"Generate structured data."

And another service might want:

"Stream some text."

Those are my application's requirements.

How OpenAI, Anthropic, or Gemini perform them is an implementation detail.

And this is where provider abstraction starts to make sense.


Defining What My Application Needs

Instead of letting every service understand OpenAI's SDK, I can define a common interface.

export interface LLMProvider {
  generate(prompt: string): Promise<string>;

  generateStructured<T>(
    prompt: string,
    schema: unknown,
  ): Promise<T>;
}

I'm essentially saying:

Any LLM provider that wants to work with my application must provide these capabilities.

Now my application has its own language:

provider.generate(prompt);

instead of being built around a vendor's language:

openai.responses.create(...)

Different Providers Can Implement the Same Requirement

OpenAI can implement my interface:

export class OpenAIProvider implements LLMProvider {
  async generate(prompt: string): Promise<string> {
    // Translate generate() into the OpenAI SDK call
    const response = await openai.responses.create({
      model: "...",
      input: prompt,
    });

    return response.output_text;
  }
}

Anthropic can implement the same interface:

export class AnthropicProvider implements LLMProvider {
  async generate(prompt: string): Promise<string> {
    // Translate generate() into the Anthropic SDK call
    // ...
    return "...";
  }
}

Now I have:

                     LLMProvider
                      generate()
                      /       \
                     /         \
                    ↓           ↓
           OpenAIProvider   AnthropicProvider
                 ↓                ↓
            OpenAI SDK       Anthropic SDK

My application cares about:

generate()

The provider cares about:

How do I make that happen using this particular vendor?

That is the basic idea behind provider abstraction.


This Is Where Dependency Inversion Appears

At this point, something interesting has happened to our architecture.

Originally we had:

ConversationService
        ↓
OpenAIProvider

Our higher-level application code directly depended on a concrete implementation.

After introducing LLMProvider, we have:

ConversationService
        ↓
LLMService
        ↓
LLMProvider
        ↑
OpenAIProvider

Our higher-level code now depends on an abstraction rather than directly depending on OpenAI.

This is the idea behind the Dependency Inversion Principle.

High-level code should depend on abstractions rather than concrete implementations.

I didn't introduce an interface because someone told me:

"You must use the Dependency Inversion Principle."

I introduced it because I didn't want my application tightly coupled to OpenAI.

The design principle is simply a name for what this architecture is achieving.


But How Does LLMService Get an OpenAIProvider?

Now we have another question.

Suppose LLMService looks like this:

@Injectable()
export class LLMService {
  constructor(
    private readonly provider: LLMProvider,
  ) {}

  generate(prompt: string) {
    return this.provider.generate(prompt);
  }
}

LLMService isn't doing this:

const provider = new OpenAIProvider();

Instead, we're saying:

constructor(
  private readonly provider: LLMProvider,
) {}

In other words:

LLMService, don't create your own dependency. Someone else will give it to you.

That idea is Dependency Injection.

Without DI

LLMService
    ↓
new OpenAIProvider()


With DI

OpenAIProvider
    ↓
injected into
    ↓
LLMService

NestJS has a Dependency Injection container that handles this for us.

But now we run into another problem.

And this is where Symbols finally become relevant.

But There Is One Problem: Interfaces Don't Exist at Runtime

So far, our LLMService looks something like this:

@Injectable()
export class LLMService {
  constructor(
    private readonly provider: LLMProvider,
  ) {}

  generate(prompt: string) {
    return this.provider.generate(prompt);
  }
}

Conceptually, this is exactly what we want.

We're saying:

LLMService doesn't care whether it gets OpenAI, Anthropic, or something else. Just give it something that follows the LLMProvider contract.

But now we run into an interesting TypeScript + NestJS problem.


TypeScript Understands Interfaces, JavaScript Doesn't

We defined:

export interface LLMProvider {
  generate(prompt: string): Promise<string>;
}

TypeScript understands this perfectly.

It can check:

class OpenAIProvider implements LLMProvider {
  async generate(prompt: string): Promise<string> {
    // ...
  }
}

If OpenAIProvider forgets to implement generate(), TypeScript can complain.

So during development:

LLMProvider interface
        ↓
TypeScript understands it
        ↓
Checks our code

But TypeScript doesn't run directly in production.

It gets compiled/transpiled into JavaScript.

And JavaScript does not have TypeScript's interface construct.

So something like:

interface LLMProvider {
  generate(prompt: string): Promise<string>;
}

class OpenAIProvider implements LLMProvider {
  // ...
}

roughly becomes:

class OpenAIProvider {
  // ...
}

The interface is gone.

TYPESCRIPT

LLMProvider       ✅
OpenAIProvider    ✅

        ↓
    compilation

JAVASCRIPT

LLMProvider       ❌
OpenAIProvider    ✅

This is intentional.

The interface was only needed for type checking while developing the application.


Why Is That a Problem for NestJS?

Because NestJS Dependency Injection happens at runtime.

When the application is actually running, Nest needs to know:

Which dependency should I put inside LLMService?

For classes, this is often straightforward because classes exist at runtime.

For example:

constructor(
  private readonly userService: UserService,
) {}

UserService is a class.

The class still exists in JavaScript at runtime, so Nest can use it as a DI token.

But:

constructor(
  private readonly provider: LLMProvider,
) {}

is different.

LLMProvider is an interface.

By the time NestJS is running:

LLMProvider
     ↓
doesn't exist

So Nest effectively has no runtime identifier it can use to answer:

"What exactly should I inject here?"

We need to give Nest something that does exist at runtime.

And that's where Symbols come in.


Enter Symbols

JavaScript has a primitive called Symbol.

const LLM_PROVIDER = Symbol("LLM_PROVIDER");

Every Symbol is a unique runtime value.

For example:

const a = Symbol("LLM_PROVIDER");
const b = Symbol("LLM_PROVIDER");

console.log(a === b); // false

Even though both have the same description, they are two different values.

So we can create:

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");

and use this Symbol as a runtime identifier for our dependency.

Now we have two similarly named things, but they have completely different jobs:

LLMProvider
    ↓
TypeScript interface
    ↓
Defines WHAT a provider must be able to do
    ↓
Compile-time only


LLM_PROVIDER
    ↓
JavaScript Symbol
    ↓
Identifies the dependency for NestJS
    ↓
Exists at runtime

Connecting the Symbol to OpenAIProvider

Now we tell Nest:

{
  provide: LLM_PROVIDER,
  useClass: OpenAIProvider,
}

You can almost read this as plain English:

For the dependency identified by LLM_PROVIDER, use OpenAIProvider.

Nest's DI container now conceptually has:

DI CONTAINER

KEY                         IMPLEMENTATION
────────────────────────────────────────────
LLM_PROVIDER   ──────────→  OpenAIProvider

The Symbol is essentially the key.

The provider class is the value/implementation associated with that key.


Now LLMService Can Ask for That Dependency

We can write:

@Injectable()
export class LLMService {
  constructor(
    @Inject(LLM_PROVIDER)
    private readonly provider: LLMProvider,
  ) {}

  generate(prompt: string) {
    return this.provider.generate(prompt);
  }
}

This one constructor parameter actually contains two separate concepts:

@Inject(LLM_PROVIDER)

is for NestJS.

It says:

At runtime, find whatever dependency is registered under this Symbol.

While:

provider: LLMProvider

is for TypeScript.

It says:

During development/type checking, make sure this variable follows the LLMProvider contract.

So:

@Inject(LLM_PROVIDER)
        ↓
      NestJS
        ↓
Runtime lookup
        ↓
LLM_PROVIDER
        ↓
OpenAIProvider


provider: LLMProvider
        ↓
    TypeScript
        ↓
Type checking
        ↓
Must satisfy the LLMProvider contract

At runtime, the object stored inside provider is actually an instance of:

OpenAIProvider

Therefore:

this.provider.generate(prompt);

actually executes:

OpenAIProvider.generate(prompt);

Why a Symbol Instead of Just a String?

Technically, NestJS also allows other values such as strings to be used as injection tokens.

We could have something like:

"LLM_PROVIDER"

But strings can accidentally have the same value:

const a = "LLM_PROVIDER";
const b = "LLM_PROVIDER";

a === b; // true

Symbols are unique:

const a = Symbol("LLM_PROVIDER");
const b = Symbol("LLM_PROVIDER");

a === b; // false

That makes Symbols useful as unique dependency identifiers.

One important detail is that we must reuse the same Symbol instance.

For example:

// llm.tokens.ts

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");

Then import it wherever we need it:

import { LLM_PROVIDER } from "./llm.tokens";

We should not create a new:

Symbol("LLM_PROVIDER")

in every file because every call to Symbol() creates a different value.


So Where Should the Symbol Live?

A simple project structure could be:

llm/
├── llm-provider.interface.ts
├── llm.tokens.ts
├── llm.service.ts
├── llm.module.ts
└── providers/
    ├── openai.provider.ts
    └── anthropic.provider.ts

Inside:

llm.tokens.ts

we define:

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");

Then llm.module.ts registers it:

@Module({
  providers: [
    {
      provide: LLM_PROVIDER,
      useClass: OpenAIProvider,
    },
    LLMService,
  ],
})
export class LLMModule {}

And llm.service.ts asks Nest for it:

@Injectable()
export class LLMService {
  constructor(
    @Inject(LLM_PROVIDER)
    private readonly provider: LLMProvider,
  ) {}
}

So the complete connection becomes:

                    LLMProvider
                 TypeScript contract
                        ↑
                        │ implements
                        │
                 OpenAIProvider
                        ↑
                        │ registered as
                        │
               LLM_PROVIDER Symbol
                        ↑
                        │ @Inject(...)
                        │
                    LLMService

The interface tells TypeScript what the provider should look like.

The Symbol tells NestJS how to find that provider at runtime.

And OpenAIProvider contains the actual implementation.

来源:Google AI:DEV 作者专属(RSS) · dev.to