本文介绍了如何使用OpenTelemetry Python SDK在FastAPI应用程序中构建端到端的、以代码为基础的LLM可观测性系统。

我们不会依赖特定于供应商的代理程序或不透明的SDK,而是会手动设计追踪数据、时间跨度以及语义属性,以便完整记录由LLM支持的请求在整个生命周期中的运行过程。

目录

引言

大型语言模型正在迅速成为现代软件系统的核心组成部分。那些曾经依赖确定性API的应用程序,如今都已开始融入由LLM提供的功能,比如对话助手、文档摘要生成、智能搜索以及检索增强型内容生成技术。

虽然这些功能带来了全新的用户体验,但它们同时也增加了运营复杂性,而传统的监控方法根本无法应对这类问题。

与传统软件服务不同,LLM系统本质上是概率性的。对于同一个请求,由于提示语的结构、模型配置、检索环境以及温度、top-p等采样参数的不同,可能会产生略有不同的响应结果。

此外,大语言模型在工作过程中会带来一些全新的运营方面的考量因素,比如令牌消耗量、提示语生成所需的时间、推理成本、上下文窗口的范围以及响应质量等。

这些因素意味着:从基础设施的角度来看,某个请求在技术上可能是成功的,但其结果却可能是错误的、基于幻觉产生的,或者质量低下。

传统的可观测性工具通常关注延迟、错误率以及吞吐量等基础设施层面的指标。虽然这些指标仍然很重要,但它们不足以帮助我们了解大语言模型在实际应用中的表现。

工程师们还需要清楚:究竟生成了什么样的提示语,检索到了哪些文档,消耗了多少令牌,使用了哪种模型配置,以及最终得到的响应是如何被评估的。如果没有这些信息,调试大语言模型的行为将会变得极其困难,运营成本也可能会迅速失控。

正是在这种情况下,大语言模型的可观测性就显得尤为重要了。针对大语言模型的可观测性工具并不仅仅局限于对基础设施的监控,它能够覆盖整个由人工智能驱动的请求处理流程——从用户输入和上下文信息的检索,到提示语的生成、模型推理、后处理,以及最终结果的质量评估。

如果实施得当,可观测性功能能够帮助团队弄清楚:为什么模型会生成特定的响应,哪些检索结果影响了最终的输出结果,一个请求在令牌消耗方面需要付出多少成本,在请求处理的哪个环节出现了延迟,以及响应是否通过了基本的质量检测。

本文介绍了如何使用OpenTelemetry在FastAPI应用中实现端到端的大语言模型可观测性。我们没有依赖任何专有的监控工具或晦涩难懂的供应商SDK,而是采取了以代码为核心的方法来进行相关功能的开发。通过明确设计追踪数据、时间跨度以及语义属性,我们可以精确地控制大语言模型交互过程的观察与分析方式。

在整个指导过程中,我们将详细讲解如何为一种基于检索结果进行内容生成的流程构建可观测性系统。在这个流程中,请求处理的每一个阶段都会被表示为一个时间跨度。我们还会探讨如何合理设置时间跨度的边界,安全地捕获提示语和模型相关的元数据,记录令牌的消耗情况与成本信息,并将评估结果直接附加到追踪数据中。

本文还说明了这种可观测性数据是如何被导出到任何支持OpenTelemetry的后端系统的——比如Jaeger、Grafana Tempo,或是专为大语言模型设计的平台如Phoenix。

读完本指南后,你将能够掌握以下技能:

  • 如何组织追踪数据,使得每个用户请求都能对应到一个完整的端到端的大语言模型交互过程
  • 如何设计反映大语言模型处理流程中各个逻辑阶段的层次结构
  • 如何安全地捕获提示语的元数据、模型配置信息以及令牌消耗情况
  • 如何将评估结果和质量指标附加到追踪数据中,以便进行更深入的分析
  • 如何在不改变现有代码架构的情况下,将可观测性数据导出到不同的后端系统

最重要的是,本文的目的并不仅仅是展示如何为应用程序添加遥测功能,而是旨在说明在构建由大型语言模型驱动的系统时,应该如何考虑可观测性这一概念。

当将大型语言模型的运行机制视为分布式系统中的核心组件时,跟踪数据便成为用于调试、优化、成本管理以及持续改进模型行为的强大工具。

先决条件与技术背景

在开始阅读本指南之前,您应该已经熟悉Python编程语言、基本的Web API概念以及微服务架构。以下是本文中会用到的一些关键工具和概念。

