(9-2)智能客服Agent开发:大模型交互

9.3  大模型交互

​在本项目中,“llm”目录包含了与大型语言模型(LLM)交互的核心功能。​具体而言,“llm”目录下的模块实现了对不同 LLM 服务的封装,包括 OpenAI 和 DeepSeek。​这些实现提供了生成文本响应、将文本嵌入向量化、将语音转换为文本等功能。​通过这些模块,项目能够灵活地与各种 LLM 服务进行交互,以满足不同的业务需求。

9.3.1  通用接口

文件base_llm.py实现了类BaseLLM,此类提供了一个通用的接口,子类可以根据不同的实现需求,如不同的语言模型(例如 OpenAI GPT),来扩展这些方法。此外,类BaseResult是一个用于表示生成结果的类,提供了对结果的内容和其他相关信息的封装。

import json
from abc import ABC, abstractmethod

from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
from pydantic import BaseModel

from app.pkg.config import Config

ROLE_USER = "user"
ROLE_SYSTEM = "system"
ROLE_ASSISTANT = "assistant"


class BaseResult(ABC):
    def __init__(self, content: str = None, usage: dict = None, **kwargs):
        self.content = content
        self.usage = usage
        self.kwargs = kwargs

        try:
            self.json = json.loads(content) if content else {}
        except:
            self.json = {}

        self.price = 0.0  # TODO: 添加成本计算


class BaseLLM(ABC):
    def __init__(self, cfg: Config):
        self.cfg = cfg
        self.embeddings: Embeddings | None = None
        self.llm: BaseChatModel | None = None

    @abstractmethod
    async def invoke(self, prompt: str, instruction: str = None, response_format: dict = None, messages: list[dict] = None, **kwargs) -> dict | BaseModel:
        """
        基于传入的提示生成响应。
        :param prompt: 请求文本,将作为用户的文本。
        :param instruction: 指令文本。
        :param response_format: 返回响应的格式。
        :param messages: 之前的消息列表。
        :param kwargs: 其他适用于不同LLM的参数。
        :return: 生成的响应。
        """
        pass

    @abstractmethod
    async def embed_query(self, text: str, dimensions: int = None) -> tuple[list[float], float]:
        """
        返回文本的向量表示。
        :param text: 要进行向量化的文本。
        :param dimensions: 向量的维度。
        :return: 文本的向量表示。
        """
        pass

    @abstractmethod
    async def embed_documents(self, texts: list[str], dimensions: int = None) -> tuple[list[list[float]], float]:
        """
        返回文本的向量表示。
        :param texts: 要进行向量化的文本列表。
        :param dimensions: 向量的维度。
        :return: 文本的向量表示。
        """
        pass

    @abstractmethod
    async def speech_to_text(self, audio_path: str, is_local_file: bool = False):
        """
        将语音转换为文本。

        参数:
            audio_path (str): 音频文件的URL或本地文件路径。
            is_local_file (bool): 如果为True,audio_path将被视为本地文件路径。

        返回:
            str: 从音频中转录的文本。
        """
        pass

9.3.2  工厂函数

文件llm_factory.py定义了一个工厂函数 create_llm(),用于根据传入的配置和模型类型创建相应的语言模型实例。​函数 create_llm()根据 llm_type 参数的值(如 "openai" 或 "deepseek")来决定返回 OpenAILLM 还是 DeepSeekLLM 的实例。​如果提供的 llm_type 无法识别,则抛出一个 ValueError 异常。

def create_llm(cfg: Config, llm_type: str) -> OpenAILLM | DeepSeekLLM:
    llm_choice = llm_type.lower() 
    if llm_choice == "openai" or llm_choice == "openai_assistant":
        return OpenAILLM(cfg)
    elif llm_choice == "deepseek":
        return DeepSeekLLM(cfg)
    else:
        raise ValueError(f"Неизвестная модель LLM: {llm_choice}")

9.3.3  调用 DeepSeek

