回调函数与异步编程基础| 豆包MarsCode AI刷题

125 阅读11分钟

回调函数与异步编程基础

回调函数

回调函数,你可能并不陌生。它是函数A作为参数传给另一个函数B,然后在函数B内部执行函数A。当函数B完成某些操作后,会调用(即“回调”)函数A。这种编程模式常见于处理异步操作,如事件监听、定时任务或网络请求。

在编程中,异步通常是指代码不必等待某个操作完成(如I/O操作、网络请求、数据库查询等)就可以继续执行的能力。异步机制的实现涉及事件循环、任务队列和其他复杂的底层机制。这与同步编程形成对比,在同步编程中,操作必须按照它们出现的顺序完成。例如:

python
复制代码
def compute(x, y, callback):
    result = x + y
    callback(result)

def print_result(value):
    print(f"The result is: {value}")

def square_result(value):
    print(f"The squared result is: {value**2}")

# 使用不同的回调函数
compute(3, 4, print_result)     # 输出: The result is: 7
compute(3, 4, square_result)    # 输出: The squared result is: 49

上述示例中,虽然 compute 调用了回调函数,但并未体现异步操作。回调函数仅是一种设计模式,与同步或异步操作无直接关系。

异步编程与回调函数

异步编程允许代码在等待某个操作(如I/O操作或网络请求)完成时继续执行其他任务,从而提升效率。例如:

python
复制代码
import asyncio

async def compute(x, y):
    print("Starting compute...")
    await asyncio.sleep(0.5)  # 模拟异步操作
    result = x + y
    print(f"Finished compute: {result}")

async def another_task():
    print("Starting another task...")
    await asyncio.sleep(1)
    print("Finished another task...")

async def main():
    print("Main starts...")
    await asyncio.gather(compute(3, 4), another_task())
    print("Main ends...")

asyncio.run(main())

以上代码中,computeanother_task 是两个独立的异步任务,使用 asyncio.gather 并发执行。当某任务遇到 await 暂停时,另一个任务可以继续执行,充分体现了异步编程的优势。


LangChain中的回调机制

LangChain 提供了强大的回调机制,用于在应用的不同阶段插入自定义逻辑,例如日志记录、事件跟踪和数据流操作。通过实现 CallbackHandler 接口,可以创建自己的回调处理器来监听和处理特定事件。

image.png

回调处理器结构

LangChain 提供了基础的 BaseCallbackHandler 和异步的 AsyncCallbackHandler,其方法包括:

  • on_llm_start:LLM任务开始时调用。
  • on_llm_new_token:新Token生成时调用。
  • on_llm_end:LLM任务结束时调用。
  • on_llm_error:LLM任务发生错误时调用。

以下是LangChain内置的回调处理器示例:

  • StdOutCallbackHandler:将事件信息输出到控制台。
  • FileCallbackHandler:将事件日志写入文件。

回调机制的使用

LangChain支持两种方式添加回调:构造函数回调请求回调

构造函数回调

在组件初始化时传入回调处理器:

python
复制代码
from langchain.callbacks import FileCallbackHandler
from langchain.chains import LLMChain
from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate

handler = FileCallbackHandler("output.log")
llm = OpenAI()
prompt = PromptTemplate.from_template("1 + {number} = ")
chain = LLMChain(llm=llm, prompt=prompt, callbacks=[handler], verbose=True)

answer = chain.run(number=2)

以上代码将事件同时输出到控制台和日志文件 output.log,适用于全局日志记录。

请求回调

如果只需为某次特定请求设置回调,可在请求时传入:

python
复制代码
chain.run(number=2, callbacks=[handler])

自定义回调处理器

通过继承 BaseCallbackHandlerAsyncCallbackHandler,可以实现自定义回调。例如:

python
复制代码
import asyncio
from langchain.callbacks.base import BaseCallbackHandler, AsyncCallbackHandler
from langchain.schema import LLMResult

class MySyncHandler(BaseCallbackHandler):
    def on_llm_new_token(self, token: str, **kwargs):
        print(f"新Token生成: {token}")

class MyAsyncHandler(AsyncCallbackHandler):
    async def on_llm_start(self, **kwargs):
        print("任务开始...")
        await asyncio.sleep(0.5)

    async def on_llm_end(self, response: LLMResult, **kwargs):
        print("任务结束...")
        await asyncio.sleep(0.5)

通过同步和异步回调,可以实现更灵活的事件处理逻辑。


令牌计数器的实现

LangChain的 get_openai_callback 提供了一种上下文管理器,用于统计与OpenAI交互中消耗的Token数量。以下是使用回调函数实现的令牌计数器示例:

python
复制代码
from langchain import OpenAI
from langchain.chains import ConversationChain
from langchain.chains.conversation.memory import ConversationBufferMemory
from langchain.callbacks import get_openai_callback