FastAPI(Web框架)

FastAPI被作为该应用程序的主要Web框架使用。它是一个现代的Python框架,专门用于利用标准的Python类型提示来构建高性能的API。FastAPI简化了请求验证、序列化以及API文档的生成过程,同时保持了轻量级和高效的特点。

大型语言模型(LLMs)

大型语言模型是该示例系统的核心组成部分。这类模型是通过分析海量的文本数据训练而成的,它们能够以类似于人类交流的方式生成或处理语言信息。在实际应用环境中,大型语言模型常被用于实现对话界面、内容摘要生成以及问答等功能。

可观测性(概念)

可观测性是贯穿本文所有内容的核心概念。从高层次来看,可观测性指的是通过分析系统在执行过程中产生的数据来理解其内部运行机制的能力。与仅仅判断系统是否处于“正常运行”状态相比,可观测性能够帮助我们深入探究为什么某个请求会以某种方式响应、延迟现象是如何产生的,以及不同组件之间是如何相互作用的。

OpenTelemetry(监控工具标准)

OpenTelemetry是实现应用程序可观测性的关键技术手段。它是一个开放且与特定供应商无关的标准,用于生成诸如跟踪数据、指标信息及日志记录之类的遥测数据。通过为大型语言模型的关键流程添加监控组件,我们可以清楚地了解请求在系统中的流转过程、每一步所花费的时间,以及哪些上下文因素影响了最终的结果。OpenTelemetry为以一致且可移植的方式收集这些信息提供了基础,使其不受任何特定监控后端环境的影响。

为什么大型语言模型的可观测性具有特殊性

传统的可观测性模型假设系统的行为是确定性的:相同的输入总会产生相同的输出。然而,大型语言模型打破了这一假设。由于提示模板的变化、数据检索方式的不同、采样参数的调整、模型版本的升级,甚至上下文窗口范围的改变,同一个请求可能会产生不同的结果。

因此,开发团队需要深入了解模型接收了哪些信息、是如何被配置的、检索到了什么数据、整个处理过程花费了多少时间,以及这些因素与特定的用户请求之间存在着怎样的关联。仅仅依靠日志记录是远远不够的,而指标数据也缺乏必要的维度信息。分布式跟踪技术才是实现大型语言模型可观测性的关键所在。

参考架构:可追踪的RAG请求流程

基于FastAPI的典型RAG服务会遵循以下处理流程:

基于FastAPI的RAG服务

每一个处理步骤都是可被观察到的,但前提是我们必须刻意添加相应的监控机制。我们的目标是确保每个用户请求都能产生一条唯一的追踪记录,而那些子操作则代表LLM模型中的具体逻辑步骤。

参考架构详解

客户端向/chat端点发送请求

整个流程始于客户端向/chat端点发送请求。该请求通常会包含用户的查询内容,以及应用程序所需的会话或对话上下文信息。

我们有意将客户端接口设计得简单明了:这样既能确保后端接收到结构固定的输入数据,也能防止特定于应用程序的逻辑影响到下游的LLM处理过程。

从监控的角度来看,这个请求标志着一条端到端追踪记录的开始,这样一来,后续的所有操作都能被追溯到用户最初的请求行为上。

FastAPI对输入数据进行验证并完成用户身份认证

当请求到达服务端后,FastAPI会首先进行数据结构验证和用户身份认证。验证机制能确保只有格式正确的输入才能继续进入后续处理流程;而身份认证则能保证只有经过授权的用户才能执行那些耗时较高的LLM操作。

将这一环节放在处理流程的早期,可以有效减少不必要的计算开销,并保护系统免受滥用。同时,这也提高了追踪记录的质量,因为所有被记录下来的请求都代表了合法的执行路径,而非格式错误或被拒绝的请求。

检索模块从向量数据库中获取相关数据

验证通过后,系统会从向量数据库中检索与用户请求相关的文档。这一环节是“检索增强生成”技术的基础所在。通过将LLM模型的推理过程与外部知识相结合,系统能够提高答案的准确性,减少错误结果的出现。

将数据检索与内容生成分开处理,可以让开发团队独立调整相似性阈值、嵌入模型参数以及选择显示的前k条结果;同时,这也便于判断不良响应是由于数据检索失败还是模型本身存在问题造成的。

使用检索到的文档来构建最终提示语

在获得了相关文档后,系统会将这些内容整合成最终要发送给LLM模型的提示语。这个步骤会将用户的查询请求、检索到的上下文信息、系统的指令以及格式规范结合在一起,形成一条结构清晰的提示语。

