首页 / 文章

追踪检索步骤:基于同一RAG智能体对比六种可观测性工具

使用Opik、MLflow、Langfuse、Phoenix、AcruxCore和Helicone这六种LLM可观测性工具对一个RAG智能体进行了追踪,同时比较了装饰器、OpenTelemetry以及代理技术。

发布者:REACTAPP.TOP阅读约 10 分钟2399 词

来源: Tracing a RAG pipeline: how six LLM observability tools handle the step that isn’t a model call

检索增强生成是一个两步流程,其中仅有一步涉及模型调用。为了解析可观测性工具是如何记录另一部分的,我们使用同一个小型代理通过六个平台进行了测试:Opik、MLflow、Langfuse、Phoenix、AcruxCore和Helicone。

为何检索步骤能绕过常规封装机制

单次模型调用很容易被监控:只需修改OpenAI客户端,每次chat.completions.create()调用都会显示为一个时间片段。添加检索步骤看似只是小改动——获取上下文、调用模型,同样的修改应该仍能覆盖所有操作。但实际上只能覆盖一半。

换言之,RAG流程至少包含两个需要追踪的独立部分。其一是检索步骤,客户端封装层不会感知到这一过程;其二是生成步骤,它与普通的模型调用类似。该实验正是针对这一衔接点展开的,在多次运行之间保持其他所有条件不变。