文件deepseek_llm.py定义了一个名为 DeepSeekLLM 的类,继承自 BaseLLM。​在初始化方法中,DeepSeekLLM 从配置中获取 DeepSeek 的 API 密钥和 URL。通过​invoke 方法用于调用 DeepSeek API 生成响应,支持在消息中加入系统指令和用户输入的提示。

class DeepSeekLLM(BaseLLM):
    def __init__(self, cfg: Config):
        super().__init__(cfg)
        # 假设 DeepSeek 使用的 API 密钥和端点
        self.api_key = self.cfg.deepseek.api_key
        self.api_url = self.cfg.deepseek.api_url

    async def invoke(self, prompt: str, intruction: str = None, response_format: dict = None, messages: list[dict] = None, **kwargs) -> dict | BaseModel:
        """
        调用 DeepSeek API 生成响应。
        结合 instruction(如果提供),并在消息中加入用户输入的 prompt。
        """

        # 如果没有提供消息列表,则创建一个空的
        messages = messages or []

        # 将 instruction 添加到消息中(如果提供)
        if intruction:
            messages.insert(0, {"role": "system", "content": intruction})

        # 构建 DeepSeek 请求的消息体
        messages.append({"role": "user", "content": prompt})

        try:
            async with aiohttp.ClientSession() as session:
                # 发送 POST 请求到 DeepSeek API
                async with session.post(
                    self.api_url,
                    headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
                    json={"messages": messages}
                ) as response:
                    # 检查响应状态
                    if response.status != 200:
                        raise Exception(f"DeepSeek API 请求失败,状态码: {response.status}")

                    # 解析响应的 JSON 数据
                    response_data = await response.json()

                    # 这里假设响应返回的内容是包含 `response` 字段的 JSON 对象
                    content = response_data.get("response", "")

                    # 如果指定了格式化响应的格式
                    if response_format:
                        if isinstance(response_format, dict):
                            if response_format.get("type") == "json_object":
                                # 转换响应内容为 JSON 对象格式
                                content = json.dumps(response_format.get("json_object", {}))
                            elif response_format.get("type") == "json_schema":
                                json_schema = response_format.get("json_schema")
                                if isinstance(json_schema, type) and issubclass(json_schema, BaseModel):
                                    content = json_schema.parse_obj(content)
                                else:
                                    content = json_schema(content)

                    # 返回生成的内容
                    return {"content": content}
        except Exception as e:
            return {"error": str(e)}

9.3.4  调用OpenAI

文件openai_llm.py定义了一个名为 OpenAILLM 的类,继承自 BaseLLM,用于与 OpenAI 的 API 进行交互,提供文本生成、文本嵌入和语音转文本等功能。

class OpenAIResult(BaseResult):
    """
    OpenAI 的 BaseResult 的具体实现。
    """

    def __init__(self, content: str = None, usage: dict = None, **kwargs):
        super().__init__(content=content, usage=usage, **kwargs)