llm = OpenAI(temperature=0.5, model_name="gpt-3.5-turbo-instruct")
conversation = ConversationChain(llm=llm, memory=ConversationBufferMemory())

with get_openai_callback() as cb:
    conversation("我姐姐明天要过生日,我需要一束生日花束。")
    conversation("她喜欢粉色玫瑰,颜色是粉色的。")
    conversation("我又来了,还记得我昨天为什么要来买花吗?")

print("总计使用的Tokens:", cb.total_tokens)

运行结果中,cb.total_tokens 将输出对话总共消耗的Tokens数。


异步回调示例

以下是一个异步回调实现的例子,用于演示如何在多个并发任务中统计Tokens:

python
复制代码
import asyncio

async def additional_interactions():
    with get_openai_callback() as cb:
        await asyncio.gather(
            *[llm.agenerate(["描述三种适合生日的花束"]) for _ in range(3)]
        )
    print("额外交互中使用的Tokens:", cb.total_tokens)

asyncio.run(additional_interactions())

此示例中,使用 asyncio.gather 并发运行多个任务,每个任务都在回调中统计消耗的Tokens。

自定义回调函数

我们也可以通过BaseCallbackHandler和AsyncCallbackHandler来自定义回调函数。下面是一个示例。

import asyncio
from typing import Any, Dict, List

from langchain.chat_models import ChatOpenAI
from langchain.schema import LLMResult, HumanMessage
from langchain.callbacks.base import AsyncCallbackHandler, BaseCallbackHandler

# 创建同步回调处理器
class MyFlowerShopSyncHandler(BaseCallbackHandler):
    def on_llm_new_token(self, token: str, **kwargs) -> None:
        print(f"获取花卉数据: token: {token}")

# 创建异步回调处理器
class MyFlowerShopAsyncHandler(AsyncCallbackHandler):

    async def on_llm_start(
        self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any
    ) -> None:
        print("正在获取花卉数据...")
        await asyncio.sleep(0.5)  # 模拟异步操作
        print("花卉数据获取完毕。提供建议...")

    async def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
        print("整理花卉建议...")
        await asyncio.sleep(0.5)  # 模拟异步操作
        print("祝你今天愉快!")

# 主要的异步函数
async def main():
    flower_shop_chat = ChatOpenAI(
        max_tokens=100,
        streaming=True,
        callbacks=[MyFlowerShopSyncHandler(), MyFlowerShopAsyncHandler()],
    )

    # 异步生成聊天回复
    await flower_shop_chat.agenerate([[HumanMessage(content="哪种花卉最适合生日?只简单说3种,不超过50字")]])

# 运行主异步函数
asyncio.run(main())

在这个鲜花店客服的程序中,当客户问及关于鲜花的建议时,我们使用了一个同步和一个异步回调。

MyFlowerShopSyncHandler 是一个同步回调,每当新的Token生成时,它就简单地打印出正在获取的鲜花数据。

而 MyFlowerShopAsyncHandler 则是异步的,当客服开始提供鲜花建议时,它会模拟数据的异步获取。在建议完成后,它还会模拟一个结束的操作,如向客户发出感谢。

这种结合了同步和异步操作的方法,使得程序能够更有效率地处理客户请求,同时提供实时反馈。

这里的异步体现在这样几个方面。

  1. 模拟延时操作:在MyFlowerShopAsyncHandler中,我们使用了await asyncio.sleep(0.5)来模拟其他请求异步获取花卉信息的过程。当执行到这个await语句时,当前的on_llm_start函数会“暂停”,释放控制权回到事件循环。这意味着,在这个sleep期间,其他异步任务(如其他客户的请求)可以被处理。  
  2. 回调机制:当ChatOpenAI在处理每个新Token时,它会调用on_llm_new_token方法。因为这是一个同步回调,所以它会立即输出。但是,开始和结束的异步回调on_llm_start和on_llm_end在开始和结束时都有一个小的延时操作,这是通过await asyncio.sleep(0.5)模拟的。  
  3. 事件循环:Python的syncio库提供了一个事件循环,允许多个异步任务并发运行。在我们的例子中,虽然看起来所有的操作都是按顺序发生的,但由于我们使用了异步操作和回调,如果有其他并发任务,它们可以在await暂停期间运行。

为了更清晰地展示异步的优势,通常我们会在程序中同时运行多个异步任务,并观察它们如何“并发”执行。但在这个简单的例子中,我们主要是通过模拟延时来展示异步操作的基本机制。

因此说,回调函数为异步操作提供了一个机制,使你可以定义“当操作完成时要做什么”,而异步机制的真正实现涉及更深层次的底层工作,如事件循环和任务调度。

用 get_openai_callback 构造令牌计数器

下面,我带着你使用LangChain中的回调函数来构造一个令牌计数器。这个计数功能对于监控大模型的会话消耗以及成本控制十分重要。

在构造令牌计数器之前,我们来回忆一下第11课中的记忆机制。我们用下面的代码生成了ConversationBufferMemory。

