Day 55 / 共 68 天 · 阶段 10 可信可观测

可观测:给 Agent 装一台行车记录仪

昨天(Day 54)学了失败闸门和护栏,让 Agent「出事不崩、危险先审批」。可它上线后到底跑得怎么样、花了多少钱、有没有偷偷变笨?你现在两眼一抹黑。今天补上可观测(Observability)这一课:用结构化日志留痕、用 trace 还原一次完整运行、用指标盯住 token/延迟/错误率、还要盯「输出漂移」。学完你就能把线上 Agent 从「黑箱」变成「有仪表盘的驾驶舱」;明天(Day 56)接着讲怎么把这笔账管起来、少花钱。

📍 你在阶段 10(可信可观测 D53-56)的位置
D53 防幻觉 Critic D54 失败闸门&护栏 D55 可观测 D56 成本治理&缓存
💡 用一个类比兜住今天(今天全程沿用「开车上路」的世界观) 线上跑一个 Agent = 开一辆车跑长途日志(log) = 行车记录仪,把一路上每件事都录下来,出事能回放;trace(链路追踪) = 一整趟行程的轨迹回放,从上车到下车每个路口都串成一条线;span = 轨迹里的一段路(比如「过隧道」这一段用了多久);指标(metrics) = 仪表盘上的数字(时速=延迟、油耗=token 花费、故障灯=错误率);输出漂移 = 车子悄悄跑偏了方向你却没发现。今天你从「盲开」升级成「盯着仪表盘开」。
L01

为什么要可观测:黑箱上路太危险

🤔 痛点Agent 上线了,一周后老板问你:「昨天有个用户说答案是瞎编的,哪一步错了?」「这个月 API 花了多少钱?」「最近是不是变慢了?」——你打开代码,啥都查不到,只能干瞪眼。程序不像人,不会自己喊疼。
💡 本质可观测 = 让系统自己「说出」它的内部状态,不用你拆开看。就像给车装行车记录仪 + 仪表盘:平时不用管,一旦出事故,回放录像 + 看仪表数据就能定位问题。三大支柱:日志(发生了啥)、trace(一次请求走了哪条路)、指标(整体健康数字)
可观测的三大支柱(都是为了「事后说得清」) 📝 日志 Logs 一条条事件记录 「几点,谁,做了啥」 行车记录仪 🧵 追踪 Trace 一次请求的完整轨迹 每一步串成一条线 行程回放 📊 指标 Metrics 可加总的数字 延迟/token/错误率 仪表盘
图注:三者互补——日志看细节,trace 看一次请求的来龙去脉,指标看整体趋势。
👶 「可观测」和「监控」有啥区别?简单记:监控(monitoring)是「盯着已知的坏事」(比如错误率超 5% 就报警);可观测(observability)是「有能力回答没预料到的问题」(比如「为啥偏偏这个用户的请求慢?」)。可观测是地基,监控是盖在上面的报警器。今天两个都会碰到,不用抠字眼。
L02

结构化日志:录像要「能检索」才有用

🤔 痛点很多新手用 print("出错了") 打日志。上线后几十万行 print 混在一起,想找「用户 A 昨晚那次报错」如同大海捞针,而且 print 没时间、没级别、没上下文。
💡 本质结构化日志 = 把日志写成机器能读的「一条条 JSON 记录」,而不是随口一句话。就像行车记录仪不仅录画面,还标上「时间、GPS、车速」——每条日志都带字段,以后能按字段搜、按级别筛。级别从轻到重:DEBUG(调试碎碎念)→INFO(正常流水)→WARNING(有点不对劲)→ERROR(出事了)。
import logging, json