将提示语的生成过程明确划分为一个独立的阶段,有利于进行版本控制、实验测试以及监控分析。此外,这样也能在调用模型之前及时发现诸如上下文窗口溢出或提示语长度过长等问题。

最终调用LLM模型

LLM API调用是整个处理流程中成本最高且结果具有不确定性的操作,因此它只在所有准备工作完成后才会被执行。在这个阶段,模型会接收一个已经完全构建好的提示语,并根据自身的配置参数生成相应的响应。

这一环节是控制延迟、成本以及可靠性的关键所在,诸如重试机制、超时设置以及电路断路器等功能都对此起到了重要作用。从可观测性的角度来看,这个处理过程也成为追踪令牌使用情况、计算成本归属以及进行提示语级别调试的依据。

响应会经过后处理后再被返回

在LLM返回响应后,系统会先对其进行后处理,然后再将结果发送给客户端。这些后处理操作可能包括格式化、过滤、验证或对输出内容进行补充优化等。后处理机制能够有效防止出现格式错误或质量低下的响应,同时确保响应结果符合应用的要求。此外,在请求完成之前,这种后处理过程还为添加各种评估指标提供了便利,比如响应长度、相关性得分或截断指示等信息。

为什么这种设计比更简单的方案更优

这种架构刻意避免了将不同的功能耦合在一起。验证、信息检索、提示语生成、模型执行以及响应处理都是相互独立的步骤。这样的设计使得系统更易于测试、观察和升级维护。当出现问题时,工程师能够清楚地判断问题发生的位置原因,而不会将LLM视为一个黑箱。

与那种“直接将用户输入发送给LLM”的单一架构相比,这种设计具有更高的正确性、更低的成本以及更强的稳定性。此外,它也天然适合与分布式追踪系统结合使用,因为系统的各个组成部分都能清晰地对应到具有明确语义意义的追踪轨迹中。随着系统规模的扩大,还可以轻松添加缓存机制、备用模型或策略执行功能,而不会影响整个流程的稳定运行。

最重要的是,这种架构将LLM视为一个更大系统中的普通组件,而非整个系统的核心。这种思维方式对于构建可靠的production级应用来说至关重要。

最适合这种架构的LLM模型

虽然这种架构对具体使用的模型类型没有严格要求,但某些模型特性在结合信息检索功能的使用场景中表现得尤为出色。

那些具备较强指令执行能力和推理能力的模型通常表现最佳,尤其是当提示语中包含了从检索到的文档中提取的结构化信息时。对于那些对准确性和推理深度要求较高的场景来说,GPT-4这类通用模型也能发挥很好的作用。

对于那些对延迟或成本有较高要求的应用场景而言,一些经过专门优化、针对指令执行能力进行训练的小型模型,如果能与高质量的信息检索功能相结合使用,也会取得良好的效果。像基于LLaMA或Mistral的开源模型,部署在私有推理端点之后时,同样非常适合这种架构。

关键并不在于模型本身,而在于它的使用方式。那些能够根据所提供的上下文来生成可靠的响应、遵守系统指令,并且能够在不同的提示条件下产生稳定输出的模型,才能最完美地融入这种设计架构中。由于信息检索与提示生成属于明确的、可独立操作的环节,因此可以随意更换或比较这些模型,而不会影响到整个系统的结构。

OpenTelemetry入门指南(仅涉及与大语言模型相关的概念)

OpenTelemetry定义了三种核心类型的遥测数据:轨迹、指标和日志。对于大语言模型系统而言,轨迹是最为重要的。要想让这些数据发挥实际作用,你需要了解以下几个基本概念:

  • 轨迹代表一个完整的端到端请求过程。

  • 片段是轨迹中某个具有时间戳的操作记录。

  • 属性是附加在片段上的键值型元数据。

  • 事件

    是带有时间戳的注释信息。

  • 上下文传播确保子片段能够正确地关联到对应的父片段上。

由于FastAPI具有异步处理的特点,因此正确的上下文传播机制至关重要。不过,只要片段被正确创建,OpenTelemetry的Python SDK就能处理好这一问题。

掌握了这些概念之后,下一步就是将OpenTelemetry集成到应用程序中。首先需要在FastAPI中配置OpenTelemetry SDK:定义一个TracerProvider,关联一个Resource(服务名称及环境信息),配置输出工具(如Jaeger、Tempo等),并启用FastAPI的自动监控功能。

