7AA7 博客

AI 应用的可观测性:如何追踪一次模型调用

白露 UID:116
2026-09-27 11:25
62 0 0

一个 AI 应用上线之后,最常被问到的两个问题是”这次调用为什么这么慢”和”效果怎么突然变差了”。传统后端服务在这两件事上有成熟答案,但模型调用链路把大部分既有假设都推翻了。

传统 APM 缺的到底是什么

APM 的核心抽象是:一个请求穿过若干服务,每个服务有耗时、状态码、错误率。这套模型在 LLM 调用上有四处失效。

成功不等于正确。这是最根本的一条。一次模型调用返回 HTTP 200,输出流畅、格式合规,但内容是编造的。APM 里的”成功率”衡量的是传输和可用性,不是正确性,在语义层完全失效。

成本不在 CPU 时间上。传统服务成本跟机器时长大体线性,LLM 调用则按输入 token、输出 token 分别计价,单价不同,输入里往往还有大段固定上下文(system prompt、检索结果、历史对话)。于是成本优化的主要手段变成改 prompt 和改上下文,跟传统性能优化路径完全不同。

延迟的构成变了。流式输出下用户感知的是首 token 延迟(TTFT),请求总耗时则取决于输出长度。一个”耗时”数字已不足以描述体验,要拆成 TTFT、每 token 间隔、总时长三段。

调用图是动态的。传统服务的依赖拓扑是静态的;Agent 场景下调用哪些工具、几次、什么顺序由模型每次决定,拓扑必须逐请求记录。

还差一个语义层。输入输出都是自然语言,没法像结构化字段那样聚合。要变成可聚合信号,需要额外处理:embedding 聚类看输入分布漂移,评分模型给输出质量打分,schema 校验看格式合规率。

一次调用要记哪些字段

把一次调用拆成若干层次,每一层有明确的用途。

层次 字段 用途
身份 trace_id、span_id、会话 id、业务线、功能点 串联调用链,定位归属
版本 代码版本、prompt id + 版本 hash、模型标识与快照、检索索引版本、工具定义版本 变更归因
输入 渲染后的完整 prompt(或摘要 + hash)、模板变量、命中文档 id 复现问题
输出 原始输出、解析后的结构化结果、解析是否失败 区分内容问题与格式问题
计量 输入/输出/缓存命中 token 数、TTFT、总耗时、重试次数 性能与成本分析
过程 工具调用链:名称、参数、返回摘要、耗时、成败 排查工具侧故障
反馈 点赞点踩、人工标注、下游任务是否成功 质量指标的真实标签

其中”版本”这一层最容易被省略,而它恰恰是排障时最关键的。没有版本信息,指标变化只能跟时间对齐,无法跟变更对齐。

用 OpenTelemetry 的语义约定来承载这些字段是自然的选择,模型调用作为一个 span,计量信息作为 attribute:

from opentelemetry import trace

tracer = trace.get_tracer("llm-app")

def traced_completion(prompt_id: str, prompt_hash: str, model: str, **kwargs):
    with tracer.start_as_current_span("llm.completion") as span:
        span.set_attribute("llm.prompt.id", prompt_id)
        span.set_attribute("llm.prompt.hash", prompt_hash)
        span.set_attribute("llm.model.name", model)
        span.set_attribute("app.business_line", kwargs.pop("biz_line", "unknown"))

        resp = client.complete(**kwargs)          # 实际调用

        usage = resp.usage
        span.set_attribute("llm.usage.prompt_tokens", usage.prompt_tokens)
        span.set_attribute("llm.usage.completion_tokens", usage.completion_tokens)
        span.set_attribute("llm.usage.cached_tokens", usage.cached_tokens)
        span.set_attribute("llm.latency.ttft_ms", resp.ttft_ms)

        # 输入输出按采样与脱敏策略决定是否记录
        maybe_record_io(span, kwargs.get("messages"), resp.text)
        return resp

工具调用要作为子 span 挂在父 span 下,形成一棵树。这样一次会话里”模型思考 → 调用搜索 → 拿到结果 → 再思考 → 调用数据库 → 生成答案”的链路才能完整还原,而不是散成一堆平铺的日志。

prompt 版本必须当代码管

prompt 比代码更脆弱:没有类型检查,没有编译错误,改一个标点的效果差异可能比改一行代码还大。但很多团队把它存在一个配置文件里,或者直接写在代码字符串里随意改动。

可行的管理方式:

  • 模板集中存放,每条有唯一 id 和递增版本号,内容变更即发新版本,不原地覆盖。
  • 变更走 PR 和 review。自然语言 diff 可读性差,配套一份”新旧版本在回归集上的输出对比”会有效得多。
  • 模板变量要有 schema。少传、多传、类型不对,都应在渲染阶段报错,而不是静默产出残缺 prompt。
  • 支持灰度。按用户或流量比例同时运行两个版本,指标分开统计。
  • 运行时回传 hash。渲染后计算模板内容摘要写进 span,与注册中心比对。这一步能挡住”绕过版本系统直接改线上文件”的情况。

