2755 字
约 9 分钟
17
S27-X05 · LangSmith 与 Agent 可观测性平台

Agent 出问题,通常不抛异常。它只是"答得不对":检索召回了一篇无关文档、工具参数拼错了一个字段、第三步该停下来却继续调用、同一句话被重试了四次。这些现象在日志里全是 INFO 级别,在监控面板上全是绿的。

这类系统需要的不是日志,而是轨迹——一次请求里每一步的输入、输出、耗时、决策依据,按调用树串起来。本文讨论的就是这类平台(以 LangSmith 为代表)该回答什么、怎么接、什么时候别用。


1. Agent 调试为什么难

难点 具体表现 传统手段为什么不够
多步 一次问答背后 5–20 次模型/工具调用 日志是线性的,看不到父子关系
非确定 同样的输入两次结果不同 复现不了就无法回归验证
外部依赖多 向量库、搜索、业务接口都在链路里 一个接口超时会伪装成"模型变笨"
输出是自然语言 没有断言可写 单元测试的 assert 在这里失效
成本隐蔽 token 花在重试和长上下文上 只看账单总额定位不到具体步骤

2. 平台要回答的三个问题

问题 对应数据 缺失时的症状
这一步的输入到底是什么? 每次调用的完整 prompt / messages 猜"是不是检索没给对内容"
为什么这么选? 模型输出、工具名与参数、中间推理 只能看最终答案,无法归因
耗时与成本花在哪? 每步 latency、token 数、调用次数 优化方向全靠直觉

三个问题合起来,就是对一条 run 树的完整记录。

3. 追踪模型:run 树与 span

一次请求是一个 root run,每次模型调用、工具调用、检索是一个 child run。父子关系靠 parent_run_id 串起来,形成一棵树:

{
  "name": "agent.run",
  "run_type": "chain",
  "inputs": { "question": "退款政策多久到账" },
  "outputs": { "answer": "..." },
  "child_runs": [
    { "name": "retriever.search", "run_type": "retriever",
      "inputs": { "query": "退款 到账 时效" },
      "outputs": { "docs": ["doc_17", "doc_42"] },
      "latency_ms": 42 },
    { "name": "llm.chat", "run_type": "llm",
      "inputs": { "messages": ["..."] },
      "outputs": { "content": "调用工具 refund_status" },
      "usage": { "input_tokens": 812, "output_tokens": 96 } }
  ]
}
字段 含义 排查时的用法
run_type chain / llm / tool / retriever / embedding 快速定位是哪一层慢
inputs / outputs 该步的完整进出参 判断"是不是输入就错了"
parent_run_id 父节点 还原调用树,看是否重复调用
start_time / end_time 起止时间 算串行/并行真实耗时
usage token 统计 定位成本大头
error 异常信息 区分"模型答错"与"工具报错"
tags / metadata 自定义标签 按租户、版本、渠道切片

4. 接入:两种粒度

最省事的接入是环境变量,SDK 会自动上报所有模型调用:

export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="<YOUR_API_KEY>"
export LANGSMITH_PROJECT="agent-prod"
# 旧版本用的是 LANGCHAIN_TRACING_V2 / LANGCHAIN_PROJECT,按当前版本核对

这只是第一步。自动上报只覆盖 SDK 认识的调用,你自己的检索函数、业务规则、后处理都不在树里,缺了它们就还原不了"为什么这么选"。这部分要靠装饰器补:

from langsmith import traceable

@traceable(run_type="retriever", name="kb.search")
def search(query: str, top_k: int = 8):
    # 自己实现的检索:向量库 + 关键词 + 合并
    return vector_search(query, top_k) + bm25_search(query, top_k)

@traceable(run_type="tool", name="refund_status")
def refund_status(order_id: str):
    return client.get(f"/api/refund/{order_id}").json()

装饰器的价值在于它把"人写的代码"和"模型的调用"放进了同一棵树。没有这一层,trace 里只有 LLM 节点,看起来像"模型自己凭空选了工具"。

接入顺序建议:先只开自动上报跑一周 → 看树缺哪些节点 → 再补装饰器。一上来全量埋点,往往埋了一堆没人看的 span。

5. 数据集与评测

追踪解决"这一次为什么错",评测解决"改完是不是整体变好了"。两者的连接点是把线上 trace 直接沉淀成数据集:

from langsmith import Client, evaluate

client = Client()

# 1) 从线上挑样本,人工确认后存成数据集
ds = client.create_dataset("refund-faq")
for run in client.list_runs(project_name="agent-prod", filter='eq(name, "agent.run")'):
    client.create_example(
        inputs=run.inputs,
        outputs={"answer": run.outputs["answer"]},   # 先当参考答案,再由人校
        dataset_id=ds.id,
    )

# 2) 回放:对数据集逐条跑目标函数
def predict(inputs: dict) -> dict:
    return {"answer": graph.invoke(inputs)["answer"]}

