3.3 大语言模型 (LLM) 集成与推理:利用外部或本地模型

虽然 OpenAI 的 GPT 系列模型(如我们项目中默认使用的 gpt-3.5-turbogpt-4o)非常强大,但在企业级应用中,出于数据隐私成本控制特定领域表现的综合考量,我们往往需要集成其他的外部模型,甚至部署完全本地化的开源模型。

本节我们将探讨如何在现有的 NestJS 项目中,优雅地扩展模型支持。

源码地址:https://github.com/you-want/ai-data-analyzer

1. 为什么需要多模型支持?

在构建 AI Agent 时,不同环节对模型能力的要求是不同的:

  • 复杂推理与任务规划(Agent Loop):需要极强的逻辑推理能力,通常依赖 GPT-4o 或 Claude 3.5 Sonnet 等顶级模型。
  • 简单的数据格式化或意图识别:只需轻量级模型即可胜任,如 GPT-3.5-turbo 或本地的 Llama 3 8B,可以大幅降低 API 调用成本并提升响应速度。
  • 高敏感数据处理(如医疗、金融财务数据):数据绝对不能出内网,此时必须使用本地部署的开源模型(如 Qwen、DeepSeek 等)。

2. 外部模型集成:统一的接口抽象

为了不让我们的业务代码(如 AnalysisService)死死绑定在 OpenAI 上,在 NestJS 中,最佳实践是使用抽象工厂模式 (Factory Pattern)策略模式 (Strategy Pattern) 来管理不同的模型服务。

目前在我们的 ai-data-analyzer/backend 中,OpenAIService 是这样实现的:

typescript 复制代码
// ai-data-analyzer/backend/src/openai/openai.service.ts (当前实现)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';

@Injectable()
export class OpenAIService {
  private readonly openai: OpenAI;
  // ...
  async chat(content: string, model?: string): Promise<string> {
    // ...
  }
}

如何重构以支持多模型?

我们可以定义一个通用的 ILLMService 接口,然后让不同的提供商服务去实现它:

typescript 复制代码
// 1. 定义统一接口
export interface ILLMService {
  chat(prompt: string, model?: string): Promise<string>;
}

// 2. 实现 OpenAI 服务
@Injectable()
export class OpenAIService implements ILLMService {
  /* ...实现细节... */
}

// ai-data-analyzer/backend/src/openai/claude.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
import { ILLMService } from './llm.interface';

@Injectable()
export class ClaudeService implements ILLMService {
  private readonly anthropic: Anthropic;
  private readonly logger = new Logger(ClaudeService.name);

  constructor(private configService: ConfigService) {
    const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
    
    if (!apiKey) {
      this.logger.warn('ANTHROPIC_API_KEY is not configured.');
    }

    this.anthropic = new Anthropic({
      apiKey: apiKey || 'dummy-key-for-init',
    });
  }

  async chat(prompt: string, model?: string): Promise<string> {
    try {
      this.logger.debug(
        `Sending prompt to Claude (Model: ${model || 'claude-3-5-sonnet-20240620'})`,
      );

      const response = await this.anthropic.messages.create({
        model: model || 'claude-3-5-sonnet-20240620',
        max_tokens: 1024,
        messages: [{ role: 'user', content: prompt }],
      });

      const firstBlock = response.content[0];
      if (firstBlock.type === 'text') {
        return firstBlock.text;
      }
      return '';
    } catch (error) {
      this.logger.error('Failed to call Claude API', error);
      throw error;
    }
  }
}

为了让 AnalysisService 彻底与具体模型解耦,我们可以在模块级别(如 OpenAIModuleLLMModule)使用 NestJS 的动态 Provider 来决定注入哪一个实现:

typescript 复制代码
// ai-data-analyzer/backend/src/openai/openai.module.ts
import { Module } from '@nestjs/common';
import { OpenAIService } from './openai.service';
import { ClaudeService } from './claude.service';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [ConfigModule],
  providers: [
    OpenAIService,
    ClaudeService,
    {
      provide: 'LLM_SERVICE', // 依赖注入的 Token
      useFactory: (
        configService: ConfigService,
        openai: OpenAIService,
        claude: ClaudeService,
      ) => {
        const activeModel = configService.get<string>('ACTIVE_LLM_PROVIDER');
        // 根据环境变量决定使用哪个模型服务
        if (activeModel === 'claude') {
          return claude;
        }
        return openai;
      },
      inject: [ConfigService, OpenAIService, ClaudeService],
    },
  ],
  // 将原有的 OpenAIService 和 新的 LLM_SERVICE 一起导出,
  // 保证旧代码兼容的同时,允许新代码使用 LLM_SERVICE
  exports: [OpenAIService, 'LLM_SERVICE'],
})
export class OpenAIModule {}

AnalysisService 中,我们只需通过 @Inject 装饰器引入这个 Token,业务逻辑无需任何改动:

typescript 复制代码
// ai-data-analyzer/backend/src/analysis/analysis.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import type { ILLMService } from '../openai/llm.interface';

