大模型应用链路追踪怎么配,能看清每次调用

文章导读
要让每次大模型调用都能被看清,关键不在先接哪个观测平台,而在于先把“一次请求”的边界定义清楚,再让同一个 trace id 从入口一路传到模型调用、工具调用和返回环节。只要链路上留下入口、检索、提示词组装、模型调用、工具调用这几段的耗时与状态,哪怕暂时只记录到结构化日志,也已经能回答“这次调用慢在哪、错在哪、重试了几次”。先做贯通,再谈采样、后端和看板。
📋 目录
  1. Ⅰ 先确认当前场景和目标
  2. Ⅱ 按这个顺序处理
  3. Ⅲ 改完后怎么验证
  4. Ⅳ 容易忽略的边界
A A

要让每次大模型调用都能被看清,关键不在先接哪个观测平台,而在于先把“一次请求”的边界定义清楚,再让同一个 trace id 从入口一路传到模型调用、工具调用和返回环节。只要链路上留下入口、检索、提示词组装、模型调用、工具调用这几段的耗时与状态,哪怕暂时只记录到结构化日志,也已经能回答“这次调用慢在哪、错在哪、重试了几次”。先做贯通,再谈采样、后端和看板。

通用做法是:入口生成或透传 trace id,在模型调用、检索、工具调用三处各建一个 span,记录模型名、输入输出长度、耗时、重试次数、错误类型,默认不记录提示词原文。适用范围是自建编排、直接调 API 或多模型路由的应用;如果调用链路跨越异步队列或流式响应,需要单独确认上下文传递。验证时用一个带唯一标记的请求走全链路,确认同一 trace id 能串起全部 span,再并发请求确认不串号。

先确认当前场景和目标

链路追踪在当前场景下才成立:你的应用自己发起模型调用,并且至少存在两段以上可以区分的环节,比如“检索知识库、拼提示词、调模型、解析结果、调工具”。如果只是单次脚本调用,一条请求日志就够,上 span 体系反而是负担。所以动手前先确认几件事:模型调用是否集中在一个客户端封装或网关出口(决定打点位置)、是否有流式返回(决定 span 何时结束)、是否存在异步任务或队列消费(决定上下文能不能传过去)、是否允许多记录提示词内容(决定脱敏策略)。这些条件里任何一项不清楚,先按最保守的方式只记元数据,后面再补。

还要确认团队拿这条链路干什么。如果目标是排查慢请求,重点记各段耗时和排队时间;如果目标是排查错误,重点是错误码、异常类型和重试次数;如果目标是看用量,重点是模型名和输入输出长度。目标不同,span 的粒度就不同,一次就把全部字段都堆上去,通常会让日志量变很难控。

按这个顺序处理

先定粒度,再定标识,再做传递,最后接入存储。粒度上建议按“一层一个 span”:入口请求一个,检索一个,模型调用一个,每个工具调用一个;重试建议记为模型调用 span 上的事件或属性,而不是每次重试都新开一个顶层 trace。标识上可以用通用 traceparent 头,也可以用自建的 request_id 加 span 栈,自建的好处是实现简单,代价是跨服务时需要在网关处统一改名。

大模型应用链路追踪怎么配,能看清每次调用

下面是入口侧的通用骨架,作用是生成或继承 trace 上下文,并放进当前执行上下文。函数名和存储方式需要按你使用的语言与框架替换,这里只表达结构:

# 通用骨架,非特定框架实现
import uuid, contextvars

_trace = contextvars.ContextVar("trace", default=None)

def begin_trace(headers):
    # 上游已带 trace 就继承,否则新建
    trace_id = headers.get("x-trace-id") or uuid.uuid4().hex
    span_id  = uuid.uuid4().hex[:16]
    _trace.set({"trace_id": trace_id, "span_id": span_id})
    return trace_id

def current_span(parent_name):
    ctx = _trace.get() or {"trace_id": uuid.uuid4().hex, "span_id": None}
    return {"trace_id": ctx["trace_id"], "parent_span_id": ctx["span_id"], "name": parent_name}