设计适用于大语言模型的片段结构

片段的分类与命名规则

清晰的片段层次结构非常重要。在本指南中,一个http.request片段通常会被视为根片段,而它之下会包含诸如rag.retrievalrag.prompt.buildllm.callllm.postprocess等子片段;当然,也可以根据需要添加llm.eval片段。这些片段每一项都代表一个逻辑上的工作单元,而非具体的实现细节。

片段的边界设定

正确确定片段的边界与为片段起合适的名称同样重要。应避免出现极端情况:比如将整个大语言模型处理流程封装在一个巨大的片段中,或者为每一个处理步骤都创建一个单独的片段,又或者把所有数据都直接记录到日志文件里。

相反,应该设计出几个粒度适中的片段,每个片段都能准确反映请求过程中的某个关键环节;同时要为这些片段添加恰当的属性,并利用事件来标记其中的重要节点,而不是将整个流程拆分成过细的片段。

如何对大语言模型调用过程进行监控

在对大语言模型的调用过程进行监控时,应将其视为轨迹中最关键的片段。无论你是使用OpenAI、Anthropic还是其他服务提供商,都应在发送API请求之前立即开始记录这个片段的运行过程,并且要等到完整的响应结果返回之后才结束该片段的记录。

在这个片段中,需要记录重试次数、超时情况以及出现的错误信息,这样这个片段就能成为分析延迟问题、确定成本分摊方式以及排查提示生成故障的核心依据。

对于那些以流式形式返回响应的数据,可以为每个数据块生成相应的事件来跟踪处理进度;但除非确实需要非常精细的时间记录功能,否则不必为每一个小部分数据都创建单独的子片段。

FastAPI示例:端到端的大语言模型调用过程监控(完整案例及说明)

from fastapi import FastAPI, Request
from opentelemetry import trace
from opentelemetry.trace import Tracer
from typing import List
import asyncio
import hashlib

# 从 OpenTelemetry 中获取一个追踪器实例。
# 使用这个追踪器创建的所有跨度都会属于同一个分布式追踪系统,并会被发送到配置好的后端。
tracer: Tracer = trace.get_tracer(__name__)

# 初始化 FastAPI 应用程序。
app = FastAPI()

# 被观察端点使用的辅助函数
async def retrieve_documents(query: str) -> List[str]:
"""
模拟文档检索过程(例如,向量搜索或知识库查询)。
这个函数代表了 RAG 流程中的检索阶段。
在真实的系统中,这可能会查询向量数据库或搜索索引。
"""
await asyncio.sleep(0.05) # 模拟 I/O 延迟
return [
"FastAPI 可以实现高性能的异步 API。",
"OpenTelemetry 提供了与供应商无关的可观测性功能。",
"大语言模型的可观测性需要追踪提示和令牌信息。",
]

def buildprompt(query: str, documents: List[str]) -> str:
"""
根据检索到的文档和用户输入的查询语句生成最终的提示语。
将提示语的生成过程单独处理,这样在需要时可以独立地对其进行观察或修改(例如,为了测量提示语生成所需的延迟)。
"""
context = "\n".join(documents)
return f"""
上下文:
{context}

问题:
{query}
"""

class LLMResponse:
"""
大语言模型响应的最小抽象层。
这种设计使得示例代码更加独立完整,同时仍允许我们添加令牌使用情况等元数据以便进行可观测性分析。
"""

def __init__(self, text: str, prompt_tokens: int, completion_tokens: int):
self.text = text
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens

@property
def total_tokens(self) -> int:
return self.prompt_tokens + self.completion_tokens

async def call_llm(prompt: str) -> LLMResponse:
"""
模拟调用大语言模型 API 的过程。
在实际应用中,这会调用 OpenAI、Anthropic 或其他提供商的服务。
这里设置的人工延迟用于模拟模型的响应时间。
"""
await asyncio.sleep(0.2) # 模拟推理所需的时间
response_text = "FastAPI 和 OpenTelemetry 可以实现端到端的大语言模型可观测性分析。"
# 为了演示目的,这里对令牌数量进行了近似计算。
prompt_tokens = len(prompt.split())
completion_tokens = len(response_text.split())
return LLMResponse(response_text, prompt_tokens, completion_tokens)

def summarize_response(response: LLMResponse) -> str:
"""
示例中的后处理步骤。
将后处理过程单独分离出来,这样就可以避免将任何额外的延迟或错误错误地归因于大语言模型本身。
"""