class OpenAILLM(BaseLLM):
    def __init__(self, cfg: Config):
        super().__init__(cfg)

        self.embeddings = OpenAIEmbeddings(model=self.cfg.openai.emb_model, dimensions=self.cfg.openai.emb_dimensions)
        self.embeddings.openai_api_key = self.cfg.openai.api_key

        self.llm = ChatOpenAI(model=self.cfg.openai.model, api_key=self.cfg.openai.api_key)
        self.client = AsyncOpenAI(api_key=self.cfg.openai.api_key)

    async def invoke(self, prompt: str, intruction: str = None, response_format: dict = None, messages: List[dict] = None, **kwargs) -> dict | BaseModel:
        """
        生成响应,将 instruction(如果提供)与 prompt 结合。
        """
        messages = messages or []

        if intruction:
            messages.insert(0, {"role": "system", "content": intruction})

        llm = self.llm
        if kwargs.get("max_tokens"):
            llm.max_tokens = kwargs.get("max_tokens")
        if kwargs.get("temperature"):
            llm.temperature = kwargs.get("temperature")
        if kwargs.get("top_p"):
            llm.top_p = kwargs.get("top_p")

        if response_format:
            if isinstance(response_format, dict):
                if response_format.get("type") == "json_object":
                    llm = llm.bind(response_format={"type": "json_object"})
                    messages.append({"role": "system", "content": f"请以 JSON 格式回答:\n\n```\n{json.dumps(response_format.get('json_object'))}\n```"})
                elif response_format.get("type") == "json_schema":
                    json_schema_candidate = response_format.get("json_schema")
                    # 检查传入的值是否为类且继承自 BaseModel
                    if isinstance(json_schema_candidate, type) and issubclass(json_schema_candidate, BaseModel):
                        json_schema = json_schema_candidate
                        llm = llm.with_structured_output(json_schema)
                    else:
                        llm = llm.with_structured_output(json_schema_candidate)

        messages.append({"role": "human", "content": prompt})

        return await llm.ainvoke(messages)

    async def embed_query(self, text: str, dimensions: int = None) -> Tuple[List[float], float]:
        """
        返回文本的向量表示。
        为此,我们使用 LangChain 的 OpenAIEmbeddings。
        """
        self.embeddings.dimensions = dimensions
        vector = await asyncio.to_thread(self.embeddings.embed_query, text)
        price = 0.0  # 此处可根据需要计算费用
        return vector, price

    async def embed_documents(self, texts: List[str], dimensions: int = None) -> Tuple[List[List[float]], float]:
        """
        返回一组文本的向量表示。
        """
        self.embeddings.dimensions = dimensions
        vectors = await asyncio.to_thread(self.embeddings.embed_documents, texts, chunk_size=10)
        price = 0.0
        return vectors, price

    async def speech_to_text(self, audio_path: str, is_local_file: bool = False):
        """
        使用 OpenAI Whisper 通过 LangChain 将语音转换为文本。

        参数:
            audio_path (str): 音频文件的 URL 或本地路径。
            is_local_file (bool): 如果为 True,audio_path 被视为本地文件路径。

        返回:
            str: 从音频中转录的文本。
        """

        temp_filename = None

        try:
            if not is_local_file:
                # 如果是 URL,则下载音频文件
                async with aiohttp.ClientSession() as session:
                    async with session.get(audio_path) as response:
                        if response.status != 200:
                            raise Exception(f"无法从 {audio_path} 下载音频文件")

                        # 创建临时文件来存储音频
                        with tempfile.NamedTemporaryFile(delete=False, suffix=".m4a") as temp_file:
                            temp_filename = temp_file.name
                            # 将音频内容写入临时文件
                            temp_file.write(await response.read())
            else:
                # 如果是本地文件,直接使用路径
                temp_filename = audio_path

            with open(temp_filename, "rb") as audio_file:
                response = await self.client.audio.transcriptions.create(
                    model="whisper-1",
                    file=audio_file
                )

                transcribed_text = response.text

            return transcribed_text
        finally:
            # 如果创建了临时文件且不是本地文件,则删除它
            if temp_filename and not is_local_file and os.path.exists(temp_filename):
                os.remove(temp_filename)

    async def save_file(self, file, purpose: Literal["assistants", "batch", "fine-tune", "vision"] = "assistants", file_path: str = None) -> str | None:
        try:
            result = await self.client.files.create(
                file=file if file else open(file_path, "rb"),
                purpose=purpose
            )
            file_id = result.id
        except Exception as e:
            file_id = None

        return file_id

上述代码用于与 OpenAI 的 API 进行交互,该类包含以下功能:

  1. 初始化方法 (__init__):​设置嵌入模型和语言模型,并初始化 OpenAI 客户端。​
  2. invoke()方法:​生成响应,将提示词与可选的指令结合,构建消息列表,并调用 OpenAI 的 API。​
  3. embed_query()方法:​获取文本的嵌入向量表示。​
  4. embed_documents()方法:​获取多个文本的嵌入向量表示。​
  5. speech_to_text()方法:​将音频转换为文本,使用 OpenAI 的 Whisper 模型进行转录。​
  6. save_file()方法:​将文件保存到 OpenAI 的服务器,用于助手、批处理、微调或视觉任务。​

在异步编程方面,OpenAILLM 使用了库aiohttp来异步发送 HTTP 请求,以提高性能和响应速度。