from langchain import OpenAI
from langchain.chains import ConversationChain
from langchain.chains.conversation.memory import ConversationBufferMemory

# 初始化大语言模型
llm = OpenAI(
    temperature=0.5,
    model_name="gpt-3.5-turbo-instruct")

# 初始化对话链
conversation = ConversationChain(
    llm=llm,
    memory=ConversationBufferMemory()
)

# 第一天的对话
# 回合1
conversation("我姐姐明天要过生日,我需要一束生日花束。")
print("第一次对话后的记忆:", conversation.memory.buffer)

# 回合2
conversation("她喜欢粉色玫瑰,颜色是粉色的。")
print("第二次对话后的记忆:", conversation.memory.buffer)

# 回合3 (第二天的对话)
conversation("我又来了,还记得我昨天为什么要来买花吗?")
print("/n第三次对话后时提示:/n",conversation.prompt.template)
print("/n第三次对话后的记忆:/n", conversation.memory.buffer)

同时,我们也给出了各种记忆机制对Token的消耗数量的估算示意图。

不过,这张图毕竟是估算,要真正地衡量出每种记忆机制到底耗费了多少个Token,那就需要回调函数上场了。

下面,我们通过回调函数机制,重构这段程序。为了做到这一点,我们首先需要确保在与大语言模型进行交互时,使用了get_openai_callback上下文管理器。

在Python中,一个上下文管理器通常用于管理资源,如文件或网络连接,这些资源在使用前需要设置,在使用后需要清理。上下文管理器经常与with语句一起使用,以确保资源正确地设置和清理。

  get_openai_callback被设计用来监控与OpenAI交互的Token数量。当你进入该上下文时,它会通过监听器跟踪Token的使用。当你退出上下文时,它会清理监听器并提供一个Token的总数。通过这种方式,它充当了一个回调机制,允许你在特定事件发生时执行特定的操作或收集特定的信息。

具体代码如下:

from langchain import OpenAI
from langchain.chains import ConversationChain
from langchain.chains.conversation.memory import ConversationBufferMemory
from langchain.callbacks import get_openai_callback

# 初始化大语言模型
llm = OpenAI(temperature=0.5, model_name="gpt-3.5-turbo-instruct")

# 初始化对话链
conversation = ConversationChain(
    llm=llm,
    memory=ConversationBufferMemory()
)

# 使用context manager进行token counting
with get_openai_callback() as cb:
    # 第一天的对话
    # 回合1
    conversation("我姐姐明天要过生日,我需要一束生日花束。")
    print("第一次对话后的记忆:", conversation.memory.buffer)

    # 回合2
    conversation("她喜欢粉色玫瑰,颜色是粉色的。")
    print("第二次对话后的记忆:", conversation.memory.buffer)

    # 回合3 (第二天的对话)
    conversation("我又来了,还记得我昨天为什么要来买花吗?")
    print("/n第三次对话后时提示:/n",conversation.prompt.template)
    print("/n第三次对话后的记忆:/n", conversation.memory.buffer)

# 输出使用的tokens
print("\n总计使用的tokens:", cb.total_tokens)

这里,我使用了get_openai_callback上下文管理器来监控与ConversationChain的交互。这允许我们计算在这些交互中使用的总Tokens数。

输出:

总计使用的tokens: 966

下面,我再添加了一个additional_interactions异步函数,用于演示如何在多个并发交互中计算Tokens。

当我们讨论异步交互时,指的是我们可以启动多个任务,它们可以并发(而不是并行)地运行,并且不会阻塞主线程。在Python中,这是通过asyncio库实现的,它使用事件循环来管理并发的异步任务。

import asyncio

进行更多的异步交互和token计数

async def additional_interactions():     with get_openai_callback() as cb:         await asyncio.gather(             *[llm.agenerate(["我姐姐喜欢什么颜色的花?"]) for _ in range(3)]         )     print("\n另外的交互中使用的tokens:", cb.total_tokens)

运行异步函数

asyncio.run(additional_interactions())


简单解释一下。

1.  `async def`:这表示additional_interactions是一个异步函数。它可以使用await关键字在其中挂起执行,允许其他异步任务继续。
1.  `await asyncio.gather(...)`:这是asyncio库提供的一个非常有用的方法,用于并发地运行多个异步任务。它会等待所有任务完成,然后继续执行。
1.  `*[llm.agenerate(["我姐姐喜欢什么颜色的花?"]) for _ in range(3)]`:这实际上是一个Python列表解析,它生成了3个 llm.agenerate(...)的异步调用。asyncio.gather将并发地运行这3个调用。

由于这3个llm.agenerate调用是并发的,所以它们不会按顺序执行,而是几乎同时启动,并在各自完成时返回。这意味着,即使其中一个调用由于某种原因需要更长时间,其他调用也不会被阻塞,它们会继续并完成。