return response.text

# 可被观察的 FastAPI 端点
@app.post("/query")
async def rag_query(request: Request, query: str):
"""
处理一个符合 RAG 模式的请求,并为该请求创建相应的 OpenTelemetry 跨度。
这个端点展示了如何为每个请求创建一个追踪记录,同时还会为检索、调用大语言模型以及后处理等步骤分别创建子跨度。
"""

# 为 HTTP 请求创建一个顶级跨度。
# 即使 FastAPI 的自动监控功能已启用,明确定义这个跨度也能帮助我们添加与特定领域相关的元数据。
with tracer.start_as_current_span("http.request") as http_span:
http-span.set_attribute("http.method", "POST")
http_span.set_attribute("http.route", "/query")

# 检索阶段
# 这个跨度用于隔离检索步骤,这样就可以独立于大语言模型的表现来调试相关性相关的问题。
with tracer.start_as_current_span("rag.retrieval") as retrieval_span:
retrieval-span.set_attribute("rag.top_k", 5)
retrieval_span.setattribute("rag.similarity_threshold", 0.8)
documents = await retrieve_documents(query)

# 记录返回了多少份文档。
# 这是一个在诊断最终响应中是否存在幻觉现象或上下文缺失问题时非常重要的信息。
retrieval-span.set_attribute(
"ragdocuments_returned",
len(documents),
)

# 调用大语言模型阶段
# 这个跨度包含了实际调用大语言模型的过程,是分析延迟、成本以及提示语相关数据的主要依据。
with tracer.start_as_current_span("llm.call") as llm_span:
llm_span.set_attribute("llm-provider", "example")
llm_span.setattribute("llm.model", "example-llm")
llm_span.setAttribute("llm.temperature", 0.7)
llm_span.set_attribute("llm.prompt_template_id", "rag_v1")

# 根据检索到的文档内容生成最终的提示语。
# 为了方便后续分析,没有将原始提示语作为跨度属性存储下来。
prompt = buildprompt(query, documents)

# 提示语的元数据
prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
llm_span.set_attribute("llm.prompt_hash", prompt_hash)
llm-span.setattribute("llm.prompt_length", len(prompt))

response = await call_llm(prompt)

# 将响应内容进行哈希处理,而不是直接存储原始文本。
# 这样就可以在不同的追踪记录之间进行关联分析,而不会暴露具体的内容。
response_hash = hashlib.sha256(
response.text.encode()
).hexdigest()
llm_span.set_attribute("llm.response_hash", response_hash)

# 记录令牌的使用情况,以便后续进行成本核算和容量规划。
llm-span.setattribute("llm.usage.prompt_tokens", response.prompt_tokens)
llm_span.setAttribute("llm_usage.completion_tokens", responseocompletion_tokens)
llm_span.set_attribute("llm USAGE.total_tokens", response.total_tokens)

# 示例中的令牌单价
estimated_cost = response.total_tokens * 0.000002
llm-span.setattribute("llm.cost_estimated_usd", estimated_cost)

# 后处理阶段
# 大语言模型响应出来之后,任何后续的处理步骤都放在这里进行。
# 这样就可以确保不会高估推理所花费的时间。
with tracer.start_as_current_span("llm.postprocess") as post_span:
summary = summarize_response(response)
post-span.set_attribute(
"llm.summary_length",
len-summary),
)

# 将最终的结果返回给客户端。
# 上述所有的跨度都属于同一个分布式追踪系统。
return {"summary": summary}
在研究完整的代码示例之前,了解这些监控机制是如何与本文前面提到的可观测性原则相对应的,会很有帮助。
这个示例的目的不仅仅是为了展示如何创建跟踪记录,而是要说明如何将一个用户请求表示为一个结构化的追踪数据,其中包含了关于大语言模型处理流程中每个阶段的有意义元数据。
从宏观角度来看,这段代码遵循了三个关键的设计理念:

  1. 每个用户请求对应一条追踪记录

  2. 每个逻辑上的大语言模型工作流程阶段都对应一个跟踪记录

  3. 为这些跟踪记录添加语义属性,以便于调试、成本追踪及分析

这些概念都与前面讨论过的可观测性实践相呼应。