# 配一个最简单的日志器:带时间、级别
logging.basicConfig(level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("agent")

# ❌ 新手写法:一句大白话,事后没法按字段搜
# print("用户问了问题,模型答完了")

# ✅ 结构化写法:把关键信息拼成 JSON,一条日志一个「事件」
log.info(json.dumps({
    "event": "llm_call",          # 事件类型:这次是「调用大模型」
    "user_id": "u_123",           # 谁触发的
    "model": "claude-3.5",        # 用了哪个模型
    "input_tokens": 820,          # 输入花了多少 token
    "output_tokens": 156,         # 输出多少 token
    "latency_ms": 1340,           # 耗时(毫秒)
    "ok": True                    # 成功还是失败
}, ensure_ascii=False))
# 输出:2026-07-12 10:20:01 [INFO] {"event":"llm_call","user_id":"u_123",...}
📝 举个例子:结构化日志的威力 线上排查时,你可以一句命令过滤出所有「失败的、耗时超 3 秒的」调用:
cat app.log | grep '"ok": false' | grep latency
因为每条日志都是规整的 JSON,你还能把它们喂给分析工具画图。散装 print 做不到这些——从今天起,凡是「以后可能要查」的事,都写结构化日志。
👶 一条好日志该带哪些字段?记个口诀「谁、何时、干了啥、结果如何、花了多少」:user_id(谁)、timestamp(何时,日志器自动加)、event(干了啥)、ok/error(结果)、tokens/latency_ms(花费)。再加一个 trace_id(下一讲讲)把同一次请求的所有日志串起来,就完美了。
L03

trace:把一次运行的轨迹串成一条线

🤔 痛点一个 Agent 回答一个问题,背后可能走了七八步:改写问题 → 检索知识库 → 调模型 → 调工具 → 再调模型总结。用户说「这次好慢」,到底卡在哪一步?散装日志根本串不起来。
💡 本质trace(链路追踪) = 给「一次完整请求」发一个身份证号(trace_id),它经过的每一步都盖上这个号,最后把所有步骤按时间拼成一条轨迹。其中每一步叫一个 span(一段路),记录它自己开始/结束的时间。这样你一眼就能看出「哪段路最堵」。就像快递单号:一个包裹从下单到签收,每个中转站都扫这个单号,你查单号就能看到全程。
一次 trace = 多个 span 拼成的时间轴(长条越长=越耗时) trace_id: abc-123(同一次请求,所有 span 共享) ← 时间从左到右 → 改写问题 (120ms) 检索知识库 (300ms) 调模型总结 (1800ms) ← 最慢! 调工具 (150ms)
图注:一眼看出「调模型总结」这段 span 占了大头,优化就该从这里下手——这就是 trace 的价值。
import uuid, time, logging, json
log = logging.getLogger("agent")

# 一次请求进来,先发一个「身份证号」
trace_id = str(uuid.uuid4())[:8]   # 例如 "a1b2c3d4"

def span(name):                     # 一个小工具:给某一步计时并打日志
    t0 = time.time()
    return t0, name

def end_span(t0, name):
    ms = int((time.time() - t0) * 1000)
    # 每一步都带上同一个 trace_id → 事后能串成一条线
    log.info(json.dumps({"trace_id": trace_id, "span": name, "ms": ms}))

t0, n = span("检索知识库")
# ... 这里执行真正的检索 ...
end_span(t0, n)          # 输出 {"trace_id":"a1b2c3d4","span":"检索知识库","ms":300}
真实项目里你不用手写这套——LangSmith、Langfuse、OpenTelemetry 这类工具会自动帮你记 span。但先理解「trace_id 把一次请求串起来、span 是其中一段」这个骨架,看工具面板才不懵。
L04

指标 metrics:仪表盘上的四个关键数字

🤔 痛点日志和 trace 是「一次一次」看的,可老板要的是「整体怎么样」:平均多快?这月烧了多少钱?多少请求失败了?一条条翻日志翻不过来。
💡 本质指标 = 能加总、能画成曲线的数字。把成千上万次请求聚合成几个关键数,像车的仪表盘:不用看每一秒,扫一眼就知道健康度。Agent 最该盯的四个:① token 用量(油耗/花钱)、② 延迟(时速)、③ 错误率(故障率)、④ 输出漂移(跑偏,下一讲)
指标怎么理解看什么信号
Token 用量每次调模型的输入+输出 token,直接换算成钱突然飙高 = 有人恶意刷、或 prompt 变臃肿
延迟 P50 / P95一半请求快过 P50;95% 请求快过 P95P95 高 = 少数用户体验很差(长尾)
错误率失败请求 ÷ 总请求突然升高 = 上游 API 挂了 / 代码有 bug
输出漂移回答质量悄悄下滑模型升级、prompt 被改后常发生

👶 小白:为什么看延迟要分 P50、P95,不直接看「平均值」?

👨‍🏫 老师:因为平均值会骗人!假设 100 个请求里 99 个都是 1 秒,但有 1 个卡了 100 秒——平均值算出来「约 2 秒」,看着还行,可那 1 个用户已经气疯了。P95=2 秒 意思是「95% 的人 2 秒内拿到结果」,P95 才反映大多数人的真实体验。行话叫「长尾延迟」:平均值把最惨的那批人藏起来了,P95/P99 把他们揪出来。开车看平均时速没用,你得知道最堵那段有多堵。

📝 举个例子:一块最朴素的指标看板 把每次请求的 latency_msok 攒进内存,定时算一算:
错误率 = 失败数 / 总数 = 12 / 400 = 3%(超 5% 就该报警)
P95 延迟 = 把 400 个延迟排序,取第 380 个 = 2100ms
真实项目里 Prometheus + Grafana 或 Langfuse 会自动算这些并画成曲线,你只要会看。
L05

监控输出漂移:车在悄悄跑偏

🤔 痛点延迟、错误率都正常,请求也都成功了——可用户就是抱怨「答案越来越水」。因为 Agent「没报错地变笨了」:模型供应商偷偷升级了版本、你改了个 prompt、或知识库过期了。传统监控只看「有没有崩」,看不见「质量下滑」。
💡 本质输出漂移(drift) = 系统没报错,但输出的「质量/风格/格式」悄悄偏离了正常。就像车子方向盘慢慢跑偏,仪表盘一切正常,可你正一点点偏离车道。对付它靠三招:① 抽样人工看、② 用 LLM 当裁判打分(Day 50 学过)、③ 盯格式/长度等硬指标。
质量评分随时间「悄悄漂移」——报错=0,但曲线在下滑 ⚠ 这天改了 prompt 第 1 周(好) 第 4 周(明显变差)
图注:漂移是「温水煮青蛙」,只盯错误率永远发现不了——必须持续给输出质量打分。
👶 我一个人怎么盯质量?不用全盯!每天随机抽 10~20 条线上问答,让 LLM 裁判按你的评分标准打分(1~5),把平均分画成一条曲线。分数一旦持续往下掉,就去查最近改了啥。这一招把 Day 49~50 学的「评测」从上线前搬到了上线后——评测不是一次性的,是天天做的体检。
🔗 想看真实系统怎么把「护栏 + 可观测」工程化?去看《gov-agents 多 Agent 框架教程》→ 学完回来继续 Day 55
L06

现成工具:LangSmith / Langfuse 帮你自动记账

🤔 痛点前面那些 trace_id、span、指标聚合,难道每个项目都自己手搓一遍?工作量巨大,还容易漏。
💡 本质业界已经有专门的 LLM 可观测平台:你只要接一下,它就自动帮你记录每次调用的 prompt、回答、token、延迟,画好 trace 树和指标看板。就像给车装一套「原厂行车记录仪 + 云端仪表盘」,不用自己焊线。两个常见的:LangSmith(LangChain 官方,和 LangGraph 无缝)、Langfuse(开源,可自己部署,不绑框架)。
工具特点适合谁
LangSmithLangChain/LangGraph 官方,接入近乎零成本,trace 树漂亮已经在用 LangChain 生态的人
Langfuse开源、能自己部署(数据不出门)、不绑定框架想自托管、对数据合规敏感的团队
OpenTelemetry通用可观测标准,不止 LLM,能对接各种后端公司已有统一监控体系
# 以 Langfuse 为例,接入常常只要一个「装饰器」——套在函数上就自动记 trace
from langfuse.decorators import observe   # 概念示意,实际以官方文档为准

@observe()                    # 加这一行,这次调用的耗时/输入/输出就被自动记录
def answer(question: str) -> str:
    # ... 你原本的 Agent 逻辑,一行不用改 ...
    return "生成的回答"

answer("退货政策是什么?")     # 跑完,Langfuse 面板里就能看到这条 trace
具体的 API 名、参数会随版本变,别背——记住「接一个 SDK/装饰器 → 自动上报 → 上平台看 trace 和指标」这个套路就够了。面试被问「你们怎么做 LLM 可观测」,答「结构化日志 + LangSmith/Langfuse 记 trace + 监控 token/延迟/错误率/质量漂移」就是满分。
📝 举个例子:接了平台后你能一键回答 「用户 u_123 昨晚 10 点那次为啥答错?」→ 搜 trace,看到检索只召回了 1 条无关文档 → 定位到是知识库切分问题。从「查不到」到「点几下就定位」,这就是接工具的回报。
L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • 可观测的三大支柱是什么?(日志、trace、指标)各自看什么?
  • 为什么 print 不如结构化日志?一条好日志该带哪些字段?
  • trace 和 span 是什么关系?trace_id 起什么作用?
  • Agent 最该监控哪四个指标?为什么看延迟要用 P95 而不是平均值?
  • 「输出漂移」是什么?错误率正常为什么还要担心质量?怎么盯它?
  • LangSmith / Langfuse 帮你省了什么活?

✋ 动手 10 分钟:给一次调用打一条完整的结构化日志

新建 day55.py,把「trace_id + span 计时 + token/延迟 + 成败」串成一条像样的日志(不接任何平台,先手搓一遍找感觉):

import uuid, time, json, logging, random
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("agent")

def call_agent(question):
    trace_id = str(uuid.uuid4())[:8]     # 本次请求身份证
    t0 = time.time()
    ok = random.random() > 0.1           # 假装 10% 概率失败
    time.sleep(random.uniform(0.2, 1.5)) # 假装在干活,随机耗时
    latency_ms = int((time.time() - t0) * 1000)
    log.info(json.dumps({                # 一条结构化日志:谁、干了啥、花多少、成不成
        "trace_id": trace_id,
        "event": "answer",
        "question": question,
        "input_tokens": random.randint(200, 900),
        "output_tokens": random.randint(50, 300),
        "latency_ms": latency_ms,
        "ok": ok
    }, ensure_ascii=False))
    return ok, latency_ms

# 跑 20 次,再自己算错误率和 P95,体会「指标」是怎么从日志聚合出来的
results = [call_agent(f"问题{i}") for i in range(20)]
err_rate = sum(1 for ok, _ in results if not ok) / len(results)
lat_sorted = sorted(ms for _, ms in results)
p95 = lat_sorted[int(len(lat_sorted) * 0.95) - 1]
print(f"错误率 = {err_rate:.0%}   P95 延迟 = {p95}ms")

加分题:把这 20 条日志重定向到文件 python day55.py > app.log,再用 grep '"ok": false' app.log 把失败的挑出来——体会结构化日志「可检索」的好处。

明日预告 · Day 56:今天你学会了「看见」Agent 花了多少 token。明天(Day 56)接着解决「怎么少花钱」——成本治理 & 缓存:把账算清楚(预算归因,谁花的)、贵活派给贵模型便宜活派给便宜模型(模型路由)、重复问题直接查缓存不再花钱(prompt/结果缓存)、能攒一批一起发的用批处理省钱。可观测是「看表」,成本治理是「省钱」,连起来才是线上运维的完整闭环。
← Day 54 · 失败闸门 & 护栏 Day 56 · 成本治理 & 缓存 →