研究结果简述

  • 装饰器起主导作用。Opik、MLflow和Langfuse均只需添加一行代码即可将函数转换为可追踪的步骤,而不会改动函数主体内容。
  • OpenTelemetry在两个平台均有效。Phoenix和AcruxCore都支持OTLP协议,因此只需通过切换端点,即可将同一手写的追踪片段发送到这两个平台。该流程已在两者上进行了测试。
  • 请求路径中存在两种产品。Helicone的代理和AcruxCore的网关无需客户端代码即可记录模型执行过程,但两者都无法检测到那些从未与模型提供方发生交互的步骤。
  • Helicone没有处理数据检索的方案,因为其整个产品就是代理,且不存在用于记录该步骤的SDK。
  • MLflow提供了最丰富的操作类型词汇表,共有十五种类型,包括RERANKER、GUARDRAIL和EVALUATOR,这些类型是根据SDK源代码而非文档统计得出的。
  • 什么是操作类型

    可以将追踪视为一条记录请求所经过各个阶段的列表。每个阶段都是一个跨度,包含名称、开始时间和结束时间以及输入和输出内容。这些信息能够显示发生了什么以及持续了多久,但无法揭示具体操作的性质:一个标记为step_2、持续900毫秒的跨度既可能是SQL查询,也可能是LLM调用或HTTP请求。区分这些操作的任务由跨度类型这一标签来完成。每种工具都会定义一组固定的类别,每个跨度都会被分配到一个类别中。

    以这里的RAG代理为例,一个请求会生成三个跨度:

    ┌────────────────┬────────────────────────────────┬────────────────┐
    │   Span name    │          What it did           │   Span type    │
    ├────────────────┼────────────────────────────────┼────────────────┤
    │ rag-agent      │ the whole request              │ agent or chain │
    ├────────────────┼────────────────────────────────┼────────────────┤
    │ search_docs    │ queried Chroma for four chunks │ retrieval      │
    ├────────────────┼────────────────────────────────┼────────────────┤
    │ ChatCompletion │ called gpt-4o-mini             │ llm            │
    └────────────────┴────────────────────────────────┴────────────────┘
    

    在这里,search_docs只是开发者自行指定的名称,而检索功能则来自SDK支持的数值列表。界面会在search_docs前显示为“检索芯片”,在补全内容前显示为“LLM芯片”。

    类型很重要,名称并非唯一决定因素,因为工具会根据类型来运作。它会选择相应的图标和布局——检索区域会显示获取到的文档,而模型区域则会显示令牌数量和成本。该系统还支持过滤功能,因此像“哪些检索步骤耗时超过一秒”这样的查询在所有追踪数据中都有效,即便开发人员给函数起了不同的名称。此外,它还能用于分析:检索环节与生成环节分别造成了多少延迟,这些信息都可以通过类型列来查询。

    这些词汇表的长度各不相同。AcruxCore支持七种:llm、tool、retrieval、embedding、agent、chain以及其他。MLflow则支持十五种,其中包括RERANKER、GUARDRAIL、EVALUATOR、PARSER和MEMORY。Opik仅支持四种:general、tool、llm和guardrail。

    正在测试的智能体

    这个智能体被刻意设计得较为简单。它会将OpenTelemetry文档的五页内容分割成大约1,000字符一段,使用text-embedding-3-small模型对它们进行嵌入处理,然后存储在内存中的Chroma集合中。当收到一个关于“span是什么”以及“父span和子span如何构成一条追踪路径”的固定问题时,它会取出四段嵌入内容,再由gpt-4o-mini模型根据这些内容生成答案。

    这六个平台都故意不拥有这些文档。如果智能体回答了关于所比较工具中的某一个的问题,测试就会变成循环论证,而且答案文本会出现在每一个截图中。

    def retrieve_context(collection, question: str, top_k: int = 4) -> str:
        [query_embedding] = embed_texts([question])
        results = collection.query(query_embeddings=[query_embedding], n_results=top_k)
        chunks = results["documents"][0]
        sources = [m["source"] for m in results["metadatas"][0]]
        return "\n\n".join(f"[{src}]\n{chunk}" for src, chunk in zip(sources, chunks))
    

    在这六次运行中,该函数、分块步骤以及嵌入调用本身并未发生变化,唯一不同的就是围绕它们添加的监控机制。所有工具均在本地机器上运行:五个通过 Docker 自托管,还有一个是 AcruxCore。LangSmith 和 W&B Weave 被排除在外,因为它们仅以托管服务的形式存在。从它的 SDK 来看,LangSmith 可以被视为第四种装饰器示例,拥有七种运行类型,但由于实际上并未观察到相关痕迹,因此无法进一步确认其特性。

    模式 1:装饰器

    这是各类工具的交汇点,也是理所当然的:函数体保持与原代码完全一致,仅对现有代码添加注解。Opik 的实现方式与其文档描述相符:

    @opik.track(name="search_docs", project_name=PROJECT_NAME)
    def search_docs(collection, query: str) -> str:
        return rag_core.retrieve_context(collection, query)
    

    MLflow在附加了span类型后仍保持相同的结构:

    @mlflow.trace(name="search_docs", span_type=SpanType.RETRIEVER)
    def search_docs(collection, query: str) -> str:
        return rag_core.retrieve_context(collection, query)
    

    Langfuse 提供了 @observe,在初次接触其上下文管理器示例时很容易被忽略:

    @observe(name="search_docs", as_type="retriever")
    def search_docs(collection, query: str) -> str:
        return rag_core.retrieve_context(collection, query)
    

    在三种方案中,模型调用都会自动嵌套。每种方案都提供了经过修改的 OpenAI 客户端(track_openai()、mlflow.openai.autolog() 和 langfuse.openai),因此生成过程会取决于当前正在运行的哪个装饰函数。这样一来,单个装饰器就能形成两层深的树结构。

    Langfuse 生成的输出最为简洁。其检索阶段的 search_docs 所需时间为 1.05 秒,而其单独记录的生成阶段时间为 4.14 秒,其中已包含成本与令牌总数。在 MLflow 中,这两个阶段分别显示检索时间为 0.47 秒,记录的 Completions 阶段时间为 3.37 秒。Opik 也绘制出了正确的树结构,但存在一个外观上的问题:追踪阶段与根节点阶段的名称相同,导致 rag-agent 在 search_docs 之上被列出两次。

    模式 2:供应商提供的上下文管理器

    Langfuse 还会暴露 span 对象本身,当 span 的边界与函数的边界不匹配时,就可以使用该对象来解决问题:

    with langfuse.start_as_current_observation(name="search_docs", as_type="span") as span:
        context = retrieve_context(collection, QUESTION)
        span.update(input={"query": QUESTION}, output={"context": context})
    

    这样做的好处是精度更高,因为可以在精确的时刻设置输入和输出,且时间范围可以覆盖函数的一部分。但缺点是缺乏分离性,因为追踪代码会混入业务逻辑中。当以整个函数作为单元时,@observe可以达成相同效果且没有上述弊端。

    模式3:标准 OpenTelemetry

    Phoenix 没有自己的装饰器,直接使用 OpenTelemetry 追踪器,而 OpenInference 工具会自动封装客户端,因此生成调用仍然处于嵌套状态:

    with tracer.start_as_current_span("search_docs") as span:
        context = retrieve_context(collection, QUESTION)
        span.set_attribute("output.value", context)
    

    其优势在于这里的所有内容都不属于 Phoenix。这些都是标准的 OTel 数据段,因此一旦端点发生变化,相同的采集工具即可将数据传输到任何支持 OTLP 的后端。这一说法确实经过了实际验证,因为虽然理论上具备可移植性,但实际操作中往往会出现问题。AcruxCore 也支持 OTLP,因此同样的数据处理流程也被应用于它:使用完全相同的 with tracer.start_as_current_span(...) 代码,相同的采集工具,只是端点不同而已。

    tracer_provider = otel.register(service_name="rag-agent-observability")
    OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
    

    每个平台都提供一个 register() 辅助函数,可将 TracerProvider、BatchSpanProcessor 以及 OTLP 导出器整合到一次调用中。这两个辅助函数并非必需,手动构建的流程也能正常工作。生成的 span 会以正确的嵌套结构呈现。OpenInference 的 RETRIEVER 属性被归类为检索类型,而自动捕获的模型 span 则会列出该模型以及 1,341 个 token。

    因此,这种方案具备良好的可移植性,这也是使用纯 OTel 编写监控代码的最佳理由:span 只需生成一次,由哪个后端接收则成为配置问题。相关成本在前期就已确定——一个 register() 调用、一个监控工具以及一个追踪对象都会在第一个 span 之前被创建,这比使用装饰器处理相同两个 span 所需的架构更为复杂。

    模式 4:带外数据导入

    除了OTLP之外,AcruxCore还提供了一种其他工具所没有的第五种传输方式。这种方式并非监控流程,而是由代码直接描述时间片段并将其发送出去。追踪ID将所有内容关联起来:将其传递给网关后,服务器端生成的模型时间片段就会加入同一条追踪中。这两种方式互为补充。

    reported = await hub.traces.ingest({
        "name": "rag-agent",
        "spans": [{
            "spanId": "search_docs", "name": "search_docs", "kind": "retrieval",
            "input": {"query": QUESTION}, "output": {"context": context},
            "startTime": started, "endTime": _now(),
        }],
    }, wait=False)                                # buffered, returns at once
    result = await hub.gateway.chat(
        "openai/gpt-4o-mini", messages, provider=PROVIDER,
        trace={"trace_id": reported.trace_id},   # generation lands in the same trace
    )
    

    与构建追踪器相比,它需要的调用次数更少,且时间戳由调用方设置,这非常适合那些已经完成且没有活跃调用栈可关联的任务。

    但问题在于wait=False参数。在默认模式下,ingest()会等待POST请求并返回服务器生成的追踪ID,因此在模型调用开始之前就需要进行一次网络往返。而当使用wait=False时,相关数据会存入本地SDK缓冲区,ID也在客户端生成。调用端不会收到任何确认信息,发送失败时只会被记录为警告并被丢弃,这与OTel导出器的处理方式类似。这实际上是在“失败时反应慢且声音大”与“失败时反应快且安静”之间做选择,只要这是经过深思熟虑的决定,两种方式都是可行的。

    模式5:位于请求路径中

    其中两种工具直接集成在调用过程中,因此模型相关操作无需额外成本。Helicone在这方面表现得最为明显:只需将base_url修改为指向该工具,即可无需任何SDK代码即可完成内容补全。AcruxCore网关也采用相同机制。通过gateway.chat()路由的调用,其llm相关操作由服务器处理,因此客户端无需任何额外配置。

    这种方案既有优势也有局限,两者皆然。请求路径中的任何内容都只会看到发往模型提供方的HTTP流量。而Chroma查询不会产生此类流量,因此无论使用哪种工具,它都保持不可见状态。

    两者的区别在于所伴随的组件不同。AcruxCore 网关会接收一个追踪 ID,这使得通过其他路径、OTel 或 ingest() 记录的 span 可以作为父 span,从而使服务器生成的模型 span 也归入同一个追踪中。Helicone 只提供代理功能,没有用于封装任意函数的 SDK。这就留下了两种选择:要么放弃从追踪中获取数据,要么编造虚假的完成信息来传递数据,而后者会让遥测数据充满虚假内容。因此,准确的表述应比“代理无法追踪数据获取过程”更为精确:代理本身确实无法做到这一点,而其重要性则取决于是否存在第二种路径。

    有一个与设计无关的部署注意事项:本次测试中自托管的Helicone构建在访问其手动日志端点时返回了500错误,原因是缺少S3_REGION变量,这导致甚至模型相关部分的记录都无法进行。这是该特定版本的缺陷,而非整体方法的问题。

    何为跨度

    两个跨度可以证明系统功能正常,但无法区分半年后的重排器与防护机制。因此,允许使用的值是从每个已安装的SDK中获取的,而非其文档中的规定。

    MLflow 在这一类别中遥遥领先。RERANKER、GUARDRAIL、EVALUATOR、PARSER 和 MEMORY 均为内置功能,而 Opik 只有四种类型,其他功能都被归类为元数据。AcruxCore 的七种类型可处理 RAG,但细节程度较低。Phoenix 的类型列表源自 OpenInference 标准,因此所有遵循该标准的工具都具备这些类型,这并非 Phoenix 的独有功能。

    这是最容易从营销页面上被误解的一行内容。只需几行代码即可核对这些列表与实际安装版本是否一致:

    from mlflow.entities import SpanType
    from openinference.semconv.trace import OpenInferenceSpanKindValues
    
    print([v for k, v in vars(SpanType).items() if isinstance(v, str) and not k.startswith("_")])
    print([e.value for e in OpenInferenceSpanKindValues])
    

    Langfuse采取了额外的处理措施。其位于langfuse._client.span中的ObservationTypeLiteral是由嵌套的Literal类型构成的,因此typing.get_args返回的是内部别名而非字符串,结果必须通过递归方式展平。这也就解释了为何其文档中的统计数字常常与SDK显示的不一致。

    选择方案

    • MLflow适合那些希望立即获得精确语义、稍后再进行评估的团队。它的词汇量最为丰富,不过整个技术栈的体积比纯追踪工具要大。
    • Opik能在同一天下午就搭建出正确的结构,流程最为简单,仅有四种跨度类型。
    • Langfuse既提供了装饰器,也通过强类型机制提供了手动处理方式,但相应的文档却有两套。
  • Phoenix适用于可能需要更换后端或已经在使用OTel的团队。它不依赖任何供应商的API,在首次生成数据跨度之前需要最多的配置工作。
  • AcruxCore适用于需要自动生成模型数据跨度,但检索过程仍需记录相关数据的情况。它拥有三种可组合的路径,其词汇量小于MLflow、Phoenix或Langfuse,且采用缓冲式数据导入方式,出错时不会产生任何提示。
  • Helicone适用于真正仅包含模型调用的流程。此类流程无需编写任何代码,也没有非模型步骤需要被记录。
  • 这里真正的教训在于无需排名。探讨某个工具是否能够进行追溯实际上包含了五个独立的问题,而每个问题的答案都是一种设计决策,都会带来相应的代价:一行代码、一个with块、初始化工作、一次网络跳转或某个盲点。请选择代价最小的方案,并在最终确定之前仔细检查相关词汇表,因为问题往往会在那时才显现出来。

    相关阅读

  • 使用 LiveKit、Qdrant 和 MLflow 追踪语音 RAG 智能体 —— 利用 LiveKit、LlamaIndex 和 Qdrant 构建语音转语音的抵押贷款助手,通过 MLflow 中的 OpenTelemetry 追踪功能展示每一轮数据检索及大语言模型处理步骤。
  • LLMOps 实践应用:人工智能的评估、可观测性、成本管控与治理 —— 确保大语言模型应用在生产环境中保持稳定运行的关键要素:提示词版本管理、评估工具集、追踪功能、幻觉检测指标、成本监控以及治理机制。