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. 自测
- Agent 的失败为什么很难用日志定位?
- run 树里的
parent_run_id用来还原什么? - 只开环境变量上报,trace 里会缺哪一类节点?
- 把线上 trace 存成数据集时,参考答案能不能直接用模型输出?
- trace 默认记录完整 prompt,这会带来哪类风险,脱敏放在哪一层?
- 什么情况下该选自建 OTel 而不是托管平台?