本文翻译整理自: Introducing workflows beta: a new way to create complex AI applications with LlamaIndex (2024年 8月1日
https://www.llamaindex.ai/blog/introducing-workflows-beta-a-new-way-to-create-complex-ai-applications-with-llamaindex
文章目录
一、关于 workflow
我们很高兴推出LlamaIndex的全新测试功能:工作流,这是一种在我们看到用户构建的日渐复杂的 AI 应用程序中 编排操作的机制。
随着LLM的出现开始成为一种趋势,现在已经成为事实上的标准:人工智能应用程序由不同组件实现的多个任务组成。
市场上的开源框架通过为数据加载器、LLM、矢量数据库和重新排名器等基础组件提供易于使用的抽象,一直到外部服务,努力让人工智能工程师的生活更轻松。
与此同时,所有这些框架也在寻找协调这些组件的最佳抽象,研究什么对人工智能开发人员来说最直观和最有效,以便实现将复合人工智能系统结合在一起的逻辑。
其中两个潜在的编排模式是链和管道,它们都是相同有向无环图(DAG)抽象的实现。
我们尝试了一下今年年初发布的 查询管道 ——它是一个声明性API,可让您针对不同的用例(如QA、结构化提取和代理自动化)在数据上编排从简单到高级的查询工作流。
但是当我们试图在此基础上构建并尝试添加循环以更好地支持更复杂的工作流时,我们注意到了几个问题,这让我们思考为什么DAG可能不适合代理环境,以及我们可以在框架中引入哪些替代方案。
二、基于图的用户体验的局限性
DAG的一个基本方面是DAG中的“A”:它们是非循环的,这意味着没有循环。
但是在一个越来越代理的世界里,无法在人工智能应用程序的逻辑中执行循环是完全不可接受的。
例如,如果一个组件提供了糟糕的结果,人工智能开发人员应该有办法告诉系统自我纠正并重试。
即使没有向DAG添加循环和循环,查询管道也会遇到一些明显的问题:
- 出错时很难调试
- 它们掩盖了组件和模块的执行方式
- 我们的管道编排器变得越来越复杂,必须处理大量不同的边缘情况
- 对于复杂的管道,它们很难阅读
一旦我们为查询管道添加了周期,这些围绕图表的开发人员用户体验问题就被放大了。
我们在以下领域经历了第一手开发人员的痛苦:
-
许多核心编排逻辑,如
if-else
语句和while
循环都被烘焙到图的边缘。
定义这些边变得既麻烦又冗长。 -
围绕可选值和默认值处理边缘情况变得很困难。
作为一个框架,我们很难弄清楚参数是否会从上游节点传递。 -
对于构建代理的开发人员来说,用周期定义图并不总是那么自然。
代理封装了一个通用的LLM驱动的实体,可以接收观察并生成响应。
在这里,图用户体验强制“代理”节点明确定义传入边和传出边,迫使用户定义与其他节点的详细通信模式。
我们问:图真的是我们可以用来编排复合AI系统中的组件的唯一抽象吗?
三、从图表到EDA:去事件驱动
复合人工智能系统可以用LlamaIndex工作流来实现。
工作流通过一组称为步骤的Python函数来回调度事件。
每个步骤都可以被视为系统的一个组件:一个用于处理查询,一个用于与LLM对话,一个用于从矢量数据库加载数据,等等。
每个步骤都接收一个或多个要处理的事件,并且可以选择发送回事件,如果需要,这些事件将被中继到其他组件。
转向事件驱动的体系结构会导致设计的根本性转变。
在许多图实现中,图遍历算法负责确定下一步应该运行什么组件以及应该传递什么数据。
在事件驱动的体系结构中,组件订阅特定类型的事件,并最终负责根据它接收到的数据决定做什么。
在事件驱动的系统中,输入的可选性和默认值等概念在组件级别进行排序,从而显着简化了编排代码。
四、工作流入门
为了帮助澄清这个想法,让我们看一个例子。
1、基本使用
最小的LlamaIndex工作流程如下所示:
from llama_index.core.workflow import (
StartEvent,
StopEvent,
Workflow,
step,
)
from llama_index.llms.openai import OpenAI
class OpenAIGenerator(Workflow):
@step()
async def generate(self, ev: StartEvent) -> StopEvent:
query = ev.get("query")
llm = OpenAI()
response = await llm.acomplete(query)
return StopEvent(result=str(response))
w = OpenAIGenerator(timeout=10, verbose=False)
result = await w.run(query="What's LlamaIndex?")
print(result)
使用@step
装饰器将generate
函数标记为工作流步骤,并使用带有正确类型注释的方法签名声明它想要接收哪些事件以及将发回哪些事件。
为了运行工作流,我们创建一个OpenAIGenerator
类的实例,传递一些配置参数,如所需的超时,然后我们调用run
方法。
传递给run
的任何关键字参数都将被打包到一个StartEvent
类型的特殊事件中,该事件将被中继到请求它的步骤(在本例中,仅为generate
步骤)。
generate
步骤返回一个StopEvent
类型的特殊事件,该事件将指示工作流优雅地停止其执行。
StopEvent
携带我们希望作为工作流结果返回给调用者的任何数据,在本例中为LLM响应。
2、工作流可以循环
在事件驱动的架构中,循环与通信有关,而不是拓扑。
任何步骤都可以通过制作和发送适当的事件来决定多次调用另一个步骤。
例如,让我们看一个自我纠正循环(查看笔记本以获取完整代码):
class ExtractionDone(Event):
output: str
passage: str
class ValidationErrorEvent(Event):
error: str
wrong_output: str
passage: str
class ReflectionWorkflow(Workflow):
@step()
async def extract(
self, ev: StartEvent | ValidationErrorEvent
) -> StopEvent | ExtractionDone:
if isinstance(ev, StartEvent):
passage = ev.get("passage")
if not passage:
return StopEvent(result="Please provide some text in input")
reflection_prompt = ""
elif isinstance(ev, ValidationErrorEvent):
passage = ev.passage
reflection_prompt = REFLECTION_PROMPT.format(
wrong_answer=ev.wrong_output, error=ev.error
)
llm = Ollama(model="llama3", request_timeout=30)
prompt = EXTRACTION_PROMPT.format(
passage=passage, schema=CarCollection.schema_json()
)
if reflection_prompt:
prompt += reflection_prompt
output = await llm.acomplete(prompt)
return ExtractionDone(output=str(output), passage=passage)
@step()
async def validate(
self, ev: ExtractionDone
) -> StopEvent | ValidationErrorEvent:
try:
json.loads(ev.output)
except Exception as e:
print("Validation failed, retrying...")
return ValidationErrorEvent(
error=str(e), wrong_output=ev.output, passage=ev.passage
)
return StopEvent(result=ev.output)
w = ReflectionWorkflow(timeout=60, verbose=True)
result = await w.run(
passage="There are two cars available: a Fiat Panda with 45Hp and a Honda Civic with 330Hp."
)
print(result)
在本例中,validate
步骤将尝试模式提取的结果作为事件接收,它可以通过返回最终将传递给extract
步骤的ValidationErrorEvent
来决定重试,该步骤将执行另一次尝试。
请注意,在本例中,如果提取/验证循环持续提供较差的结果时间过长,工作流可能会超时,但另一种策略可能会在尝试了精确的次数后放弃,仅举一个例子。
3、工作流保持状态
工作流在执行过程中保持全局状态,该状态可以根据请求共享并传播到其步骤。
这种共享状态被实现为Context
对象,可以由步骤用于在迭代之间存储数据,也可以作为不同步骤之间的替代通信形式。
让我们看一个更复杂的RAG示例的摘录,作为一个示例,展示如何使用全局上下文(查看笔记本以获取完整代码):
class RAGWorkflow(Workflow):
@step(pass_context=True)
async def ingest(self, ctx: Context, ev: StartEvent) -> Optional[StopEvent]:
dataset_name = ev.get("dataset")
_, documents = download_llama_dataset(dsname, "./data")
ctx.data["INDEX"] = VectorStoreIndex.from_documents(documents=documents)
return StopEvent(result=f"Indexed {len(documents)} documents.")
...
在这种情况下,ingest
步骤创建了一个索引,它希望将其提供给稍后在工作流执行期间可能需要它的任何其他步骤。
在LlamaIndex工作流中执行此操作的惯用方法是声明该步骤需要全局上下文的实例(@step(pass_context=True)
并使用其他步骤稍后可能访问的预定义键将索引存储在上下文本身中。
4、工作流程可以定制
除了工作流,我们还将发布一组预定义的工作流,以便最常见的用例可以用一行代码来实现。
使用这些预定义的流,用户可能仍然希望稍微更改预定义的工作流以引入一些自定义行为,而无需从头开始重写整个工作流。
假设您想自定义RAG工作流并使用自定义重新排名步骤,您需要做的就是子类化一个假设的内置RAGWorkflow
类并覆盖rerank
步骤,如下所示:
class MyWorkflow(RAGWorkflow):
@step(pass_context=True)
def rerank(
self, ctx: Context, ev: Union[RetrieverEvent, StartEvent]
) -> Optional[QueryResult]:
# my custom reranking logic here
w = MyWorkflow(timeout=60, verbose=True)
result = await w.run(query="Who is Paul Graham?")
5、可以调试工作流
工作流的复杂性会随着应用程序逻辑的复杂性而增加,有时仅通过查看Python代码很难理解事件在执行过程中是如何流动的。
为了简化对复杂工作流的理解并支持工作流执行的调试,LlamaIndex提供了两个功能:
draw_all_possible_flows
生成一个图片,显示工作流中的所有步骤以及事件可能如何流动draw_most_recent_execution
生成类似的图片,仅显示上次工作流执行期间实际发送的事件
最重要的是,可以通过多次调用run_step()
手动执行工作流,直到所有步骤完成。
每次run_step
调用后,可以检查工作流,检查任何中间结果或调试日志。
五、为什么今天应该使用工作流
尽管LlamaIndex工作流处于开发的早期阶段,但与查询管道相比,它已经向前迈进了一步,扩展了它们的功能并增加了更多的灵活性。
最重要的是,工作流附带了一组您通常期望从更成熟的软件中获得的功能:
- 完全异步,支持流式传输
- 默认检测,通过支持的集成提供一键式可观察性
- 分步执行,更易于调试
- 事件驱动依赖项的验证和可视化
- 事件被实现为pydantic模型,以简化定制和新功能的进一步开发
六、资源
2024-08-06(二)