原文:
towardsdatascience.com/deep-dive-into-llamaindex-workflow-event-driven-llm-architecture-8011f41f851a
最近,LlamaIndex 在其某个版本中引入了一个名为Workflow的新功能,为 LLM 应用提供了事件驱动和逻辑解耦的能力。
在今天的文章中,我们将通过一个实际的迷你项目深入了解这个功能,探索新功能和仍需改进的地方。让我们开始吧。
简介
为什么是事件驱动的?
越来越多的 LLM 应用正在转向智能代理架构,期望 LLMs 通过调用不同的 API 或多次迭代调用来满足用户请求。
然而,这种转变也带来一个问题:随着代理应用进行更多的 API 调用,程序响应速度变慢,代码逻辑变得更加复杂。
一个典型的例子是ReActAgent,它涉及思考、行动、观察和最终答案等步骤,至少需要三次 LLM 调用和一次工具调用。如果需要循环,会有更多的 I/O 调用。
一个典型的 ReAct 代理至少会调用 LLM 三次。图片由作者提供
有没有一种方法可以优化这一点?
如上图所示,在传统的编程模型中,所有的 I/O 调用都是线性的;下一个任务必须等待前一个任务完成。
尽管主流的 LLM 现在支持通过流输出生成结果,但在代理应用中,我们仍然需要在返回或进入下一阶段之前等待 LLM 完成结果生成。
实际上,我们不需要所有的 I/O 调用都按顺序进行;它们可以像下面图表所示的那样并发执行:
在并发编程中,多个步骤是并行执行的。图片由作者提供
这个图表看起来熟悉吗?是的,Python 的asyncio包提供了执行 I/O 绑定任务并发的功能,几乎所有基于 I/O 的 API,包括 LLM 客户端,都支持并发执行。
LlamaIndex 的工作流程也利用了并发编程的原则。它更进一步,不仅封装了asyncio库的细节,还提供了一种事件机制,使我们能够解耦业务流程的不同部分。
现在我们已经了解了背景,让我们通过一个实际项目来逐步了解 LlamaIndex 工作流程。
第一印象
在主菜之前,让我们通过一个简单的代码示例熟悉元素和基本原理,作为开胃菜。
导入必要的包
首先,我们需要导入必要的工具。工作流程已经包含在最新版本的 LlamaIndex 中,不需要单独安装。
from llama_index.core.workflow import (
Event,
StartEvent,
StopEvent,
Workflow,
Context,
step,
)
定义一些事件
由于工作流程是一个事件驱动框架,我们应该首先定义一些事件。
为了避免不一致性,我们首先定义一个BaseEvent,确保所有事件都使用payload键进行消息传递。
class BaseEvent(Event):
payload: str | dict | None
让我们定义我们今天的第一事件:SecondStepEvent
class SecondStepEvent(BaseEvent):
...
从简单开始
接下来,让我们开始编写我们的第一个工作流程程序,它是一个包含两个方法的Workflow子类:
class SimpleWorkflow(Workflow):
@step
async def start(self, ev: StartEvent) -> SecondStepEvent:
return SecondStepEvent(payload=ev.payload)
@step
async def second_step(self, ev: SecondStepEvent) -> StopEvent:
return StopEvent(result=ev.payload)
-
start方法接受一个StartEvent,然后返回一个SecondStepEvent。 -
second_step方法接受一个SecondStepEvent,然后返回一个StopEvent。
让我们运行代码看看它是如何工作的。
s_wf = SimpleWorkflow(timeout=10, verbose=True)
result = await s_wf.run(payload="hello world")
print(result)
我们已经开启了verbose选项,这样我们就可以详细看到代码是如何执行的。
我们第一个工作流程程序执行的结果。图片由作者提供
尝试使用可视化工具
LlamaIndex 还慷慨地提供了一个小工具,允许我们查看整个工作流程过程,非常直观。
from llama_index.utils.workflow import draw_all_possible_flows
draw_all_possible_flows(SimpleWorkflow, filename="simple_workflow.html")
第一个工作流程代码的流程图。图片由作者提供
解释原理
快速查看源代码可以发现,工作流程内部维护了一个Context,它不仅保持一个事件队列,还维护一个包含每个步骤的字典。
工作流程使用 run_flow 循环来监听事件并执行步骤。图片由作者提供
当工作流程初始化时,step装饰器分析每个方法的签名以确定它将接收和返回哪些事件,开始监听事件队列,并将此方法存储在step字典中。
当工作流程的run方法启动时,它启动一个runflow循环,最初将StartEvent放入事件队列。如果有接受这个StartEvent的方法,它将开始执行并返回相应的事件,将其放回事件队列。
step方法也可以直接调用 Context 的send_event方法,将事件放入队列。
如果 runflow 循环在队列中检测到StopEvent,它将退出流程并返回最终结果。
在对元素和实现原理有基本了解之后,我们可以通过一个动手项目来探索工作流程的优势和不足。
动手项目
在今天的实战项目中,我们将帮助超市的采购经理创建一个基于客户反馈管理 SKU 库存的系统,展示 Workflow 的分支和循环控制、流式事件和并发执行功能。
分支和循环控制
在反馈监控的第一个版本中,我们将持续监控某个 SKU 的最新反馈,分析输入中隐含的反馈,然后采取相应的行动。
整个代码逻辑如下所示:
我们反馈监控程序的流程图。图片由作者提供
首先,我们将定义一个 InventoryManager 类,该类使用 async 实现了 place_order 和 clear_out 方法。
class InventoryManager:
async def place_order(self, sku: str) -> None:
await asyncio.sleep(0.5)
print(f"Will place an order for {sku}")
async def clear_out(self, sku: str) -> None:
await asyncio.sleep(0.5)
print(f"Will clear out {sku}")
我们还需要实现四个事件:LoopEvent、GetFeedbackEvent、OrderEvent 和 ClearEvent,它们都是 BaseEvent 的子类,确保它们遵循统一的消息传递接口。
class LoopEvent(BaseEvent):
...
class GetFeedbackEvent(BaseEvent):
...
class OrderEvent(BaseEvent):
...
class ClearEvent(BaseEvent):
...
接下来,我们开始实现 FeedbackMonitorWorkflow 类,它包含核心业务逻辑。
class FeedbackMonitorWorkflow(Workflow):
def __init__(self, total_cycle: int = 1, *args, **kwargs) -> None:
self.total_cycle = total_cycle
self.counter = 0
self.manager = InventoryManager()
super().__init__(*args, **kwargs)
@step
async def begin(self, ev: StartEvent | LoopEvent)
-> GetFeedbackEvent | StopEvent:
print("We now return to the begin step")
if isinstance(ev, StartEvent):
self.sku = ev.payload
if self.counter < self.total_cycle:
await asyncio.sleep(3)
self.counter += 1
return GetFeedbackEvent(payload=self.sku)
else:
return StopEvent(result="We're done for the day.")
@step
async def get_feedback(self, ev: GetFeedbackEvent) -> OrderEvent | ClearEvent:
print(f"Wil get the latest feedback for {ev.payload}")
if random.random() < 0.3:
return ClearEvent(payload='Bad')
else:
return OrderEvent(payload='Good')
@step
async def order(self, ev: OrderEvent) -> LoopEvent:
print(f"We now buy some sku with feedback {ev.payload}.")
await self.manager.place_order(self.sku)
return LoopEvent(payload="Start a new cycle.")
@step
async def clear(self, ev: ClearEvent) -> LoopEvent:
print(f"We now sell some sku with feedback {ev.payload}")
await self.manager.clear_out(self.sku)
return LoopEvent(payload="Start a new cycle.")
-
begin方法是我们的入口点,接受StartEvent和LoopEvent。 -
StartEvent是启动代码的默认事件,我们通过此事件传递 SKU。 -
GetFeedbackEvent触发get_feedback方法以获取反馈信息。为了简化,我们使用random方法生成两个反馈,“Good” 和 “Bad”,然后根据反馈返回相应的OrderEvent或ClearEvent。 -
交易完成后,
LoopEvent重新初始化begin方法以开始新一轮的循环。为了简化代码,我们只设置了一个循环。 -
在每个循环中,
begin方法返回一个GetFeedbackEvent以触发最新 SKU 反馈的获取。如果所有循环都已完成,则返回一个StopEvent。 -
当接收到
OrderEvent或ClearEvent时,相应的step方法根据消息体中的情感标志执行交易,并返回一个LoopEvent以启动新的循环。
如您所见,通过使用事件,我们可以解耦复杂的循环和分支过程,使得相应的事件可以触发新的循环。
让我们使用 draw_all_possible_flows 工具来看看流程图是否与我们的设计业务逻辑图匹配。
draw_all_possible_flows(FeedbackMonitorWorkflow, filename="feedback_monitor_workflow.html")
我们使用事件来解耦分支和循环控制。图片由作者提供
就这样了吗?如果只是关于解耦循环和分支控制,我难道不能通过一些编程技巧实现吗?
是的,但流程控制只是最表面的层。接下来,让我们体验一下将 asyncio 与 Workflow 结合使用所释放的强大潜力。
流式事件
在构建代理链时,最令人头疼的问题之一是如何在执行过程中向用户反馈消息,帮助他们理解代码执行的进度。
在上面的代码中,我们使用print方法在控制台上实时打印进度,但这种方法对于 Web 应用来说不可行。
一种解决方案是启动一个单独的管道以实时向用户推送消息,但当多个步骤并发执行时,如何处理这个管道成为一个挑战。
幸运的是,工作流的上下文直接提供了一个消息流管道,我们可以方便地将消息写入这个管道,并通过一个async for循环在调用端统一处理它们。
LlamaIndex 工作流使用流队列输出消息。图片由作者提供
让我们修改我们之前的交易程序:
class ProgressEvent(BaseEvent):
...
class FeedbackMonitorWorkflowV2(Workflow):
def __init__(self, total_cycle: int = 1, *args, **kwargs) -> None:
self.total_cycle = total_cycle
self.counter = 0
self.manager = InventoryManager()
super().__init__(*args, **kwargs)
@step
async def begin(self, ctx: Context,
ev: StartEvent | LoopEvent)
-> GetFeedbackEvent | StopEvent:
ctx.write_event_to_stream(
ProgressEvent(payload="We now return to the begin step")
)
...
@step
async def get_feedback(self, ctx: Context,
ev: GetFeedbackEvent) -> OrderEvent | ClearEvent:
ctx.write_event_to_stream(
ProgressEvent(payload=f"Wil get the latest feedback for {ev.payload}")
)
...
@step
async def order(self, ctx: Context,
ev: OrderEvent) -> LoopEvent:
ctx.write_event_to_stream(
ProgressEvent(payload=f"We now buy some sku with feedback {ev.payload}.")
)
...
@step
async def clear(self, ctx: Context,
ev: ClearEvent) -> LoopEvent:
ctx.write_event_to_stream(
ProgressEvent(payload=f"We now sell some sku with feedback {ev.payload}")
)
...
在第一步中,我们在step方法的签名中传递一个Context类型参数。这使工作流知道将当前执行上下文传递到step方法中。
然后,我们将print方法替换为ctx.write_event_to_stream方法,以实时将消息写入管道。
最后,在等待最终结果之前,我们使用stream_events方法遍历消息管道的最新消息。
from datetime import datetime
def streaming_log(message: str) -> None:
current_time = datetime.now().strftime("%H:%M:%S")
print(f"{current_time} {message}")
feedback_monitor_v2 = FeedbackMonitorWorkflowV2(timeout=10, verbose=False)
handler = feedback_monitor_v2.run(payload="Apple")
async for event in handler.stream_events():
if isinstance(event , ProgressEvent):
streaming_log(event.payload)
final_result = await handler
print("Final result: ", final_result)
在代码执行过程中,工作流通过流队列流式传输消息。图片由作者提供
并发执行
如文章开头所述,对于 I/O 密集型任务,我们可以使用asyncio包来使代码并发执行,从而大大提高运行效率。工作流为我们实现了这一机制,封装了asyncio执行代码,让我们专注于代码逻辑。
让我们以FeedbackMonitor项目为例进行解释。
我们可以让多个步骤并行执行以优化执行时间。图片由作者提供
这次,我们将升级项目,使FeedbackMonitor能够通过一个来源而不是同时通过在线、离线和机器学习趋势预测器来判断是“好”还是“坏”。
首先,我们添加了六个事件:OnlineEvent、OnlineFeedbackEvent、OfflineEvent、OfflineFeedbackEvent、TrendingPredictionEvent和PredictionResultEvent。
from collections import Counter
class OnlineEvent(BaseEvent):
...
class OnlineFeedbackEvent(BaseEvent):
...
class OfflineEvent(BaseEvent):
...
class OfflineFeedbackEvent(BaseEvent):
...
class TrendingPredictionEvent(BaseEvent):
...
class PredictionResultEvent(BaseEvent):
...
class TradeEvent(BaseEvent):
...
然后,我们编写一个ComplexFeedbackMonitor类作为新的工作流。
class ComplexFeedbackMonitor(Workflow):
def __init__(self, *args, **kwargs):
self.manager = InventoryManager()
super().__init__(*args, **kwargs)
@step
async def start(self, ctx: Context, ev: StartEvent)
-> OnlineEvent | OfflineEvent | TrendingPredictionEvent:
self.sku = ev.payload
ctx.send_event(OnlineEvent(payload=ev.payload))
ctx.send_event(OfflineEvent(payload=ev.payload))
ctx.send_event(TrendingPredictionEvent(payload=ev.payload))
@step
async def online_feedback(self, ev: OnlineEvent) -> OnlineFeedbackEvent:
await asyncio.sleep(random.randint(1, 3))
if random.random() < 0.3:
return OnlineFeedbackEvent(payload='Bad')
else:
return OnlineFeedbackEvent(payload='Good')
@step
async def offline_feedback(self, ev: OfflineEvent) -> OfflineFeedbackEvent:
await asyncio.sleep(random.randint(1, 3))
if random.random() < 0.3:
return OfflineFeedbackEvent(payload='Bad')
else:
return OfflineFeedbackEvent(payload='Good')
@step
async def trending_predict(self, ev: TrendingPredictionEvent) -> PredictionResultEvent:
await asyncio.sleep(random.randint(1, 3))
if random.random() < 0.3:
return PredictionResultEvent(payload='Bad')
else:
return PredictionResultEvent(payload='Good')
@step
async def trading_decision(self, ctx: Context,
ev: OnlineFeedbackEvent | OfflineFeedbackEvent | PredictionResultEvent)
-> TradeEvent:
results = ctx.collect_events(ev,
[OnlineFeedbackEvent, OfflineFeedbackEvent, PredictionResultEvent])
if results is not None:
voting = dict(Counter([ev.payload for ev in results]))
print(voting)
feedback = max(voting, key=voting.get)
return TradeEvent(payload=feedback)
@step
async def trade(self, ev: TradeEvent) -> StopEvent:
feedback = ev.payload
match feedback:
case 'Goode':
await self.manager.place_order(self.sku)
case 'Bad':
await self.manager.clear_out(self.sku)
case _:
print("Do nothing")
return StopEvent(result='We are done for the day.')
在start方法中,我们使用ctx.send_event同时抛出OnlineEvent、OfflineEvent和TrendingPredictionEvent。由于工作流程根据step方法的类型注解确定抛出的消息,我们仍然需要标记返回的消息类型。
接下来,我们实现online_feedback、offline_feedback和trending_predict方法来获取交易信号并返回相应的事件。
我们仍然使用random方法来模拟客户反馈分析。
由于不同来源的内容需要不同的解析时间,我们希望在做出交易决策之前等待所有消息返回。此时,我们可以在trading_decision方法中使用ctx.collect_events方法。
每次新的反馈事件返回时,trading_events方法执行一次。
但ctx.collect_events方法接受所有我们需要等待的事件作为参数,其返回值在所有反馈事件返回之前保持为空。到那时,返回值是一个包含三个反馈事件的列表。
我们可以使用Counter方法来统计“好”和“坏”出现的次数,然后根据最多投票的标记做出交易决策。
最后,让我们使用draw_all_possible_flows工具来看看我们新设计的流程有多酷:
draw_all_possible_flows(ComplexFeedbackMonitor, filename='complex_feedback_monitor.html')
工作流程并行执行三个异步任务并获取最终结果。图片由作者提供
接下来,让我们执行这个工作流程并看看结果。
feedback_monitor = ComplexFeedbackMonitor(timeout=20, verbose=True)
result = await feedback_monitor.run(payload='Apple')
print(result)
使用工作流程并行执行代码的详细过程。图片由作者提供
我们可以观察到,从不同来源获取反馈的三个方法同时被触发,但返回时间不同。
前两个返回的事件可以触发trading_decision方法,但不能继续触发TradeEvent。只有当所有三个事件返回并计算出最终交易决策后,才会触发TradeEvent。
如您所见,借助工作流程的力量,我们确实可以使我们的代码架构既清晰又高效。
但不要过于乐观,因为经过一段时间的实践,我认为还有一些不足之处。
是时候谈谈不足之处了
如果您回顾我们之前的代码,您会注意到我们所有的代码逻辑都写在了同一个工作流程中,这对于简单的应用来说是可以的,但对于复杂的实际应用来说却是一个灾难。
理想情况下,我们应该将不同的逻辑拆分到工作流程中,以保持“单一职责”原则的纯洁性。满足这一需求官方解决方案是嵌套工作流程:
嵌套工作流程
假设我们想要将交易订单逻辑从FeedbackMonitor中分离出来成为一个独立的工作流程。当我们需要下单时应该如何调用它?
官方解决方案是嵌套工作流程,即在 A 工作流程的step方法中传递另一个工作流程 B 作为参数。然后,在 A 工作流程实例化后,添加 B 工作流程的实例。如下面的代码所示:
class OrderStation(Workflow):
def __init__(self, *args, **kwargs):
self.manager = InventoryManager()
super().__init__(*args, **kwargs)
@step
async def trade(self, ev: StartEvent) -> StopEvent:
print("We are now in a new workflow named OrderStation")
feedback = ev.feedback
match feedback:
case 'Good':
await self.manager.place_order(ev.sku)
case 'Bad':
await self.manager.clear_out(ev.sku)
return StopEvent(result="Done!")
class ComplexFeedbackMonitorV2(ComplexFeedbackMonitor):
@step
async def trade(self, ev: TradeEvent, order_station: OrderStation) -> StopEvent:
feedback = ev.payload
await order_station.run(feedback=feedback, sku=self.sku)
return StopEvent(result='We are done for the day.')
feedback_monitor_v2 = ComplexFeedbackMonitorV2(timeout=20, verbose=False)
feedback_monitor_v2.add_workflows(
order_station=OrderStation(timeout=10, verbose=True)
)
result = await feedback_monitor_v2.run(payload='Apple')
print(result)
等一下,如果你有 Java 开发经验,你会不会对看到这段代码感到惊讶:这不是依赖注入吗?
嵌套工作流程就像“依赖注入”。图片由作者提供
这确实类似于依赖注入,但不同之处在于我们仍然需要在实例初始化后显式地添加特定的工作流程实例,因此仍然存在耦合,这是第一个问题。
在编码过程中,我还发现了一个问题,那就是对于嵌套工作流程,我只能通过run方法来调用它们,而不能从外部工作流程中调用嵌套工作流程中的相应step方法。
因此,这不是工作流程之间通信的好解决方案。
工作流程之间的通信
那么,有没有真正实现工作流程之间通信的方法?我搜索了 API 文档,但没有找到官方解决方案,我还注意到这个问题也没有得到解答。所以我决定亲自尝试看看是否能解决这个问题。
再次审查源代码后,我认为ctx.send_event方法有一些潜力,所以我首先想到的是,是否在两个工作流程之间共享相同的 Context 可以解决这个问题?
我注意到实例化Context需要传递一个workflow实例,并且可以通过在run方法中传递来设置工作流程自己的 Context。
因此,代码如下,保持两个工作流程不变,只是OrderStation中的step方法不再接受StartEvent,而是接受特定的TradeEventV2。
class TradeEventV2(Event):
feedback: str
sku: str
class OrderStation(Workflow):
def __init__(self, *args, **kwargs):
self.manager = InventoryManager()
super().__init__(*args, **kwargs)
@step
async def trade(self, ev: TradeEventV2) -> StopEvent:
print("We are now in a new workflow named OrderStation")
feedback = ev.feedback
match feedback:
case 'Good':
await self.manager.place_order(ev.sku)
case 'Bad':
await self.manager.clear_out(ev.sku)
return StopEvent(result="Done!")
class ComplexFeedbackMonitorV3(ComplexFeedbackMonitor):
@step
async def trade(self, ctx: Context, ev: TradeEvent) -> StopEvent | TradeEventV2:
feedback = ev.payload
ctx.send_event(
TradeEventV2(feedback=feedback, sku=self.sku)
)
return StopEvent(result='We are done for the day.')
然后,我使用OrderStation创建一个 Context 实例,并在run方法执行期间将其传递给FeedbackMonitor实例,果然,它抛出了一个错误:
feedback_monitor_v3 = ComplexFeedbackMonitorV3(timeout=20, verbose=False)
result = await feedback_monitor_v3.run(payload='Apple')
print(result)
当我使用 Context 在两个工作流程之间进行通信时,我遇到了错误。图片由作者提供
看起来方法签名验证出了问题,让我们尝试关闭验证:
feedback_monitor_v3 = ComplexFeedbackMonitorV3(timeout=20, verbose=False, disable_validation=True)
order_station = OrderStation(timeout=10, verbose=True)
result = await feedback_monitor_v3.run(ctx=Context(workflow=order_station),
payload='Apple')
print(result)
仍然没有成功,看起来这种方法行不通。
TradeStation 中的交易方法没有被触发。图片由作者提供
未绑定语法
然后,我注意到文档中提到了一种 Unbound 语法,这似乎能够将每个步骤的逻辑从 Workflow 中解耦。以下是一个示例代码:
class TestWorkflow(Workflow):
...
@step(workflow=TestWorkflow)
def some_step(ev: StartEvent) -> StopEvent:
return StopEvent()
尽管我们仍然只能在单个 Workflow 中运行,但这让我感受到了模块间通信的可行性。
由于文章篇幅较长,这里我将不使用代码进行解释,让我向您展示如何使用 Unbound 语法进行模块通信的图表:
描述如何将 Unbound 语法将代码逻辑解耦为多个模块的图表。图片由作者提供
如图表所示:首先,我们可以定义一个 Application 类作为 Workflow 管道,并同时定义所需的事件。
然后,每个项目团队都可以编写自己的业务逻辑代码,并使用不同的 step 方法来监听和发送外部消息。
最后,我们可以在 fastapi API 中的 Application 调用 run 方法来激活各种模块以完成任务。
这样,业务逻辑可以分解为不同的模块进行开发,然后可以使用事件调用不同的 step 方法。
这确实实现了逻辑解耦的目的。然而,由于这种方法仅通过 add_step 方法在 step 装饰器中将每个步骤注册到 Workflow 中,它仍然没有实现 Workflow 之间的真正通信。
摘要
LlamaIndex 的 Workflow 的新特性使得 RAG、LLM 生成和 I/O 调用的并行执行变得非常简单,事件驱动的架构也允许程序从复杂的逻辑控制中解耦。
在今天的文章中,我通过一个 FeedbackMonitor 项目演示了 Workflow 的几个特性。
在项目实践中,我们也发现 Workflow 在模块间通信方面仍有不足,我们讨论了包括嵌套 Workflow 和 Unbound 语法在内的不同解决方案。
最后,随着像 Langchain 和 AutoGen 这样的代理框架开始提出它们自己的事件驱动架构,我相信 Workflow 正在正确的道路上,并将看到长期的发展。让我们密切关注它。
喜欢这篇文章吗?现在订阅,直接将更多前沿的数据科学技巧发送到您的邮箱! 欢迎您的反馈和提问 - 让我们在下面的评论中讨论!
这篇文章最初发表在 Data Leads Future。
723

被折叠的 条评论
为什么被折叠?



