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 合计: 104507

232 条请求、14 条出错、6% 错误率,汇总接口报的是 0%。 这不是 bug,原因在 §2.1,但如果你看着那个 0% 就以为线上没事,那就出事了。

这一章讲怎么在没有参考答案的情况下看住线上系统。学完你应能:

前置依赖: 第 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)= 1184

76 + 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):

看出问题了吗:

动态内容(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  RuntimeError

20 类降到 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 条

这张表怎么读:

排查顺序就是:先看 tool 和 llm 层的组件名定位根因,chain 层的计数只当参考。


4. 延迟长尾 #

4.1 看分位数,不看平均值 #

  延迟        p50 1.76s   p95 8.57s   p99 11.47s

p50 和 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.54s

p95 从 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=1

exit=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=0

7.1 上线后观测清单 #

把本章的东西整理成一份可以照着做的清单。

上线前要准备好的:

每天看的(一分钟):

每周看的:

出事时的排查顺序:

  1. 先分清是全面故障还是局部问题:错误率是从 0 跳到 90%,还是从 2% 涨到 8%?前者查依赖(key、下游服务、限流),后者查具体错误分类
  2. 按 §3.2 聚类,看是不是单一根因
  3. 按 §3.3 定位层和组件——看 tool / llm 层,chain 层的计数只是冒泡
  4. 挑一条具体的 trace 按第 31 章的读法看时间轴
  5. 修完之后:把这个案例加进第 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. 练习 #

  1. 在你自己的项目上跑一次 monitor.py,把 include_stats 报的错误率和自己按窗口数出来的比一比。差多少?

  2. 验证 limit 的偏差。 同一个窗口,分别用全量和 --sample 50 算 p95,看差多少。再想想:什么情况下这个偏差会变成「高估」而不是低估?

  3. 给 error_kind 加一条规则。 找一类被它归到「首行」的错误(抓不到类名的),加个正则让它归到有意义的类别里。

  4. 加一条零容忍告警。 参照 §6 最后那段,让 AuthenticationError 出现一次就告警,并且不受错误率阈值影响。

  5. 把窗口对比做成三段。 现在是「本窗口 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 一定会再来。下一章就把这条链路接起来,做成一个闭环。