一个 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 里捞出失败样本的输入输出,人工看几十条。指标只能告诉你哪里坏了,样本才能告诉你怎么坏的。
整套流程能不能跑通,取决于前面那些埋点是否完整:版本信息、固定回归集、可回溯的输入输出记录。缺任何一环,排障就退化为猜测。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.