配套还需要一份固定回归集:几十到几百条有明确期望的输入。每次 prompt 或模型变更先在回归集上跑一遍。没有它,”效果变了”就只是主观印象。

采样与脱敏的边界

全量记录所有输入输出,成本和风险都不划算。分层处理更实际:

  • 结构化指标全量记录。token 数、延迟、成本、版本号这些字段体积小、无隐私风险,全量采集,聚合分析才有统计意义。
  • 完整输入输出按低采样率记录,例如百分之一到百分之五,用于分布观察和案例抽查。
  • 条件触发强制全量:调用失败、用户点踩、耗时超过阈值、命中敏感词、处于灰度中的新版本。这些样本数量少但信息量大。

脱敏要在落盘之前做,而不是先全存下来再清理。清理脚本总有漏网的时候,而且日志系统本身通常不在合规边界内。

import re

PATTERNS = [
    (re.compile(r"1[3-9]d{9}"), "PHONE"),
    (re.compile(r"b[w.+-]+@[w-]+.[w.]+b"), "EMAIL"),
    (re.compile(r"bd{17}[dXx]b"), "ID_CARD"),
    (re.compile(r"bd{16,19}b"), "BANK_CARD"),
]

def redact(text: str) -> str:
    """替换成带编号的占位符,保留文本结构,便于事后对照排查"""
    for pattern, name in PATTERNS:
        counter = {"n": 0}
        def _sub(_m):
            counter["n"] += 1
            return f"<{name}_{counter['n']}>"
        text = pattern.sub(_sub, text)
    return text

保留编号而不是一律替换成 ***,是为了排查时能看出”两处提到的是同一个手机号”,这类结构信息丢掉之后很多问题就查不动了。确实需要还原原始值的场景,映射表要单独加密存放并做权限控制,不要跟日志放在同一个系统里。

脱敏必须在算文本类指标之前完成,否则明文会流过指标计算链路,等于脱敏白做。

成本归因怎么做

难点有两个:一次用户请求背后可能是多轮模型调用加多次工具调用,还可能有重试;一个 API key 往往被多个业务线共用,账单层面看不出归属。

解法是在 trace 上挂归因维度(业务线、功能点、终端用户标识、模型、prompt 版本),聚合时按这些字段分组。有了这些就能回答”哪个功能单次成本最高””哪个用户群贡献了主要消耗””这次 prompt 改动让成本涨了多少”这类问题。

几个容易算错的地方:

  • 缓存命中的 token 要单独计价,单价通常与正常输入不同,混在一起会让成本模型失真。
  • 重试要计入。失败重试消耗了真实 token,如果只统计成功请求,成本会被系统性低估。
  • 异步和批处理任务难以归属到具体会话,在提交任务时就携带上下文标签兜底,不要等到结算时再猜。
  • 只盯总成本意义有限,更有用的是单位业务价值的成本,比如每解决一个工单消耗多少 token。
维度 聚合口径 典型用途
业务线 按请求标签求和 预算分配与结算
功能点 按 span 名称分组 找出成本高收益低的功能
prompt 版本 按 hash 分组 评估变更的成本影响
输入长度分桶 按 token 区间分组 定位长上下文开销

排障:效果突然变差怎么定位

一个能复现的排查顺序,比临场发挥可靠得多。

确认变化是真的。看时间序列和置信区间,样本量够不够,波动是否在历史正常范围内。不少”效果变差”其实是告警阈值设得太敏感。

分桶定位范围。把整体指标按业务线、功能点、用户群、输入长度、语言切开,看是哪一段在掉。全局均匀下跌和某个子集暴跌,指向的原因完全不同。

对齐变更时间线。把指标拐点跟模型版本切换、prompt 发布、检索索引重建、依赖工具上线这些事件对上。灰度发布是渐进的,曲线会呈现斜坡而不是台阶,容易被误判为缓慢劣化。

分层验证,从最可能的环节开始:

  • 数据层:检索到的文档 id 分布有没有变?把当时的 query 重跑一遍检索,看 top-k 里是否还包含正确内容。召回率下跌是最常见的根因之一。
  • prompt 层:用固定回归集分别跑新旧版本,对比输出差异。
  • 模型层:如果用的是别名,确认背后指向的快照有没有变,同样用回归集对比。
  • 解析层:结构化解析失败率有没有上升。很多时候内容没变,是解析不出来了,却被当成效果问题。

回到具体样本。从 span 里捞出失败样本的输入输出,人工看几十条。指标只能告诉你哪里坏了,样本才能告诉你怎么坏的。

整套流程能不能跑通,取决于前面那些埋点是否完整:版本信息、固定回归集、可回溯的输入输出记录。缺任何一环,排障就退化为猜测。

1.9k浏览量
62赞
0评论
0参与用户
13 分钟阅读时间

评论(0)

还没有评论,来抢沙发。