results = evaluate(predict, data="refund-faq", evaluators=[exact_match, llm_judge])
环节 要点 常见错误
采样 按失败类型分层抽,不要随机抽 全抽"看起来正常"的样本
标注 参考答案必须人工校过 把模型输出直接当标准答案
评测器 规则能判就别用模型判 全用 LLM 打分,评测本身不稳定
基线 每次改动都跑同一份数据集 换数据集后对比"变好了"没有意义

6. 反馈与标注

线上真实反馈是最贵的信号:用户点了"没用"、客服改了答案、人工标注了正确工具。这些要能回流到同一条 trace 上,而不是散在工单系统里。

client.create_feedback(
    run_id=run_id,
    key="correctness",
    score=0.0,
    comment="应该调用 refund_status 而不是 order_detail",
)

回流之后,按 feedback.score 过滤再抽数据集,评测集就自然覆盖了真实失败模式。

7. 成本与 token 统计

统计维度 从哪来 用途
单次请求 token 每个 llm run 的 usage 找出最贵的那一步
按步骤类型汇总 run_type 分组 判断成本在检索还是生成
按版本对比 metadata.version 标签 新版是否更贵
重试成本 同一 parent 下重复的 child 重试策略是否在烧钱
缓存命中 检索/embedding 命中率 有没有白花

一个常被忽略的事实:长上下文 Agent 的成本大头往往在"历史消息全量重发",而不是最后一次生成。按步骤汇总 token,这类问题一眼就能看出来。

8. 自建对照:OTel + 自建存储

维度 托管平台 OpenTelemetry 自建
接入成本 装 SDK、配 key 接 OTel SDK + 后端(Tempo/Jaeger + 对象存储)
语义模型 为 LLM 场景设计(prompt/usage/feedback) 通用 span,LLM 语义要自己约定
评测/数据集 内置 需要自己搭
数据出境 取决于厂商区域 完全自控
成本 按量/按席位 存储与运维成本
定制 受平台约束 随便改

判断口径:如果 trace 里含不可外发的业务数据,或者需要和内部告警/权限体系深度集成,自建更合适;如果只是想让团队快速看清 Agent 在干什么,托管平台的语义模型省下的时间更多。

9. 敏感数据与脱敏

trace 默认记录的是完整 prompt,包括用户原话、检索到的文档正文、系统提示词。这可能比日志危险得多,因为日志通常不打全量业务数据。

风险点 处理方式
用户 PII(手机号、身份证) 上报前正则替换,或只上报哈希
系统提示词(含内部规则) 用 hide_inputs / 自定义序列化隐藏
检索到的文档正文 只上报文档 id 与分数,正文留在本地
工具返回的业务数据 白名单字段,其余脱敏
密钥误入 prompt 在出口做一次模式扫描并阻断
import re
from langsmith import traceable

PII = re.compile(r"1[3-9]\d{9}|\d{17}[\dXx]")

def redact(text: str) -> str:
    return PII.sub("[REDACTED]", text)

@traceable(name="llm.call", process_inputs=lambda msgs: redact(str(msgs)))
def call_llm(msgs):
    ...

脱敏要放在上报出口而不是业务逻辑里,否则很容易在某条新链路上漏掉。


10. 排错:六种接入后的怪现象

现象 原因 处理
trace 里只有 LLM,没有工具 自定义函数没加装饰器 补 @traceable,确认装饰在真正被调用的函数上
完全不上报 环境变量名与当前版本不一致,或异步进程没继承环境 打印进程内环境变量核对,注意 worker/subprocess
树是平的,所有节点同级 异步并发丢了 context 传 parent 显式指定,或用平台的 context 传播机制
上报量突然暴涨 循环里每步都上报 + 重试放大 采样率、合并细碎 span
评测结果和线上差很远 生产数据直连评测,分布被污染 清洗后固定一份回归集
时间戳乱序 客户端与服务器时钟偏差 统一 NTP,用平台侧时间排序

11. 边界:什么时候不值得上

场景 更合适 理由
单次调用、无工具、无检索 普通日志 + 结构化字段 trace 树的收益接近零
离线批处理、结果可离线核对 文件级日志 + 评测脚本 不需要实时观测
高频低价值请求(每秒上千) 采样后只留异常 全量上报的成本会超过收益
合规要求数据不出内网 自建 OTel + 内部存储 托管平台的便利换不来合规
团队还没定评测口径 先把评测集做出来 没有评测的 trace 只能"看个热闹"

一句话定位:追踪回答"这一次发生了什么",评测回答"整体有没有变好"。只做前者会陷入逐案 debug,只做后者会不知道为什么变差。


12. 自测

  1. Agent 的失败为什么很难用日志定位?
  2. run 树里的 parent_run_id 用来还原什么?
  3. 只开环境变量上报,trace 里会缺哪一类节点?
  4. 把线上 trace 存成数据集时,参考答案能不能直接用模型输出?
  5. trace 默认记录完整 prompt,这会带来哪类风险,脱敏放在哪一层?
  6. 什么情况下该选自建 OTel 而不是托管平台?
S27-X05 · LangSmith 与 Agent 可观测性平台
https://www.clxhxhhr.top/posts/5778/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。