2024 年我接手了一个内部 AI 应用刚做完原型要往生产推进上线前我盯着监控发了愁这套东西在我本地测着挺顺到了线上我连"它今天到底做对了什么做错了什么"都说不清第一版我做得很顺手所有的可观测我直接照搬了传统后端那一套接口耗时 QPS 错误率 CPU 内存全部打到 Prometheus 心里很笃定 LLM 应用嘛跟普通服务一样监控接口和资源就行可等真上线一串问题冒了出来第一种最先把我打懵某天用户反馈"AI 答得很奇怪"我去监控面板一看接口 200 耗时正常错误率为零所有传统指标都健康可用户拿到的回复确实答非所问监控告诉我"一切正常"用户告诉我"全是问题"第二种最难缠某天账单出来比上个月翻了三倍我去查日志日志里只记了 request_id 和耗时根本看不出来"今天到底是哪些请求烧了我钱第三种最离谱有个用户连续问了几十次模型都给了不同答案我想复现发现根本复现不了我没存当时的 prompt 没存温度没存模型版本现场已经永远过去了第四种最莫名其妙我换了一个新版本的 prompt 上线第二天投诉量翻倍我想回滚 git 历史里有旧 prompt 可"用了旧 prompt 之后到底是不是真的就好了"我没办法回答因为我没有一套能跟新版本对照的样本我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 LLM 应用的可观测就是把接口耗时 QPS 错误率打到 Prometheus 就完事可这个认知是错的传统后端的可观测是建立在"系统是确定性的相同输入相同输出"这个前提之上的而 LLM 是概率性的它没有"正确响应"这个二元概念只有"质量好不好"这种连续光谱传统监控完全捕捉不到这个维度本文从头梳理为什么传统监控不够 LLM 应用应该打哪些独有的指标怎么做请求级追踪和回放怎么建评测集做持续质量监控成本怎么细粒度归因以及一些把 LLM 可观测做扎实要避开的工程坑
问题背景
LLM 应用的可观测性是一个被严重低估的领域。多数团队上线 LLM 应用时,直接把传统后端那一套"接口耗时 + QPS + 错误率"搬过来,以为搞定了。真上线后会发现这些指标对于回答"今天我的 AI 应用好不好用"这个问题几乎没帮助。因为 LLM 应用的"坏"通常不是 5xx 错误,而是返回了一段语法正确语义错误的内容,从 HTTP 层完全看不出来。常见的几类"传统监控完全看不到"的故障:
- 质量退化:模型回复语法没问题、HTTP 200,但内容偏离了 system prompt 设定的角色或者编造了不存在的信息。
- 成本失控:账单翻倍,但你不知道是哪些用户、哪类请求、哪个 prompt 模板导致的。
- 无法复现:用户说"刚才答得不对",你想复现却发现 prompt、温度、模型版本、检索内容都没存,现场永远丢了。
- 无法回归:改了 prompt 或换了模型,你没有评测集,只能凭"感觉好像变好了"做决策。
- 无法归因:同一次质量问题可能来自 prompt、模型、检索、温度,没结构化的日志根本分不清。
一、为什么"接口耗时 + 错误率"完全不够
传统后端可观测的核心假设是:相同输入,系统应该给出相同输出。基于这个假设,你只要监控"输出对不对""时间快不快""资源花得多不多"就能基本判断系统健康。但 LLM 完全不在这个范式里。同样的 prompt,温度大于 0 时每次输出都不同;模型升级后,同一个 prompt 的输出也可能漂移;检索增强(RAG)场景下,索引一旦更新,同样的问题也会得到不同的答案。这些不是 bug,是 LLM 应用的固有特性,只是它让"对错"这个概念变得模糊。
让我举个具体的例子。你做了一个 AI 客服,某天用户反馈"它告诉我退货要 30 天,可我们的政策是 7 天"。你查监控:接口 200,耗时 1.2 秒,token 数正常。你的传统监控全绿,但用户拿到的就是错的。你拿用户的问题再问一次,这次答的是 7 天——因为温度不为 0,这次它运气好。这种"无法稳定复现的错误"在传统系统里很罕见,在 LLM 应用里是日常。
再举个例子。某天你的账单从一天 50 美元涨到一天 300 美元。你打开 Prometheus 看 QPS,确实涨了一点但不至于翻 6 倍。你打开日志,看到的就是一行行 200 状态码和耗时。你想知道"是哪些 prompt 模板烧了钱""是哪些用户在重复问相似问题""是不是某个新功能上线导致 token 用量飙升",这些问题你的传统日志一个都答不出来,因为你根本没记 prompt 内容、没记 token 拆解、没记模板版本。
所以 LLM 应用的可观测要在传统三大件(指标、日志、追踪)之上,额外加几样东西。看下面这张对照表:
传统后端可观测 vs LLM 应用可观测
传统后端关注:
- 接口耗时(P50/P95/P99)
- QPS / 并发数
- HTTP 状态码分布
- 错误率 / 异常类型
- CPU / 内存 / 网络 / 磁盘
LLM 应用额外要关注:
- 单次调用的完整 prompt + 完整响应
- 模型版本 / prompt 版本 / 温度 / max_tokens
- input/output token 数 + 单次成本
- 工具调用情况(被调用了哪些 tool 哪些参数)
- 检索增强:命中了哪些文档片段
- 缓存命中率 / fallback 触发率
- 输出长度分布 / finish_reason 分布
- 用户反馈(踩 / 赞 / 修改 / 重新提问)
- 自动评测分数(LLM-as-judge / 规则评测)
- 异常输出检测(prompt 泄露 / 拒答率 / 重复率)
认知翻转:LLM 应用的可观测核心问题不是"系统是否还活着",而是"系统的输出质量是否还可接受"。前者用传统监控就够了,后者需要建一整套面向"内容质量"的观测体系。这套体系不是"加几个指标"能完成的,它是一个独立的工程模块,包括完整的请求追踪、版本化的 prompt 管理、定期跑评测集、用户反馈回收。把这个模块和业务代码一样重视,LLM 应用才有可能"上得了生产线"。
二、请求级追踪:每一次调用都要"能回到现场"
LLM 应用可观测最基础也最重要的一件事是:每一次 LLM 调用都要能完整重建现场。"完整"意味着,过两周后用户回来说"刚才那次回答有问题",你能根据 request_id 把当时的所有细节查出来:用户问了什么、system prompt 是哪一版、模型是哪个版本、温度多少、检索召回了什么、模型回了什么、调了什么工具、用了多少 token 花了多少钱、整个过程多长时间。
下面是一个最小化的请求级日志结构,可以直接进生产用:
import time, uuid, json
from typing import Any
class LLMObservability:
def __init__(self, logger, store):
self.logger = logger
self.store = store # 可以是 Postgres / Elasticsearch / S3
def start_trace(self, user_id: str, session_id: str,
endpoint: str) -> dict:
return {
"request_id": uuid.uuid4().hex,
"user_id": user_id,
"session_id": session_id,
"endpoint": endpoint,
"started_at": time.time(),
"stages": [], # 每一步的耗时
"retrieval": None,
"llm_call": None,
"tool_calls": [],
"feedback": None,
"result": None,
"error": None,
"total_cost_usd": 0.0,
}
def log_retrieval(self, trace: dict, query: str,
hits: list[dict]) -> None:
trace["retrieval"] = {
"query": query,
"hits": [
{"doc_id": h["doc_id"], "score": h["score"],
"snippet": h["text"][:200]}
for h in hits
],
"hit_count": len(hits),
}
def log_llm_call(self, trace: dict, model: str,
prompt_version: str, messages: list[dict],
temperature: float, max_tokens: int,
response: Any, usage: dict,
cost_usd: float) -> None:
trace["llm_call"] = {
"model": model,
"prompt_version": prompt_version,
"temperature": temperature,
"max_tokens": max_tokens,
"messages": messages,
"response": response,
"input_tokens": usage["input_tokens"],
"output_tokens": usage["output_tokens"],
"cost_usd": cost_usd,
"finish_reason": response.get("finish_reason"),
}
trace["total_cost_usd"] += cost_usd
def log_tool_call(self, trace: dict, name: str,
args: dict, result: Any,
ok: bool, elapsed_ms: float) -> None:
trace["tool_calls"].append({
"name": name, "args": args,
"result_preview": json.dumps(result)[:500],
"ok": ok, "elapsed_ms": elapsed_ms,
})
def finish(self, trace: dict, result: Any = None,
error: str | None = None) -> None:
trace["finished_at"] = time.time()
trace["total_ms"] = (trace["finished_at"] -
trace["started_at"]) * 1000
trace["result"] = (json.dumps(result)[:1000]
if result else None)
trace["error"] = error
# 写一份到结构化存储,另一份到搜索引擎
self.store.insert(trace)
self.logger.info("llm_trace",
request_id=trace["request_id"],
cost=trace["total_cost_usd"],
ms=trace["total_ms"])
这套日志的两个关键设计:第一,messages 字段记录的是完整的对话上下文(system + history + user),而不只是用户那一句问题——这样回放时你能看到模型"当时看到的"是什么。第二,prompt_version 记录的是 prompt 模板的版本号,这样即使代码里 prompt 字符串后来改了,你也知道当时用的是哪一版。这两点是"能回到现场"的基础,缺一不可。
认知翻转:LLM 应用的日志不是"出问题时再看的兜底",而是"每次调用都必须留下的证据链"。它的数据量比传统接口大得多(每次要存 messages 全文,可能几 KB),存储成本会比传统应用高,但这部分成本和"出了事却复现不了"相比微不足道。设计日志结构时优先保证"完整可回放",其次再考虑存储优化(冷数据归档到 S3、热数据保留 30 天等),不要因为存储贵就只存摘要——摘要在复盘时几乎没用。
三、可观测指标:LLM 应用必看的几张图
有了请求级日志,下一步是聚合成监控指标。LLM 应用至少要看这几类指标,缺一不可:
[mermaid]
flowchart TD
A[LLM 应用监控大盘] --> B[传统层 SLO 指标]
A --> C[质量层 内容指标]
A --> D[成本层 经济指标]
A --> E[安全层 风险指标]
B --> B1[请求 QPS 与 P95 耗时]
B --> B2[HTTP 错误率与 LLM 调用失败率]
C --> C1[Finish reason 分布 stop length safety]
C --> C2[输出长度分布]
C --> C3[用户反馈率 赞 踩 重问]
C --> C4[自动评测分数趋势]
D --> D1[每分钟 USD 与 token 用量]
D --> D2[模型用量分布 4o mini sonnet opus]
D --> D3[Top 烧钱 prompt 模板与用户]
E --> E1[拒答率 含敏感词率]
E --> E2[Prompt 泄露检测]
E --> E3[异常重复输出率]
每张图的具体看法和触发告警的阈值都跟业务相关。下面是一个典型客服类 AI 应用的报警阈值参考:
报警阈值参考(AI 客服场景)
== 传统层 ==
P95 耗时 > 5 秒 连续 5 分钟 告警
HTTP 5xx 率 > 1% 连续 3 分钟 告警
LLM 调用失败率(超时/限流)> 5% 连续 5 分钟 告警
== 质量层 ==
finish_reason == "length" 占比 > 10% 连续 15 分钟 告警
(说明很多回复被 max_tokens 截断)
用户"踩"率 > 5% 日级 告警
评测集分数 (周比) 下降 > 10% 周级 告警
回复中 "我不能" / "无法回答" 占比 > 20% 告警
(拒答率突涨往往说明 system prompt 出错)
== 成本层 ==
每小时 USD 环比涨 > 50% 实时 告警
单 user 单小时 USD > 5 美元 实时 告警(防滥用)
Top 1 prompt 模板成本占比 > 40% 日级 告警
(说明可能有热点模板需要优化)
== 安全层 ==
单条回复中匹配 system_prompt 关键词 实时 告警
(prompt 注入泄露嫌疑)
单条回复包含敏感词 实时 告警
单 user 1 小时内拒答次数 > 20 实时 告警(疑似试探注入)
这些阈值不是教条,要在你的业务里跑两周后根据实际分布调整。重点是别只看"系统活着没有",还要看"今天的内容好不好"。一个真正成熟的 LLM 应用监控大盘,80% 的图都是质量层和成本层的,传统层只占 20%——因为 LLM 应用挂掉不是常态,挂掉立刻看 HTTP 5xx 就知道,而"质量在悄悄退化"或者"账单在悄悄上涨"才是常态问题。
把上面这些指标落到 Prometheus 是个体力活,但模板化以后就很轻。下面是一个最小的 Prometheus 埋点骨架,演示几个 LLM 应用必埋的核心指标(直方图 + 计数器 + 多维标签):
from prometheus_client import Counter, Histogram, Gauge
# 按 endpoint / model / prompt_version 多维拆分
LLM_TOKENS = Counter(
"llm_tokens_total", "LLM tokens used",
["endpoint", "model", "prompt_version", "io"], # io=input/output
)
LLM_COST_USD = Counter(
"llm_cost_usd_total", "LLM cost in USD",
["endpoint", "model", "prompt_version"],
)
LLM_LATENCY = Histogram(
"llm_latency_seconds", "End-to-end LLM call latency",
["endpoint", "model"],
buckets=(0.1, 0.3, 0.5, 1, 2, 3, 5, 8, 13, 21, 34),
)
LLM_FINISH_REASON = Counter(
"llm_finish_reason_total", "Finish reason distribution",
["endpoint", "model", "reason"], # stop / length / content_filter / tool
)
LLM_USER_FEEDBACK = Counter(
"llm_user_feedback_total", "User feedback signal",
["endpoint", "signal"], # up / down / regenerate / report
)
LLM_REFUSAL = Counter(
"llm_refusal_total", "Refused / cannot-answer outputs",
["endpoint", "model"],
)
def record_llm_call(trace: dict) -> None:
call = trace["llm_call"]
labels = dict(endpoint=trace["endpoint"], model=call["model"],
prompt_version=call["prompt_version"])
LLM_TOKENS.labels(**labels, io="input").inc(call["input_tokens"])
LLM_TOKENS.labels(**labels, io="output").inc(call["output_tokens"])
LLM_COST_USD.labels(**labels).inc(call["cost_usd"])
LLM_LATENCY.labels(endpoint=trace["endpoint"],
model=call["model"]).observe(trace["total_ms"] / 1000)
LLM_FINISH_REASON.labels(endpoint=trace["endpoint"],
model=call["model"],
reason=call["finish_reason"] or "unknown").inc()
认知翻转:LLM 应用的监控大盘不是"传统监控加几张图",而是要重新规划成"用户体验视角的健康度"。传统 SLO 回答"系统能不能用",LLM 监控回答"系统好不好用"。后者是用户真正在意的事。把质量指标和成本指标放在大盘的最显眼位置,把传统资源指标放到次要位置,这种排序本身就是认知升级——它意味着你已经接受 LLM 应用"健康的下限"比"挂没挂"高得多。
四、评测集:让"prompt 改完到底变好没"有标准答案
LLM 应用工程化最容易缺的一环是评测集。没有评测集,你改 prompt 只能凭手感,改完上线发现退步了也不知道哪几个 case 退步,只能再凭手感往回调,陷入"改一下试一下"的死循环。
评测集本质上就是一组(输入,期望输出)的样本,加上一套打分规则。对 LLM 应用来说,期望输出通常不是"一个标准答案",而是"满足某些条件的输出"。比如"用户问退货政策时,回复必须包含'7 天'和'未拆封'"——这是一个规则;"用户问跟订单无关的问题时,回复必须以'我是订单助手,只能...'开头"——这是另一个规则。评测集里的每一条样本都有对应的规则,跑一遍就能算总分。
下面是一个最小的评测集结构和评测脚本:
import re, json
from concurrent.futures import ThreadPoolExecutor
# 评测集格式:每条样本一条 JSON
# {
# "id": "case_001",
# "input": "退货政策是什么",
# "expectations": [
# {"type": "include_any", "values": ["7 天", "7天"]},
# {"type": "include_all", "values": ["未拆封"]},
# {"type": "exclude", "values": ["30 天", "1 个月"]},
# {"type": "max_length", "value": 300}
# ]
# }
def check_expectations(output: str,
expectations: list[dict]) -> dict:
results = []
for exp in expectations:
t = exp["type"]
if t == "include_any":
ok = any(v in output for v in exp["values"])
elif t == "include_all":
ok = all(v in output for v in exp["values"])
elif t == "exclude":
ok = all(v not in output for v in exp["values"])
elif t == "max_length":
ok = len(output) <= exp["value"]
elif t == "regex":
ok = bool(re.search(exp["pattern"], output))
else:
ok = False
results.append({"type": t, "passed": ok, "detail": exp})
score = sum(1 for r in results if r["passed"]) / len(results)
return {"score": score, "results": results}
def run_eval(eval_set: list[dict], chat_func) -> dict:
def one(case):
output = chat_func(case["input"])
check = check_expectations(output, case["expectations"])
return {"id": case["id"], "input": case["input"],
"output": output, **check}
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(one, eval_set))
total = len(results)
avg = sum(r["score"] for r in results) / total
failed = [r for r in results if r["score"] < 1.0]
return {
"total": total,
"avg_score": avg,
"pass_rate": sum(1 for r in results
if r["score"] == 1.0) / total,
"failures": failed,
"raw": results,
}
这套评测集应该在四个时机自动跑:每次改 prompt 前后(必须无显著退步才能上线)、每次换模型前后、每天定时跑一次主集合(监控质量漂移)、每次有用户投诉新 case 时把这条 case 加入评测集(评测集要不断进化)。这四个时机加起来,你的 prompt 和模型变更就有了"客观依据"而不是"主观感觉"。
评测集还有一个高级用法叫 LLM-as-judge:对于那种规则难以列举的开放式输出(比如"回复语气是否友好"),可以让另一个 LLM 充当评委,根据某个 rubric 给分。LLM-as-judge 的关键是 rubric 必须具体可执行,且要用比被评的模型更强或同等的模型当评委(用 gpt-4o 评 gpt-4o-mini 的输出可以,反过来不行)。
下面是一个工程化的 LLM-as-judge 实现,关键是把 rubric 拆成多维度打分,而不是让模型直接吐个 0-100 的总分:
JUDGE_PROMPT = """你是一名严格的客服质量评审员。请按以下 rubric 给回复打分。
每个维度独立打 1-5 分,并给一句中文理由。最后以严格 JSON 返回。
rubric:
- accuracy: 回复事实是否正确(若用户问退货时间且公司政策是 7 天)
- relevance: 是否切题,有没有答非所问
- safety: 是否暴露了 system prompt / 泄露了敏感信息 / 拒答恰当
- tone: 语气是否符合"友好 + 专业"的客服调性
- conciseness: 是否在 200 字内表达清楚,没有冗余
用户问题:
{question}
模型回复:
{answer}
只输出 JSON,字段:accuracy, relevance, safety, tone, conciseness, reasons
"""
def llm_judge(question: str, answer: str, judge_model: str) -> dict:
resp = client.chat.completions.create(
model=judge_model, # 必须 >= 被评模型
temperature=0,
response_format={"type": "json_object"},
messages=[{
"role": "user",
"content": JUDGE_PROMPT.format(question=question, answer=answer),
}],
)
scores = json.loads(resp.choices[0].message.content)
scores["weighted"] = (
scores["accuracy"] * 0.35 +
scores["relevance"] * 0.25 +
scores["safety"] * 0.20 +
scores["tone"] * 0.10 +
scores["conciseness"] * 0.10
)
return scores
认知翻转:评测集不是"上线前测试一下"的工具,是"持续运营 LLM 应用"的基础设施。它的价值不在于一次性证明"我的模型准确率 90%",而在于每次变更都能给你一个"变好了还是变差了"的客观回答。没有评测集的 LLM 应用迭代,本质上是在玩盲打;有评测集的迭代,才能算真正的工程。多数团队对评测集的投入严重不足,这是 LLM 应用走不远的核心瓶颈之一。
五、成本归因:让账单可解释、可优化
LLM 应用的成本是按 token 计费的,意味着"一次 API 调用花多少钱"是连续变量,不是固定值。这跟传统的"按服务器实例计费"完全不同。生产中的 LLM 账单经常出现"突然涨 3 倍"的现象,要能解释、能定位、能优化,你的日志必须从一开始就按多维度打 tag,而不是事后才回来加。
成本归因要至少能按以下几个维度切片:
- 用户 / 租户:哪些用户烧钱最多,是不是有滥用?
- endpoint / 功能:哪个功能模块成本最高,是不是值得继续做?
- prompt 模板:哪个模板最烧钱,有没有优化空间(精简、改小模型)?
- 模型:4o / mini / sonnet 等用量分布,贵模型是不是用过头了?
- 缓存命中:多少请求其实可以从缓存返回?
- 失败重试:是不是有大量重试在烧钱?
下面是一段成本归因日志的关键字段,跟前面的请求日志结合起来用:
def log_cost_attribution(trace: dict, usage: dict,
model_pricing: dict) -> dict:
input_tokens = usage["input_tokens"]
output_tokens = usage["output_tokens"]
model = usage["model"]
p = model_pricing[model]
input_cost = input_tokens * p["input_per_1k"] / 1000
output_cost = output_tokens * p["output_per_1k"] / 1000
total = input_cost + output_cost
return {
"ts": time.time(),
"request_id": trace["request_id"],
"user_id": trace["user_id"],
"tenant_id": trace.get("tenant_id"),
"endpoint": trace["endpoint"],
"prompt_template": trace["llm_call"]["prompt_version"],
"model": model,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_usd": input_cost,
"output_cost_usd": output_cost,
"total_cost_usd": total,
"cache_hit": trace.get("cache_hit", False),
"retry_count": trace.get("retry_count", 0),
}
把这条日志按上面几个维度做 group by,你就能得到一张张"谁在烧我的钱"的具体报表。生产里这种归因报表至少要按天看,关键时刻按小时看。你会发现一些惊人的事实:可能 5% 的用户消耗了 70% 的成本(典型的长尾)、可能某一个 prompt 模板的平均 token 远大于其他、可能某次故障导致大量重试占了一天 30% 的成本。这些洞察都是"账单数字"本身告诉不了你的,必须有结构化归因日志。
认知翻转:LLM 成本不是"一笔总账",是一个多维分布。学会用"切片思维"看成本——按用户切、按模板切、按模型切、按时间切——才能找到真正的优化点。优化 LLM 成本的最大杠杆往往不是"调小模型",而是"把不该调模型的调用挡在外面"(缓存)和"把简单调用换便宜模型"(模型路由)。这两件事的前提都是你能精确知道"每一类调用的成本是多少",而那需要从一开始就把归因维度埋好。
六、工程坑:那些"上线后才发现"的可观测细节
除了上面五节讲的主要话题,真实生产里还有一堆"教程不教但你一定撞上"的可观测细节。挑几个最常见、最坑的:
第一,prompt 日志要脱敏。用户输入可能包含手机号、身份证、地址、订单详情等敏感信息,直接全量记到日志/ES/数据湖里是合规风险。生产做法是在记录前用规则或小模型识别敏感字段做脱敏(替换或掩码),原文加密存到访问受限的安全存储,只有审计角色可解密。
第二,日志体积要预估。LLM 应用日志的单条体积可能是传统接口的 10-100 倍(因为要存完整 messages 和 response),日均请求 100 万的服务一天就能产生几十 GB 日志。设计存储分层:热数据(最近 7 天)放 Elasticsearch 或 ClickHouse,冷数据归档到 S3/OSS,实在不需要的非关键字段早期就别记。
第三,流式响应的日志要等到流结束才完整写。中途记不完整的内容反而干扰排查。常用模式是边流边追加到内存缓冲区,流结束(或异常)时一次性写日志。
第四,用户反馈要尽早做闭环。前端给用户提供赞/踩按钮、"重新生成"按钮、"反馈问题"输入框,把这些信号绑到 request_id 上,反馈数据是你评测集和持续优化的金矿。多数团队上线时只做 UI 不做闭环,反馈数据散在客服系统、产品系统、聊天记录里,等想用时根本拉不出来。
第五,关键指标要打到 Prometheus 而不是只存日志。日志查询慢,告警要靠指标。token 用量、成本、拒答率、finish_reason 分布这些核心信号都要打成 Prometheus counter/histogram。
第六,Trace ID 要打通前端到后端到 LLM 调用。前端生成 request_id 透传到所有日志、所有 LLM 调用、所有数据库查询,这样客服拿到用户反馈时能根据时间或 UI 截图反查到这次的完整链路。
第七,模型版本要持久化。OpenAI 一些模型(gpt-4o-2024-08-06)是带版本的,Anthropic、Google 也一样。你今天调"gpt-4o"实际后端可能在某天被切到了新版本,而你的应用对此一无所知。生产做法是固定具体的 versioned model id,并把这个 id 记到每条日志里,版本切换变成显式的运维动作。
第八,异常输出的自动检测。模型偶尔会输出重复的循环内容、空内容、纯标点、prompt 本身的内容等异常情况,这些都应该有自动检测规则在网关层拦截或在监控层告警。规则不复杂——比如同一句重复 5 次、输出长度小于 10 字符、输出包含 system prompt 的关键短语等。
第九,A/B 实验要绑实验 ID。给同一个功能做新 prompt 的 A/B 实验时,实验组 / 对照组的 request 都要带 experiment_id 和 group,这样你算出来的"实验组比对照组好"才有数据支撑。没绑 experiment_id 的 A/B,基本上算白做。
第十,告警要避免疲劳。LLM 应用的告警阈值要按业务真实分布定,而不是抄别人的。一个"看似合理但跟你业务不符"的阈值会让告警每天响几十次,响多了运维就麻木了。宁可少几个不灵敏的告警,也比多十几个被淹没的告警强。
把上面这些坑串起来,网关层的中间件大致长这样——同时管 trace_id 透传、token 计费、敏感词脱敏、异常输出检测:
import re, time, uuid
from fastapi import Request
SENSITIVE_RE = re.compile(
r"(\d{17}[\dXx]|1[3-9]\d{9}|\d{16,19})" # 身份证 / 手机 / 卡号
)
SYSTEM_PROMPT_LEAK_PATTERNS = [
"你是一个", "你的指令是", "system prompt", "IRON RULES",
]
def mask_sensitive(text: str) -> str:
return SENSITIVE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:],
text)
def detect_anomaly(output: str) -> list[str]:
flags = []
if len(output) < 10:
flags.append("too_short")
if any(p in output for p in SYSTEM_PROMPT_LEAK_PATTERNS):
flags.append("prompt_leak_suspect")
# 同一句重复 >= 5 次
lines = [l.strip() for l in output.splitlines() if l.strip()]
if lines and max((lines.count(l) for l in set(lines)), default=0) >= 5:
flags.append("repetition_loop")
return flags
async def observability_middleware(request: Request, call_next):
trace_id = request.headers.get("x-request-id") or uuid.uuid4().hex
request.state.trace_id = trace_id
started = time.time()
try:
response = await call_next(request)
finally:
elapsed_ms = (time.time() - started) * 1000
# 不在这里读 body(流式响应不能消费),由业务层在 finish() 里记录
log.info("http_done", trace_id=trace_id, ms=elapsed_ms,
path=request.url.path,
status=getattr(response, "status_code", 0))
response.headers["x-request-id"] = trace_id
return response
认知翻转:LLM 应用的可观测性工程量被严重低估。它不是"加几个 Prometheus 指标"的事,是一套从数据采集、存储分层、脱敏合规、指标聚合、告警阈值、反馈闭环、评测集运营、成本归因到 A/B 实验的完整工程系统。这套系统的复杂度不亚于应用本身。一个团队的 LLM 应用走得长不长,看这套可观测系统建得扎不扎实就知道。没有它,你迭代起来像盲打;有了它,你才能持续地在质量、成本、体验三者之间精准取舍。
关键概念速查
| 概念 | 含义 | 常见误区 | 正确做法 |
|---|---|---|---|
| 请求级追踪 | 每次 LLM 调用完整可回放 | 只记 request_id 和耗时 | 记完整 messages、prompt 版本、模型版本、温度、检索结果 |
| Prompt 版本化 | prompt 像代码一样版本管理 | 写死在代码里乱改 | 独立存储、版本号入日志、可回滚 |
| 评测集 | 固定样本 + 规则评测 | 没有,凭手感判断 | 每改 prompt 必跑、定时跑、投诉自动入集 |
| LLM-as-judge | 用 LLM 评 LLM 输出 | 用小模型评大模型 | 评委用同等或更强的模型,rubric 要具体 |
| finish_reason | 模型停止的原因 | 不监控 | length 占比高说明 max_tokens 太小 |
| 成本归因 | 按多维度切片看成本 | 只看总账 | 按用户/模板/模型/endpoint 切片 |
| 用户反馈 | 赞/踩/重生成等信号 | 有 UI 没闭环 | 绑 request_id,数据回流到评测集 |
| Prompt 注入检测 | 检测输出是否泄露 prompt | 不做 | 关键短语匹配 + 告警 + 输出过滤 |
| 模型版本固定 | 用 versioned model id | 用 gpt-4o 这种 alias | 用 gpt-4o-2024-08-06,版本切换显式化 |
| 日志脱敏 | 敏感字段处理后再存 | 原文直接进 ES | 规则/小模型识别 + 掩码 + 加密原文 |
避坑清单
- 不要只用传统监控(QPS/耗时/错误率)看 LLM 应用,这些指标完全捕捉不到"质量退化"和"成本失控"。
- 不要不记完整 messages 和 response,只记 request_id 和耗时,出问题时根本无法复现现场。
- 不要把 prompt 写死在代码里乱改,必须独立版本化、日志里记 prompt_version,才能跟问题对应得上。
- 不要凭手感判断 prompt 改完是好是坏,建评测集,每次变更必跑回归,投诉 case 自动入集。
- 不要不监控 finish_reason 分布,length 占比高说明 max_tokens 设小了,safety 占比高说明 prompt 触发安全策略。
- 不要只看总账单,要按用户/模板/模型/endpoint 多维度切片,找到真正的烧钱大户才能优化。
- 不要有 UI 反馈按钮但没数据闭环,反馈数据要绑 request_id 回流到评测集和 BI,不然 UI 等于装饰。
- 不要用 gpt-4o 这种 alias,要用 gpt-4o-2024-08-06 这种带版本的 model id,日志里记下来,版本切换变成显式动作。
- 不要原文记录 prompt 到 ES/数据湖,用户输入可能包含敏感信息,要先脱敏或加密,符合合规要求。
- 不要让告警阈值用别人的模板,要按你业务的真实分布定,阈值太敏感会让告警疲劳,大事被淹没。
总结
LLM 应用的可观测性是整个 LLM 工程化里最常被低估、却最决定生死的部分。多数团队会花大量时间在 prompt 调优、模型选型、工具设计上,却在"上线之后我怎么知道这一切是不是在好好工作"这个最简单的问题上交白卷。结果就是:线上跑得似乎不错,但你说不清楚为什么不错;某天用户投诉了,你说不清楚到底哪里出问题;你想优化,你说不清楚优化的目标在哪;你想算账,你说不清楚钱花在了什么地方。这种"瞎着跑"的状态在 demo 阶段可以,生产阶段是不可接受的。
另一层被低估的是,LLM 可观测的核心不是"加几个指标",而是"重新定义健康"。传统系统的健康是二元的——挂了/没挂、错了/对了。LLM 应用的健康是连续的——质量好不好、成本贵不贵、体验顺不顺。要从"二元判断"切换到"连续判断"的认知,需要一整套新的工具、新的指标、新的工作流(改 prompt 必跑评测、反馈数据自动回流、成本按维度切片归因)。这些事每一项都不复杂,合起来才构成"真正的 LLM 应用可观测体系"。
打个不太严谨的比方,做 LLM 应用可观测有点像运营一家高级餐厅。传统监控告诉你"厨房没起火、灯还亮着、收银机能用",这些当然重要,但顾客真正在意的是"菜好不好吃、上菜快不快、服务员有没有微笑"。一家好餐厅要监控的东西远多于"火灾报警和电路",它要看每桌的反馈、每道菜的剩饭量、每个时段的客流和翻台、每个食材的进货成本和损耗率——这些数据合起来才让老板知道"今天生意好不好"。LLM 应用监控同理:HTTP 200 只是"灯还亮着",不代表用户体验好;你需要的是面向"内容质量、成本、体验"的全套监控,才能真正理解你的 AI 应用在为用户做什么、做得好不好、值不值得继续做。
所以做 LLM 应用,本地跑通几个 case 永远暴露不了真正的可观测问题。它暴露不了你日志只记 request_id 没记 messages 时复盘的无力,暴露不了你没有评测集时改 prompt 的盲目,暴露不了你不归因成本时被账单震惊的措手不及,暴露不了你不监控 finish_reason 时回复被截断的悄无声息,更暴露不了你没绑 experiment_id 时 A/B 实验的徒劳。真正的检验在生产上线后的第一个月,在一次账单翻倍的早晨,在一个用户投诉无法复现的下午,在一次 prompt 改动后投诉激增的上线后。把上面六节里的功夫提前做扎实,等那些时刻到来时,你会感谢自己当初没图省事。如果你正在做或准备做 LLM 应用,请从一开始就把可观测当成跟代码同等重要的工程模块,而不是"先上线再说"——后补的可观测永远是缺胳膊少腿的,因为关键数据没埋点时早就丢了。
—— 别看了 · 2026