**顶级请求跟踪记录**
FastAPI端点在开始处理请求时,会首先创建一个名为`http.request`的顶级跟踪记录。这个跟踪记录代表了整个请求的处理流程,同时也是整个追踪数据结构的根节点。
with tracer.start_as_current_span("http.request") as http_span:
虽然FastAPI可以通过OpenTelemetry的自动监控机制自动生成HTTP相关的跟踪记录,但明确地创建这个跟踪记录可以让应用程序添加一些特定于该应用的元数据,比如路由名称或用户标识符。
在这里,我们会为这个跟踪记录设置HTTP方法及路由信息:
http_span.set_attribute("http.method", "POST")
http_span.set_attribute("http.route", "/query")

这样,在分析生产环境中的请求流量时,就可以根据这些元数据轻松地过滤出相关的追踪记录。

**检索阶段跟踪记录**
下一个跟踪记录用于记录RAG处理流程中的检索环节:
with tracer.start_as_current_span("rag.retrieval") as retrievalSpan:
这个跟踪记录将向量搜索或知识检索步骤与其他处理环节区分开来。如果用户反馈得到的答案不相关,工程师就可以通过检查这个跟踪记录来判断问题是由于检索结果不佳引起的,还是模型本身的行为导致的。
在这里,我们会为这个跟踪记录添加一些语义属性:

  • rag.top_k – 被请求的文档数量
  • rag.similarity_threshold – 用于过滤结果的相似度阈值
  • ragdocuments_returned – 实际检索到的文档数量

这些属性与文章前面讨论过的RAG相关的可观测性指标是一致的。

**大语言模型调用跟踪记录**
在整条追踪数据链中,最重要的跟踪记录就是`llm.call`这个记录,它涵盖了模型实际被调用的整个过程。
with tracer.start_as_current_span("llm.call") as llmSpan:
这个跟踪记录记录了与大语言模型请求相关的延迟、配置信息以及令牌使用情况。在生产环境中,这个记录是分析模型行为及成本消耗的关键依据。

在此记录的关键属性包括:

  • llm.provider – 模型提供者(如OpenAI、Anthropic等)

  • llm.model – 具体的模型版本

  • llm_temperature – 用于控制响应随机性的采样参数

  • llm.prompt_template_id – 所使用的提示模板的标识符

这些属性使得人们能够将模型配置的变化与后续产生的质量或成本变化联系起来。

提示处理与隐私保护

示例中并未直接在跟踪数据中存储完整的提示内容或响应文本,而是采用了更为安全的做法:对敏感数据进行哈希处理。

response_hash = hashlib.sha256(response.text.encode()).hexdigest()

生成的哈希值会被作为属性保存在跟踪数据中:

llm_span.set_attribute("llm.response_hash", response_hash)

这种处理方式能够让工程师们将不同跟踪数据中的重复响应关联起来,同时避免在监控系统中暴露可能包含敏感信息的内容。

令牌使用情况追踪

llm.call跟踪数据也会记录令牌的使用情况:

llm_span.set_attribute(
    "llm_usage.total_tokens",
    response.total_tokens
)

在跟踪层面记录令牌的使用情况对于监控成本和效率至关重要,因为大多数大型语言模型的计费方式都是根据令牌消耗量来计算的。

后处理步骤

最后,示例中还包含了一个llm.postprocess跟踪数据:

with tracer.start_as_current_span("llm.postprocess") as post_span:

这个跟踪数据代表了模型生成响应后所进行的任何处理步骤。将后处理环节与模型的调用过程分开,可以确保诸如格式化、过滤或验证等操作所导致的延迟不会被错误地归因于模型本身。

这里还会记录一些属性,例如响应内容的长度:

post_span.set_attribute("llm.summary_length", len(summary))

这些信息在诊断诸如输出内容异常短或被截断等问题时非常有用。

跟踪数据如何构成完整的追踪记录

当请求完成时,所有相关的跟踪数据都会属于同一个分布式追踪记录中:

http.request
 ├── rag.retrieval
 ├── llm.call
 └── llm.postprocess

这种层次结构反映了带有检索功能的大型语言模型系统的逻辑工作流程。由于每个跟踪数据都包含了结构化的元数据,工程师们可以快速回答诸如以下这些问题:

  • 延迟是由于检索操作还是模型推理造成的?

  • 有多少份文档对生成提示内容产生了影响?

  • 是哪种模型配置产生了当前的响应结果?

  • 总共消耗了多少个令牌?

  • 响应内容是否经过了后处理或被截断了?

这种结构化的跟踪设计正是将可观测性从单纯的监控功能转变为对大语言模型系统而言实用的调试与优化工具。

语义属性:提升大语言模型可观测性的最佳实践