模型调用处的包装是收益最大的一段。它的作用是把模型名、输入输出长度、耗时、重试与错误固定下来,替换点是你的客户端调用和上报函数:

def call_llm(prompt, model, retry=0):
    span = current_span("llm.chat")
    start = time.time()
    try:
        resp = client.chat(prompt=prompt, model=model)   # 替换为你的调用
        emit(span, status="ok", model=model,
             in_chars=len(prompt), out_chars=len(resp.text),
             retry=retry, latency_ms=int((time.time()-start)*1000))
        return resp
    except Exception as e:
        emit(span, status="error", model=model, retry=retry,
             error_type=type(e).__name__, latency_ms=int((time.time()-start)*1000))
        raise

字段不必多,够用即可。可先按下表落地,后续按排查需求加字段:

大模型应用链路追踪怎么配,能看清每次调用
字段用途是否必填
trace_id / span_id / parent_span_id串起整条链路必填
span.name区分入口、检索、模型、工具必填
latency_ms定位慢在哪一段必填
status / error_type区分正常、超时、限流、异常必填
model / provider按模型维度聚合建议
in_chars / out_chars估算上下文长度与截断风险建议
retry判断重试是否放大耗时建议
prompt 原文排错用,涉及合规默认不记

传递这一环最容易被跳过。同步调用里上下文一般能自然带下去,异步任务、线程池、后台队列消费则需要显式把 trace_id 作为参数或消息头写进去,消费端再恢复。流式返回的场景,建议在收到首个数据块和结束信号时各记一次时间,span 在流结束或超时后关闭,不要用整个连接的生命周期代替模型耗时。

改完后怎么验证

不要只看有没有数据上报,要验证能不能用这条链路回答问题。可以按下面的动作逐项走一遍:

  1. 构造一个带唯一标记的正常请求,在日志或追踪后端按 trace id 检索,预期能看到入口、检索、模型调用、工具调用按顺序出现,父子关系正确,总耗时约等于各段之和加上排队时间。
  2. 同时发若干并发请求,预期每个请求的 trace id 不同,各 span 不交叉挂载;如果出现交叉,通常是上下文用了全局变量而不是请求级上下文。
  3. 人为触发一次超时或错误,预期模型调用 span 标记为错误,且带上错误类型与重试次数,链路不在此处断开。
  4. 测试流式返回,预期首个数据块时间与整体结束时间分别可查,二者差值符合流式特征。
  5. 如果已开采样,取一条被采样丢弃的请求,确认仍能通过入口日志定位到它,避免出现“有请求无链路”的空档。

预期信号可以概括为三句话:同一 trace id 能串起全部环节;慢请求能找到具体是哪一段耗时;错误请求能看到错误类型而不是只有一句失败。这三条满足,追踪配置就算可用,字段美化可以后面再做。

大模型应用链路追踪怎么配,能看清每次调用

容易忽略的边界

第一是上下文丢失。多线程、多进程、异步任务切换时,请求级上下文不一定自动继承,需要在任务提交处显式传 trace_id,否则链路会在异步边界处断成两截。第二是采样。开启头部采样后,被丢弃的请求在追踪系统里查不到,排障时需要配合入口日志或错误必采策略,这部分要按团队排障习惯决定,不能默认全采也不能默认全丢。

第三是内容合规。提示词和模型输出里可能带用户信息,默认只记长度和哈希是更稳妥的选择,需要留存原文时先确认数据留存范围和访问权限。第四是版本与协议兼容。追踪 SDK 与后端之间的字段命名、协议版本存在差异,升级时容易出现字段丢失或上报失败,建议在预发环境先验证再切换,并在上报异常时保留本地打印兜底。

如果上线后发现上报量过大或阻塞主流程,回退顺序建议是:先把上报改为异步或非阻塞,再去掉内容类字段只留元数据,最后降低采样率;仍然有问题就退回“结构化日志加 request_id”的轻量方案,保证每次调用仍可定位,等条件成熟再补 span 体系。