虽然 OpenAI 的 GPT 系列模型(如我们项目中默认使用的 gpt-3.5-turbo 或 gpt-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 彻底与具体模型解耦,我们可以在模块级别(如 OpenAIModule 或 LLMModule)使用 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 列的总和。
...
这证明你的动态依赖注入已经完美生效!
下一节,我们将讨论如何处理大批量的数据、处理流式数据以及实时分析的架构设计。