1. 本章目标 #
前八章都在「发版之前」:读 trace 排障、建数据集、写评测器、跑实验、设门禁。这一章往后挪一步——东西已经上线了,真实用户在用。
这时候的处境和之前完全不同:
| 发版前(第 29~36 章) | 上线后(本章) | |
|---|---|---|
| trace 数量 | 几十条,你自己造的 | 每天几千到几万条 |
| 你知道要看什么 | 知道,就看你改的那块 | 不知道,问题会从没想过的地方来 |
| 有参考答案 | 有,数据集里写着 | 没有,用户不会告诉你答案该是什么 |
| 目标 | 判断这版能不能发 | 尽早发现「有事发生了」,并定位到是什么事 |
第三行最要紧。上线后没有参考答案,所以第 34 章那套评测器基本用不上——你没法给真实用户的提问打 must_include。能用的只有不需要参考答案的信号:错不错、慢不慢、贵不贵、以及这些量有没有突变。
本章从一个具体的坑开始。我拿手上一个真实项目(前面几章零散调用积累下来的 langchain 项目)跑巡检,LangSmith 的汇总接口是这么报的:
include_stats: run_count=77 error_rate=0.00% tokens=5606零错误。但按时间窗口自己数一遍:
实际 list_runs: 根 run 232 条,其中 error 14 条 → 错误率 6.03%
实际 token 合计: 104507232 条请求、14 条出错、6% 错误率,汇总接口报的是 0%。 这不是 bug,原因在 §2.1,但如果你看着那个 0% 就以为线上没事,那就出事了。
这一章讲怎么在没有参考答案的情况下看住线上系统。学完你应能:
- 说清汇总数字为什么会骗人,以及该用什么替代
- 把几十条零散错误聚成几类——实测 75 条错误从 20 个「类别」归并成 6 个真实类别
- 定位错误落在哪一层(
chain/tool/llm)、哪个组件报得最多 - 用分位数而不是平均值看延迟,并知道 p99 高不代表输出长(本章最慢那条 26.9 秒,只生成了 169 个 token)
- 知道哪些字段能让服务端过滤、哪些只能拉回本地筛(
latency能,total_tokens不能) - 避开采样最大的陷阱:
limit给的是「最近 N 条」,不是随机样本,拿它算 p95 会低估 68% - 设出不会天天误报的告警:绝对阈值 + 变化率 + 小样本保护
- 拿到一份可以照着做的上线后观测清单
前置依赖: 第 31 章(读 trace、服务端 filter)、第 35 章 §9(include_stats 的坑)、第 36 章(阈值和告警的思路)。
参考文档:
1.1 本章统一环境 #
langsmith 0.12.1
观测对象:langchain 项目——.env 里的默认项目,前面几章零散调用都落在这里
规模:232 条根 run / 1184 条全部 run / 14 条根 run 出错 / 75 条含子 run 出错
时间跨度:08-22 ~ 09-03用这个项目而不是造假数据,是因为它的错误是真实积累出来的:过期 API key、超时、参数校验失败、PII 中间件拦下的、模型调用次数超限的。造数据造不出这种分布。
本章不需要新的 trace 配额,所有操作都是读。这也是上一章末尾提到的 429 之后仍能继续的原因。
2. 别信汇总数字 #
2.1 include_stats 只统计最近 7 天 #
第 35 章 §9.2 已经发现 include_stats 的 feedback_stats 要很久才收敛。这里是另一个问题,更隐蔽——它还有时间窗口。
把 run 按天数一遍就明白了:
roots = list(client.list_runs(project_name="langchain", is_root=True))
by_day = Counter(r.start_time.strftime("%m-%d") for r in roots if r.start_time)根 run 的时间分布(按天):
08-22 105 条
08-23 50 条
08-30 76 条
09-03 1 条
include_stats 报的 run_count = 77
全部 run(含子 run)= 118476 + 1 = 77。 今天是 09-03,include_stats 统计的是 08-30 和 09-03 这两天——也就是最近 7 天。08-22 和 08-23 的 155 条被切掉了。
而那 14 条出错的 run 全都在 08-22 和 08-23。所以:
窗口内(最近 7 天):77 条,0 条出错 → 0.00% ← include_stats 报的
全部时间: 232 条,14 条出错 → 6.03% ← 实际两个数都没错,是问题定义不同。麻烦在于 include_stats 不会告诉你它用了什么窗口,你看到 error_rate=0.00% 只会以为一切正常。
它有它的用处:
| 用途 | 能不能用 include_stats |
|---|---|
| 快速扫一眼哪个项目在活跃 | 能,一次查询拿全部项目 |
| 最近一周的 token 和成本 | 能,这正是它的窗口 |
| 告警判断 | 不能,窗口外的问题看不见 |
| 历史趋势对比 | 不能,拿不到指定窗口 |
2.2 自己按时间窗口数 #
监控要自己控制窗口。start_time 参数就是干这个的:
now = dt.datetime.now(dt.timezone.utc)
since = now - dt.timedelta(hours=24)
roots = list(client.list_runs(project_name=P, is_root=True, start_time=since)) 最近 1h: 0 条根 run
最近 6h: 1 条根 run
最近 24h: 1 条根 run
最近 72h: 1 条根 run
最近 720h: 232 条根 run有两个细节:
一是时区。 start_time 要传带时区的 datetime,用 dt.timezone.utc。传 naive datetime(不带时区)会按本地时间理解,和服务端的 UTC 差 8 小时——窗口整体偏移,看起来像流量突然消失。
二是 is_root=True。 不加它,返回的是全部 1184 条 run(含每个模型步、工具步)。算「请求数」和「错误率」必须只数根 run,否则一次请求里三个子 run 出错会被算成三次失败,错误率虚高。
3. 异常聚类 #
线上错误的特点是:同一个根因会以几十条不同的报错出现。 聚类就是把它们归回几个真实类别。
3.1 先把出错的捞出来 #
三种写法,结果一样但速度不一样:
# 写法一:error=True 参数
client.list_runs(project_name=P, is_root=True, error=True)
# 写法二:查询语言
client.list_runs(project_name=P, filter='and(eq(is_root, true), eq(status, "error"))')
# 写法三:不限根 run,把子 run 的错误也捞出来
client.list_runs(project_name=P, error=True) 全部根 run → 232 条 (7.1s)
error=True 参数 → 14 条 (7.2s)
filter eq(status,"error") → 14 条 (2.2s)
不限根、全部 error → 75 条 (1.2s)两点值得记:
filter 比 error=True 快 3 倍(2.2s vs 7.2s),结果完全一样。差别在服务端的执行路径,能用 filter 就用 filter。
根 run 14 条,含子 run 75 条。 这两个数都有用,但回答不同的问题:14 条是「多少次用户请求失败了」(算错误率用这个),75 条是「系统里一共出现了多少处错误」(做聚类用这个,信息更全)。
3.2 归一化:20 个「类别」其实只有 6 类 #
直接按错误信息的首行归类,看起来是这样:
不归一化(取首行):20 个类别
27 AuthenticationError("Error code: 401 - {'error': {'message': '
15 ValueError('参数不合法(不该重试)')Traceback (most recent call last):
14 TimeoutError('连接下游超时')Traceback (most recent call last):
5 ValueError('参数不合法(不该重试)')
4 PIIDetectionError('Detected 1 instance(s) of email in text con
4 ModelCallLimitExceededError('Model call limits exceeded: run l
2 TimeoutError('第 1 次调用超时失败')Traceback (most recent call last):
2 TimeoutError('第 2 次调用超时失败')Traceback (most recent call last):看出问题了吗:
- 同一个
ValueError,一条带Traceback一条不带,被算成两类(15 + 5) - 同一类超时,因为消息里带了「第 1 次」「第 2 次」,被拆成三类(14 + 2 + 2)
- 还有
During task with name 'tools' and id '23d859c3-...'这种——每条的 UUID 都不同,于是每条都是独一份,光这一种就凑出 4 个「类别」
动态内容(UUID、序号、时间戳、Traceback)会把聚类彻底打散。 得先抹掉再归类:
def error_kind(e: str) -> str:
"""把错误信息归一化成「类型」,否则聚不出类。"""
if not e:
return "(无错误信息)"
# 大多数错误串里都有异常类名,优先抓它
m = re.search(r"([A-Za-z_][\w.]*(?:Error|Exception|Timeout))\b", e)
if m:
return m.group(1).rsplit(".", 1)[-1] # 只留类名,去掉模块路径
# 抓不到类名的,退化到首行并把 UUID 抹成 <id>
line = e.strip().splitlines()[0]
return re.sub(r"[0-9a-f]{8}-[0-9a-f-]{27}", "<id>", line)[:50]归一化后:6 个类别
27 AuthenticationError
20 ValueError
19 TimeoutError
4 PIIDetectionError
4 ModelCallLimitExceededError
1 RuntimeError20 类降到 6 类,而且每一类都对应一个真实的根因。 现在能排优先级了:认证问题最多(27 条),参数校验和超时各约 20 条,中间件拦下的各 4 条。
.rsplit(".", 1)[-1] 那一步别省。不加它,langchain.agents.middleware._redaction.PIIDetectionError 会被截断成 langchain.agents.middleware._redaction.PIIDetectio——又长又会因为截断位置不同而分裂。
3.3 错误落在哪一层、哪个组件 #
归好类只知道「是什么错」,还要知道「在哪出的错」。两个维度:
Counter(r.run_type for r in sub) # 哪一层
Counter(r.name for r in sub) # 哪个组件含子 run 共 75 条出错,分布在:
chain 37 条
tool 25 条
llm 13 条
报错最多的组件:
LangGraph 14 条
ChatDeepSeek 13 条
search_order 12 条
value_error_tool 9 条
ToolRetryMiddleware.wrap_tool_call 6 条这张表怎么读:
chain层 37 条最多,但它多半不是根因。chain是编排层(LangGraph那 14 条就在这),子步骤出错会向上冒泡,所以它的计数天然偏高。看根因要往下看。tool25 条 +llm13 条才是真正出错的地方。search_order和value_error_tool是工具,ChatDeepSeek是模型调用。ToolRetryMiddleware.wrap_tool_call出现 6 次是个信号: 重试中间件被触发了 6 次,说明工具层的失败有一部分被重试掩盖了——用户可能没感知,但下游在抖。
排查顺序就是:先看 tool 和 llm 层的组件名定位根因,chain 层的计数只当参考。
4. 延迟长尾 #
4.1 看分位数,不看平均值 #
延迟 p50 1.76s p95 8.57s p99 11.47sp50 和 p99 差 6.5 倍。如果只报一个平均值,这个分布的形状就全丢了——平均值会被少数极慢的请求拉高,同时又掩盖它们到底有多慢。
分位数得自己算,因为 §2.1 那个窗口问题:
def pct(sorted_vals, q: float):
if not sorted_vals:
return 0.0
i = min(int(len(sorted_vals) * q), len(sorted_vals) - 1)
return sorted_vals[i]
lat = sorted((r.end_time - r.start_time).total_seconds()
for r in roots if r.end_time and r.start_time)
p50, p95, p99 = pct(lat, .5), pct(lat, .95), pct(lat, .99)配合「超过 N 秒有多少条」看,比单个分位数更直观:
【延迟长尾】
> 3s 64 条 27.6%
> 5s 30 条 12.9%
> 10s 9 条 3.9%27.6% 的请求超过 3 秒。 这个数字比「p50 = 1.76s」更能说明用户的实际体验。
4.2 慢不等于输出长 #
直觉上慢的请求应该是生成内容多的。看实际最慢的三条:
最慢三条:
26.9s LangGraph tokens= 169 status=success
16.3s LangGraph tokens= 1169 status=success
11.5s LangGraph tokens= 2162 status=success最慢的 26.9 秒只有 169 个 token,最快的 11.5 秒反而有 2162 个。 延迟和 token 数完全不相关,甚至是反的。
所以那 26.9 秒花在哪了?不是生成,是等——网络抖动、模型侧排队、限流后的重试。这些都不体现在 token 上。
这条对排查影响很大:看到 p99 高,不要先去优化提示词长度或输出长度。 先去看那几条慢 trace 的时间轴,看时间是耗在哪个 span 上(第 31 章的读法)。第 36 章也顺带见过一次这个现象——坏版本工具调用多了 57%,但耗时中位数反而略降。
4.3 哪些字段能让服务端过滤 #
latency 可以:
client.list_runs(project_name=P,
filter='and(eq(is_root, true), gt(latency, 5))') latency > 3s → 64 条
latency > 5s → 30 条
latency > 10s → 9 条total_tokens 不行——而且是静默地不行:
本地筛出 total_tokens>2000 的根 run:10 条
例:LangGraph tokens=2789
gt(total_tokens, 2000) → 0 条
gt(total_tokens, 2000.0) → 0 条
gte(total_tokens, 2000) → 0 条
gt(prompt_tokens, 1000) → 0 条
gt(latency, 5) → 30 条 ← 只有这个正常明明有 10 条 token 超过 2000,服务端 filter 返回 0 条,不报错、不警告。 换成 gte、换成浮点数、换 prompt_tokens 都一样是 0。
所以要按 token 筛,只能拉回本地:
roots = list(client.list_runs(project_name=P, is_root=True, start_time=since))
big = [r for r in roots if (r.total_tokens or 0) > 2000]这是个需要记住的边界:filter 表达式写错字段名不会报错,只会返回空。 拿到 0 条时先确认是真没有,还是这个字段不支持——方法就是拉回本地数一遍对比。
5. 采样:limit 不是随机样本 #
线上 trace 量大,直觉是采样。这里有两个坑。
5.1 limit 上限是 100 #
limit= 100 → 100 条,OK
limit= 101 → 报错 {"detail":"Limit exceeds maximum allowed value of 100"}
limit= 500 → 报错 {"detail":"Limit exceeds maximum allowed value of 100"}硬限制,超了服务端直接 400。不传 limit 时 SDK 会自己翻页:
不传 limit 时 SDK 自己翻页:
拿到 232 条,耗时 12.2s(内部按 100 一页翻)5.2 limit 给的是「最近 N 条」 #
这个坑更要紧。同一个项目、同一个时间窗口,全量 vs 采样 50 条:
全量 232 条: p50 1.76s p95 8.57s p99 11.47s
采样 50 条: p50 0.25s p95 2.72s p99 5.54sp95 从 8.57 秒变成 2.72 秒,低估 68%。
因为 limit=50 返回的是按时间倒序的最近 50 条,不是从 232 条里随机抽 50 条。而最近那批恰好是后期跑的小测试,又快又简单。样本有系统性偏差,算出来的分位数没有意义。
结论:
| 目的 | 能不能用 limit |
|---|---|
| 看最新几条长什么样 | 能,这正是它的语义 |
| 快速确认「服务还活着吗」 | 能 |
| 算错误率、分位数 | 不能,会系统性偏低 |
| 看某类错误的具体案例 | 能,配合 filter 缩小范围后取几条 |
要真采样,得自己按时间分层——把窗口切成若干段,每段取一点:
# 想要覆盖整个窗口的样本,就按时间切片各取一点
buckets = []
for i in range(6):
a = since + (now - since) * i / 6
b = since + (now - since) * (i + 1) / 6
seg = list(client.list_runs(project_name=P, is_root=True,
start_time=a, limit=20))
buckets += [r for r in seg if r.start_time and r.start_time < b]不过对多数项目来说,每天几千条直接拉全量也就十几秒,先别急着优化。monitor.py 的 --sample 参数存在的意义是应急(比如项目太大暂时想看个大概),文档里也标了它会让分位数失真。
6. 告警怎么设才不天天误报 #
第 36 章讲过门禁最怕误报。告警更是——告警疲劳一旦形成,真出事那条也会被划掉。
三类规则配合:
ALERT = {
"error_rate": 0.05, # 绝对阈值:错误率超 5%
"p95_sec": 10.0, # 绝对阈值:p95 超 10 秒
"error_rate_jump": 2.0, # 变化率:错误率比上一窗口翻倍
"p95_jump": 1.5, # 变化率:p95 涨 50%
}绝对阈值管「已经很糟了」。它简单可靠,但定得高就发现得晚,定得低就长期在响。
变化率管「正在变糟」。它能在绝对值还没触线时就报出来,是更早的信号。但它有个前提——上一个窗口得有足够样本:
# 变化率类告警要求上一窗口有足够样本,否则小样本会乱报
if prev["n"] >= 20:
if prev["error_rate"] > 0 and \
cur["error_rate"] / prev["error_rate"] > ALERT["error_rate_jump"]:
alerts.append(...)
elif prev["n"]:
print(f"(上一窗口只有 {prev['n']} 条,样本太少,跳过变化率告警)")没有这个保护,低流量时段一定误报。 上一窗口 2 条里有 1 条失败是 50%,这个窗口 100 条里有 3 条失败是 3%——变化率算出来是「下降」,反过来如果上窗口 0 条失败、这窗口 1 条,比值是无穷大。夜里流量低的时候,这种告警会响一整晚。
第三类是零容忍规则,不看比例只看有没有:某些错误出现一次就该报。比如认证失败(说明 key 过期或被吊销,会影响所有后续请求)、以及第 34 章那个 no_leak 对应的提示词泄漏。这类判断放进 error_kind 的分类结果里做就行:
if kinds.get("AuthenticationError"):
alerts.append(f"出现 {kinds['AuthenticationError']} 次认证失败,检查 API key")本章这个项目跑出来是:
============================================================================
✗ 有告警
错误率 6.03% > 阈值 5%
============================================================================
exit=1exit=1 是为了能挂到定时任务上。真接告警通道的时候,把这里换成发企业微信 / Slack / PagerDuty 即可——判断逻辑和通知渠道要分开写,否则换渠道得改判断代码。
7. 本章产出:monitor.py #
"""monitor.py —— 线上巡检。可独立运行。
python monitor.py # 默认看最近 24 小时
python monitor.py --hours 168 # 看最近 7 天
python monitor.py --project langchain --hours 720
python monitor.py --sample 100 # 只抽 100 条,省时间
做四件事:健康度、异常聚类、延迟长尾、和上一窗口对比。
退出码:0 = 正常,1 = 有告警。可以挂到定时任务上。
为什么不直接用 `list_projects(include_stats=True)`:它只统计最近 7 天。
实测同一个项目它报 `run_count=77 / error_rate=0.00%`,
而按时间窗口自己数是 232 条根 run、14 条出错(6.03%)——
那 14 条错误全在 7 天之前,被窗口切掉了(第 37 章 §2.1)。
"""
import argparse
import datetime as dt
import re
import sys
from collections import Counter
from dotenv import load_dotenv
load_dotenv(override=True)
from langsmith import Client
client = Client()
# 告警阈值。错误率和延迟用绝对值,变化率用相对值
ALERT = {
"error_rate": 0.05, # 错误率超 5% 告警
"p95_sec": 10.0, # p95 延迟超 10 秒告警
"error_rate_jump": 2.0, # 错误率比上一窗口翻倍告警
"p95_jump": 1.5, # p95 比上一窗口涨 50% 告警
}
def error_kind(e: str) -> str:
"""把错误信息归一化成「类型」,否则聚不出类。
线上错误串里全是 UUID、行号、Traceback,同一种错误会被拆成好几类。
实测 75 条错误:不归一化是 20 个类别(同一个 ValueError 因为
带不带 Traceback 被拆成 15 和 5 两堆),归一化后是 6 个(§3.2)。
"""
if not e:
return "(无错误信息)"
m = re.search(r"([A-Za-z_][\w.]*(?:Error|Exception|Timeout))\b", e)
if m:
return m.group(1).rsplit(".", 1)[-1] # 只留类名,去掉模块路径
line = e.strip().splitlines()[0]
return re.sub(r"[0-9a-f]{8}-[0-9a-f-]{27}", "<id>", line)[:50]
def collect(project: str, since: dt.datetime, until=None, sample=None) -> dict:
"""拉一个时间窗口的根 run。
注意 limit 最大 100(超了服务端直接 400),不传 limit 时 SDK 会自己
按 100 一页翻完(232 条约 12 秒)。--sample 就是拿 limit 换时间。
"""
kw = dict(project_name=project, is_root=True, start_time=since)
if sample:
kw["limit"] = min(sample, 100)
roots = list(client.list_runs(**kw))
if until:
roots = [r for r in roots if r.start_time and r.start_time < until]
lat = [(r.end_time - r.start_time).total_seconds()
for r in roots if r.end_time and r.start_time]
errs = [r for r in roots if r.status == "error"]
return {
"n": len(roots),
"errs": errs,
"error_rate": len(errs) / len(roots) if roots else 0.0,
"lat": sorted(lat),
"tokens": sum(r.total_tokens or 0 for r in roots),
"roots": roots,
}
def pct(sorted_vals, q: float):
if not sorted_vals:
return 0.0
i = min(int(len(sorted_vals) * q), len(sorted_vals) - 1)
return sorted_vals[i]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project", default="langchain")
ap.add_argument("--hours", type=float, default=24)
ap.add_argument("--sample", type=int, help="只抽这么多条(上限 100)")
args = ap.parse_args()
now = dt.datetime.now(dt.timezone.utc)
t1 = now - dt.timedelta(hours=args.hours)
t0 = now - dt.timedelta(hours=args.hours * 2)
print("=" * 76)
print(f"线上巡检 | {args.project} | 最近 {args.hours:g}h"
f"{f' | 抽样 {args.sample}' if args.sample else ''}")
print(f"窗口 {t1:%m-%d %H:%M} ~ {now:%m-%d %H:%M} UTC")
print("=" * 76)
cur = collect(args.project, t1, sample=args.sample)
prev = collect(args.project, t0, until=t1, sample=args.sample)
# ── 1. 健康度
print(f"\n【健康度】")
if cur["n"] == 0:
print(f" 窗口内没有请求。要么真没流量,要么项目名写错了。")
print(f" 提示:项目名区分大小写,且 evaluate 的 run 在实验项目里,"
f"不在默认项目。")
return 0
p50, p95, p99 = pct(cur["lat"], .5), pct(cur["lat"], .95), pct(cur["lat"], .99)
n_note = f" (上一窗口 {prev['n']})" if prev["n"] else ""
er_note = f" (上一窗口 {prev['error_rate']:.2%})" if prev["n"] else ""
print(f" 请求数 {cur['n']}{n_note}")
print(f" 错误率 {cur['error_rate']:.2%}{er_note}")
print(f" 延迟 p50 {p50:.2f}s p95 {p95:.2f}s p99 {p99:.2f}s")
print(f" token 合计 {cur['tokens']}"
f"({cur['tokens'] / cur['n']:.0f}/请求)")
# ── 2. 异常聚类
print(f"\n【异常聚类】{len(cur['errs'])} 条出错")
if cur["errs"]:
kinds = Counter(error_kind(r.error) for r in cur["errs"])
for k, c in kinds.most_common(8):
print(f" {c:4} 条 {c / len(cur['errs']):>6.1%} {k}")
# 错误落在哪一层:要连子 run 一起看,根 run 的错误信息常是转发上来的
sub = list(client.list_runs(project_name=args.project, error=True,
start_time=t1))
if sub:
print(f"\n 含子 run 共 {len(sub)} 条出错,分布在:")
for t, c in Counter(r.run_type for r in sub).most_common():
print(f" {t:10} {c:4} 条")
print(f" 报错最多的组件:")
for t, c in Counter(r.name for r in sub).most_common(5):
print(f" {t[:32]:34} {c:4} 条")
print(f"\n 最近三条的原文(截断):")
for r in cur["errs"][:3]:
print(f" [{r.start_time:%m-%d %H:%M}] "
f"{(r.error or '').strip().splitlines()[0][:64]}")
# ── 3. 延迟长尾
print(f"\n【延迟长尾】")
# latency 支持服务端过滤,token 不支持(§4.3),所以这里能用 filter
for sec in [3, 5, 10]:
n = sum(1 for _ in client.list_runs(
project_name=args.project, start_time=t1,
filter=f'and(eq(is_root, true), gt(latency, {sec}))'))
print(f" > {sec:2}s {n:4} 条 {n / cur['n']:>6.1%}")
slow = sorted((r for r in cur["roots"] if r.end_time),
key=lambda r: -(r.end_time - r.start_time).total_seconds())[:3]
if slow:
print(f" 最慢三条:")
for r in slow:
sec = (r.end_time - r.start_time).total_seconds()
print(f" {sec:6.1f}s {r.name[:22]:24} "
f"tokens={r.total_tokens or 0:6} status={r.status}")
# ── 4. 告警
print("\n" + "=" * 76)
alerts = []
if cur["error_rate"] > ALERT["error_rate"]:
alerts.append(f"错误率 {cur['error_rate']:.2%} > 阈值 "
f"{ALERT['error_rate']:.0%}")
if p95 > ALERT["p95_sec"]:
alerts.append(f"p95 延迟 {p95:.1f}s > 阈值 {ALERT['p95_sec']:.0f}s")
# 变化率类告警要求上一窗口有足够样本,否则小样本会乱报
if prev["n"] >= 20:
if prev["error_rate"] > 0 and \
cur["error_rate"] / prev["error_rate"] > ALERT["error_rate_jump"]:
alerts.append(f"错误率从 {prev['error_rate']:.2%} 涨到 "
f"{cur['error_rate']:.2%}")
p95_prev = pct(prev["lat"], .95)
if p95_prev > 0 and p95 / p95_prev > ALERT["p95_jump"]:
alerts.append(f"p95 从 {p95_prev:.1f}s 涨到 {p95:.1f}s")
elif prev["n"]:
print(f"(上一窗口只有 {prev['n']} 条,样本太少,跳过变化率告警)")
if alerts:
print("✗ 有告警")
for a in alerts:
print(f" {a}")
print("=" * 76)
return 1
print("✓ 各项正常")
print("=" * 76)
return 0
if __name__ == "__main__":
sys.exit(main())跑一下:
> python monitor.py --project langchain --hours 720
============================================================================
线上巡检 | langchain | 最近 720h
窗口 08-04 07:48 ~ 09-03 07:48 UTC
============================================================================
【健康度】
请求数 232
错误率 6.03%
延迟 p50 1.76s p95 8.57s p99 11.47s
token 合计 104507(450/请求)
【异常聚类】14 条出错
5 条 35.7% AuthenticationError
3 条 21.4% ValueError
2 条 14.3% PIIDetectionError
2 条 14.3% ModelCallLimitExceededError
1 条 7.1% RuntimeError
1 条 7.1% TimeoutError
含子 run 共 75 条出错,分布在:
chain 37 条
tool 25 条
llm 13 条
报错最多的组件:
LangGraph 14 条
ChatDeepSeek 13 条
search_order 12 条
value_error_tool 9 条
ToolRetryMiddleware.wrap_tool_ca 6 条
最近三条的原文(截断):
[08-22 07:39] RuntimeError('Cannot use Command(resume=...) without checkpointe
[08-22 04:01] PIIDetectionError('Detected 1 instance(s) of email in text conte
[08-22 04:00] PIIDetectionError('Detected 1 instance(s) of email in text conte
【延迟长尾】
> 3s 64 条 27.6%
> 5s 30 条 12.9%
> 10s 9 条 3.9%
最慢三条:
26.9s LangGraph tokens= 169 status=success
16.3s LangGraph tokens= 1169 status=success
11.5s LangGraph tokens= 2162 status=success
============================================================================
✗ 有告警
错误率 6.03% > 阈值 5%
============================================================================
exit=1流量很少的窗口也能正常处理:
> python monitor.py --project langchain --hours 24
【健康度】
请求数 1
错误率 0.00%
延迟 p50 0.14s p95 0.14s p99 0.14s
【异常聚类】0 条出错
✓ 各项正常
exit=07.1 上线后观测清单 #
把本章的东西整理成一份可以照着做的清单。
上线前要准备好的:
- 生产环境用独立的 LangSmith 项目,别和开发、评测混在一起(第 30 章 §4)
-
metadata里带上版本号 /git_sha/ 环境标识,出问题能按版本筛(第 36 章 §9.3) - 敏感字段配好
hide_inputs/hide_outputs(第 30 章 §6)——上线后再补就来不及了,已经传上去的抹不掉 - trace 配额算一遍:日请求量 × 天数,对照套餐上限(第 36 章 §6.3 那个 429)
- 巡检脚本挂上定时任务,跑通一次告警通道
每天看的(一分钟):
- 请求数——和昨天同期比,掉一半和涨十倍同样值得查
- 错误率和错误分类的构成有没有变(新出现的类别最要紧)
- p95 / p99,以及「>3 秒占比」
- token/请求——突然上涨通常意味着有人的提示词变长了,或者进了重试循环
每周看的:
- 把这周的错误样本捞出来,按 §3 聚类,挑高频的看具体 trace
- 用户反馈的差评(
create_feedback打的分,第 33 章 §5.5)采样进评测集 - 成本趋势,对照套餐配额
- 门禁的基线是否还符合线上实际(第 36 章 §9.5)
出事时的排查顺序:
- 先分清是全面故障还是局部问题:错误率是从 0 跳到 90%,还是从 2% 涨到 8%?前者查依赖(key、下游服务、限流),后者查具体错误分类
- 按 §3.2 聚类,看是不是单一根因
- 按 §3.3 定位层和组件——看
tool/llm层,chain层的计数只是冒泡 - 挑一条具体的 trace 按第 31 章的读法看时间轴
- 修完之后:把这个案例加进第 36 章的回放集,确认门禁下次能拦住它
最后一步最容易省掉,但它是这一章和前面八章连起来的地方——线上发现的问题要变成评测集里的一条,否则同样的 bug 会再来一次。下一章就做这件事。
8. 常见问题 #
Q:LangSmith 自带的 dashboard 图表能不能直接用?
能看趋势,但要清楚它的窗口(§2.1)。而且自定义程度有限——像本章 §3.2 那种归一化聚类、§6 那种带小样本保护的变化率告警,都得自己写。用 UI 看趋势、找具体 trace;用脚本做告警和聚类。
Q:巡检脚本多久跑一次?
看流量。日请求上万的,15 分钟一次配合 1 小时窗口;日请求几百的,1 小时一次配合 24 小时窗口就够。窗口太短会因为样本不足而误报(§6 的小样本保护就是为这个),所以宁可窗口开大一点、跑得勤一点。
Q:线上没有参考答案,第 34 章的评测器是不是完全用不上?
不需要参考答案的那几个还能用:no_crash、no_leak、concise 都只看输出本身。must_include 和 llm_judge 需要参考答案,只能用在数据集上。另一条路是在线评测——对线上输出跑 LLM-as-judge,但这要为每条真实请求付一次裁判成本,通常只对采样出来的一小部分做。
Q:用户点了「没用」,怎么和 trace 关联起来?
第 33 章 §5.5 那套 create_feedback。前端把 run_id 带上,用户点差评时打一条 feedback,然后按 feedback 筛选差评的 trace 采样进评测集。注意那里的索引延迟——feedback 写完要 10~15 秒才能查到。
Q:error_kind 那个正则会不会误判?
会。它假设错误信息里有 XxxError / XxxException / XxxTimeout 形式的类名,这对 Python 生态基本成立,但下游服务返回的 JSON 错误、或者自定义命名的异常就抓不到,会退化到「首行 + 抹 UUID」。聚类的目的是排优先级,不是精确分类,八成准就够用。看到某个类别里混了不同的东西,再针对它加条规则。
Q:为什么错误率要只数根 run?
一次用户请求是一条根 run。它内部三个子 run 出错、被重试中间件救回来了,用户其实没受影响。如果按全部 run 算,这次成功的请求会贡献 3 次失败——本章的数据里就是 14(根)对 75(全部),差 5 倍多(§3.1)。算错误率数根 run,做聚类看全部 run。
9. 练习 #
在你自己的项目上跑一次
monitor.py,把include_stats报的错误率和自己按窗口数出来的比一比。差多少?验证
limit的偏差。 同一个窗口,分别用全量和--sample 50算 p95,看差多少。再想想:什么情况下这个偏差会变成「高估」而不是低估?给
error_kind加一条规则。 找一类被它归到「首行」的错误(抓不到类名的),加个正则让它归到有意义的类别里。加一条零容忍告警。 参照 §6 最后那段,让
AuthenticationError出现一次就告警,并且不受错误率阈值影响。把窗口对比做成三段。 现在是「本窗口 vs 上一窗口」,改成和「过去 7 天同一时段的均值」比。想想为什么后者对有日周期的流量更合适。
10. 小结 #
上线后的观测和发版前的评测是两件事:没有参考答案,也不知道该看什么。 所以能用的信号只有「错不错、慢不慢、贵不贵,以及有没有突变」。
这一章有三个数字值得记住。
第一个是 0% 对 6.03%。 include_stats 只统计最近 7 天,而那 14 条错误在 7 天之前,于是它报零错误。汇总数字不会告诉你它的窗口是什么——告警必须自己按时间窗口数,不能读汇总接口。
第二个是 20 类对 6 类。 同一个 ValueError 因为带不带 Traceback 被拆成两堆,同一类超时因为消息里带「第 1 次」「第 2 次」被拆成三堆,带 UUID 的更是每条一类。抹掉动态内容再归类,20 个「类别」收敛成 6 个真实根因,这才能排优先级。定位则要分层看:chain 层计数最高但多是冒泡,根因在 tool 和 llm 层。
第三个是 8.57 秒对 2.72 秒。 limit=50 给的是最近 50 条,不是随机 50 条,拿它算 p95 低估了 68%。limit 用来看最新几条、上限还只有 100,任何要算比例和分位数的场合都得拉全量。
还有一条反直觉的:慢不等于输出长。 最慢那条 26.9 秒只生成了 169 个 token,而 11.5 秒那条有 2162 个。p99 高的时候先去看慢 trace 的时间轴,别一上来就优化提示词长度。
告警的设法和第 36 章的门禁同一个道理——最怕误报。绝对阈值管「已经很糟」,变化率管「正在变糟」,但变化率必须加小样本保护,否则夜里流量低的时候会响一整晚。
最后是那份观测清单里最容易省掉的一步:线上修完一个问题,要把它变成评测集里的一条。 不做这步,同样的 bug 一定会再来。下一章就把这条链路接起来,做成一个闭环。