@Injectable()
export class AnalysisService {
  private readonly logger = new Logger(AnalysisService.name);

  // 无论底层是 OpenAI 还是 Claude,这里都能通过依赖注入 Token 无缝接收
  constructor(
    @Inject('LLM_SERVICE') private readonly llmService: ILLMService,
  ) {}

  async analyzeText(rawContent: string): Promise<string> {
    // 业务代码直接调用接口方法
    return this.llmService.chat(rawContent);
  }
}

这种设计使得我们的 AnalysisService 只需要依赖 ILLMService,无论底层换成什么模型,业务逻辑都不需要修改一行代码。


3. 本地模型部署与接入:拥抱 Ollama

如果你的数据具有极高的机密性,使用本地模型是最佳选择。目前最简单、最流行的本地大模型运行工具非 Ollama 莫属。

Ollama 支持 Llama 3, Mistral, 通义千问 (Qwen), DeepSeek 等众多优秀的开源模型,并且它原生提供了完全兼容 OpenAI API 格式的接口

这意味着,在我们的项目中接入本地模型,几乎不需要修改任何代码,只需要修改环境变量!

接入步骤:

第一步:安装并运行 Ollama

在你的本地机器或服务器上安装 Ollama,并拉取/运行一个模型,例如阿里的 Qwen 2.5:

bash 复制代码
ollama run qwen2.5

运行后,Ollama 会在本地启动一个 API 服务,默认地址为 http://localhost:11434

第二步:修改 .env 配置文件

回到我们的 ai-data-analyzer/backend 项目,修改 .env 文件,将基础 URL 指向本地的 Ollama 服务:

env 复制代码
# 将原来的 OpenAI 配置替换或注释掉
# OPENAI_API_KEY=sk-xxxxxx
# OPENAI_BASE_URL=https://api.openai.com/v1

# 替换为 Ollama 的配置
OPENAI_API_KEY=ollama  # 随便填,Ollama 不需要真实的 API Key
OPENAI_BASE_URL=http://localhost:11434/v1
OPENAI_MODEL=qwen2.5   # 指定你刚才在 Ollama 中拉取的模型名称

因为我们的 OpenAIService 在初始化时读取了 OPENAI_BASE_URL

typescript 复制代码
    const baseURL = this.configService.get<string>('OPENAI_BASE_URL');
    this.openai = new OpenAI({
      apiKey,
      baseURL, // 如果环境变量有值,就会使用本地的 Ollama 地址
    });

就这样,你的 AI Agent 瞬间就变成了一个完全本地化、断网可用的私有智能体!


4. 模型路由策略 (Model Routing)

在实际的企业级 Agent 中,我们通常会混合使用多种模型,这就是模型路由

你可以在 AnalysisService 中注入多种模型服务,根据任务的难度进行分发:

typescript 复制代码
  async runAgentLoop(rawData: any[], userPrompt: string): Promise<string> {
    // 1. 先用快速且廉价的本地模型(或 GPT-3.5)进行意图识别
    const intent = await this.localLLMService.chat(`判断用户请求的难度类别:${userPrompt}`);
    
    let aiResponse;
    if (intent.includes('COMPLEX_ANALYSIS')) {
      // 2. 复杂的逻辑推理,交给强大的 GPT-4o
      this.logger.log('Routing to GPT-4o for complex reasoning...');
      aiResponse = await this.gpt4Service.chat(conversationHistory);
    } else {
      // 3. 简单的计算提取,继续使用本地模型
      this.logger.log('Routing to Local Model for simple task...');
      aiResponse = await this.localLLMService.chat(conversationHistory);
    }
    // ...
  }

通过这种灵活的模型集成与路由策略,我们能够在智能化水平运行成本数据安全之间找到完美的平衡点。


5. 测试与验证

在完成上述改造后,我们可以在本地启动后端服务进行测试。

第一步:配置环境变量

.env 文件中添加你想测试的模型配置:

env 复制代码
# 切换为 claude 即可使用 Claude API
ACTIVE_LLM_PROVIDER=claude
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxx

第二步:启动后端服务

bash 复制代码
npm run start:dev

第三步:发送测试请求

准备一个 test-data.csv 文件:

csv 复制代码
id,name,amount
1,Alice,100
2,Bob,200

使用 cURL 向我们之前写好的上传接口发送请求:

bash 复制代码
curl -X POST http://localhost:3001/data/upload \
  -F "file=@/绝对路径/test-data.csv" \
  -F "prompt=请帮我计算金额的总和"

如果配置正确,你应该能在后端的控制台(终端)中看到类似这样的日志:

复制代码
[ClaudeService] Sending prompt to Claude (Model: claude-3-5-sonnet-20240620)
[AnalysisService] AI Response:
Thought: 我需要计算 amount 列的总和。
...

这证明你的动态依赖注入已经完美生效!


下一节,我们将讨论如何处理大批量的数据、处理流式数据以及实时分析的架构设计。