我们的目标并非记录所有可能的细节,而是仅保留那些稳定且信息量丰富的属性,这些属性才能帮助我们在生产环境中有效地进行调试、成本控制及质量分析。如果属性设计不合理,就会导致跟踪数据混乱、存在隐私风险,同时也会使得监控仪表盘变得难以理解。

提示信息、响应结果以及模型元数据

存储原始的提示信息往往既不安全又成本高昂,因此最好记录一些结构化且信息量最小的元数据。在实际操作中,这意味着需要使用llm.prompt_template_id来标识提示信息的模板;使用llm.prompt_hash来存储最终提示信息的哈希值(从而避免存储原始文本);同时还需要记录如llm.prompt_length这样的长度信息,以便了解提示信息由多少个字符组成。

此外,还应当始终记录一些关键的推理参数:llm-provider(例如“openai”或“anthropic”)、llm.model(例如“gpt-4.1”)、llm_temperaturellm.top_p(采样参数)、llm.max_tokens(允许使用的最大字符数),以及llm.stream(用于指示是否启用了流式处理功能)。在记录这些参数时,必须确保遵守所在组织的隐私与合规要求。


with tracer.start_as_current_span("llm.call") as llm_span:
            llm_span.set_attribute("llm-provider", "example")
            llm_span.set_attribute("llm.model", "example-llm")
            llm_span.set_attribute("llm_temperature", 0.7)
            llm_span.set_attribute("llm.top_p", 0.9)
            llm_span.set_attribute("llm.max_tokens", 512)
            llm_span.set_attribute("llm.stream", False)
            llm_span.set_attribute("llm.prompt_template_id", "rag_v1")

            # 使用获取到的上下文信息构建最终的提示信息。
            # 故意不将原始提示信息作为属性进行存储。
            prompt = build_prompt(query, documents)

            # 提示信息的元数据
            prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
            llm_span.set_attribute("llm.prompt_hash", prompt_hash)
            llm_span.set_attribute("llm.prompt_length", len(prompt))

令牌使用情况与成本(为何这在实际应用中如此重要)

令牌的使用量是大语言模型系统中最常见的盲点之一。许多团队会监测延迟和错误率,但往往直到账单金额激增时才会发现成本失控的问题。由于令牌消耗量会受到提示信息结构、获取到的上下文信息以及模型配置的影响,因此必须在跟踪层面上明确记录这些数据。

最重要的做法是在大语言模型完成推理操作后,立即记录其令牌使用情况。这样就能确保所记录的数据反映了整个请求过程,而不仅仅是部分输出结果或流式处理过程中的数据。

至少需要获取以下属性:llm_usage.prompt_tokensllm.usage.completion_tokens以及llmusage.total_tokens

def __init__(self, text: str, prompt_tokens: int, completion_tokens: int):
        self.text = text
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_tokens

    @property
    def total_tokens(self) -> int:
        return self.prompt_tokens + self.completion_tokens

async def call_llm(prompt: str) -> LLMResponse:
    """
    模拟对LLM API的调用。
    在实际的实现中,这会涉及到与OpenAI、Anthropic或其他提供商的交互。这里设置的人工延迟用于模拟模型处理请求所需的时间。
    """
    await asyncio.sleep(0.2)  # 模拟模型推理时间
    response_text = "FastAPI和OpenTelemetry使得端到端的LLM性能监控成为可能。"
    # 为了演示目的,这里对token的数量进行了估算。
    prompt_tokens = len(prompt.split())
    completion_tokens = len(response_text.split())
    return LLMResponse(response_text, prompt_tokens, completion_tokens)

这些数值有助于你区分那些因为提示信息过长而导致处理成本增加的请求,以及那些因为模型生成的结果过于冗长而产生高成本的请求。

*如果可能的话,还需要附加一个预估的成本值:*llm.cost_estimated_usd

    # 示例:每个token的费用
    estimated_cost = response.total_tokens * 0.000002
    llm_span.set_attribute("llm.cost_estimated_usd", estimated_cost)

这个数值通常是通过将token的数量乘以模型公布的定价信息来得出的。尽管这种估算可能并不精确,但它仍然能够帮助你进行有效的分析。例如,你可以借此找出哪些接口、提示模板或用户操作流程导致了最高的成本支出,而无需依赖那些粗略的、针对整个账户的计费报表。

一旦这些数据被添加到了相应的跟踪信息中,下一步就是将它们与输出质量联系起来,而不仅仅是系统的运行状态。

跟踪记录中的评估机制

本节介绍了一种可以在本指南提供的基础监控功能基础上进一步使用的扩展方法。这种方法属于可选配置,在示例代码中并未实现,但它展示了如何直接在跟踪数据中添加与质量相关的信息。

性能监控的意义不仅仅在于系统是否能够正常运行,更在于模型是否能够生成有用的结果。通过在跟踪记录中加入评估机制,你可以直接将这些与质量相关的信息添加到那些用于检测延迟和成本的数据中。

最简单的方法就是在进行实时评估时同步记录结果,并将这些结果作为跟踪数据中的属性保存下来。例如,对于简单的布尔判断,可以使用llm.eval.passed;对于需要数值评分的判断,可以使用llm.eval.relevance_score;而对于某些特殊情况,还可以使用llm.eval.hallucination Detectedllm.eval.refusal_detected等属性。这些属性会随跟踪数据一起被保存下来,因此你可以在性能监控的后端系统中像处理其他数据一样对它们进行筛选和汇总分析。

为了提高准确性,你可以将基于模型的评估作为独立步骤来执行。在这种模式下,评估用的大型语言模型会异步地处理原始的提示和响应结果,其运行过程会被记录在一个子跨度中(例如 `llm.eval`),这个子跨度会与主 `llm.call` 跨度使用相同的跟踪 ID。之后,你可以为这个评估跨度添加相关性、准确性或毒性等评分指标。

由于这个评估跨度使用了相同的跟踪 ID,因此你可以通过分析这些评分数据来了解提示内容的变化或检索机制的改进情况。

导出和可视化跟踪数据(以及这些功能与第三方工具的结合方式)

这种以代码为核心的可观测性设计并不依赖于特定的第三方工具。只要使用 OpenTelemetry 进行数据采集,这些跟踪数据就可以被导出到不同的后端系统中,而无需对原有的采集机制进行任何修改。

像 Jaeger 和 Grafana Tempo 这样的通用追踪系统可以帮助工程师诊断延迟问题、错误现象以及请求在检索、提示生成和模型调用等环节中的处理流程,从而了解系统的实际运行情况。而专门针对大型语言模型的平台(如 Arize Phoenix)则会利用相同的数据,但会添加一些与模型特性相关的分析功能,比如提示词聚类、token 分析以及质量评估等。

由于整个数据采集机制仍然基于 OpenTelemetry 的标准进行设计,因此你在使用第三方工具的仪表盘时,依然能够完全控制各项属性和跟踪数据的结构;同时,随着需求的变化,你也可以轻松更换后端系统,而无需修改应用程序的代码。

最佳实践与避免误区

要实现高效的大型语言模型可观测性,就需要遵循一些规范的实践原则。对于处理大量请求的系统来说,应该对跟踪数据进行抽样处理以减少开销;同时,提示词或响应结果应默认被进行哈希处理,以此降低存储成本并降低隐私风险。所有跟踪数据都应被视为生产环境中的重要数据,因此必须制定适当的访问控制和数据保留策略。

常见的错误包括仅依赖第三方工具提供的跟踪数据、在没有关联这些数据的情况下记录提示词信息,或者忽视评估结果所带来的反馈信息。这些问题会导致可观测性分析出现碎片化,从而掩盖质量下降的趋势,尤其是当可观测性分析只关注某些特定组件而忽略了整个应用程序的运行环境时。

扩展系统功能

一旦跟踪数据变得可靠,它们就能支持更多高级功能。例如,可以通过这些数据计算出 p95 延迟时间;利用跟踪 ID 将不同系统的日志关联起来;还可以利用历史跟踪数据来进行离线评估或提示词测试。

通过遵循 OpenTelemetry 的规范,可观测性相关的技术栈也能与不断发展的大型语言模型语义标准保持同步,从而使系统具备更好的灵活性和前瞻性。

总结

实现端到端的大型语言模型可观测性,并不是通过安装额外的组件来实现的。而是需要通过合理的跨度设计、有意义的语义属性定义,以及在必要时添加轻量级的评估机制来实现的。

将大型语言模型的调用过程视为分布式跟踪系统中的核心操作,可以帮助你更快地定位问题、更好地控制成本、更安全地部署系统,并显著提升系统的质量。虽然像 Jaeger、Tempo 和 Phoenix 这样的后端工具是可以互换使用的,但数据采集的具体策略却不能随意更改。

一条设计精良的追踪路径,是任何一款用于生产环境的大型语言模型系统中最为宝贵的资源。

Comments are closed.