你不能改你看不见的东西。Agent 工程里这句话字面成立

写过 Agent 的人都知道一个痛:Agent 跑歪了,你打开日志,看到几十轮 messages、几十次 tool_call,不知道到底是哪一步开始偏的。一次失败的 Agent 调试,可能花你一下午。

这不是调试能力问题,是可观测性缺失问题。Agent 比传统程序难调试得多——输出非确定、路径不固定、单次运行的 trace 很长、失败原因可能在 10 步之前埋的。靠 print 和 log 文件已经不够。这一篇讲清楚 Agent 工程里的可观测性栈:trace、replay、eval

Trace:把 Agent 的每一步变成可检索的数据

trace 是 Agent 调试的基础设施。它把"这一次 Agent 跑"的每一步——每次 LLM 调用、每次工具执行、每次状态更新——都记录成结构化数据,可以按时间、按任务、按失败类型检索。

一个最简 trace 长这样:

import time, uuid, json

class Trace:
    def __init__(self, task_id: str):
        self.task_id = task_id
        self.spans = []

    def start_span(self, name: str, inputs: dict = None):
        span = {
            "id": str(uuid.uuid4()),
            "name": name,
            "inputs": inputs,
            "start_time": time.time(),
        }
        self.spans.append(span)
        return span

    def end_span(self, span, outputs: dict = None, error: str = None):
        span["end_time"] = time.time()
        span["outputs"] = outputs
        span["error"] = error

    def save(self):
        with open(f"traces/{self.task_id}.json", "w") as f:
            json.dump({"task_id": self.task_id, "spans": self.spans}, f, indent=2, default=str)

在 Agent 的关键路径上埋 span:

async def run(task: str):
    trace = Trace(task_id=str(uuid.uuid4()))
    root = trace.start_span("agent_run", {"task": task})

    messages = [...]
    for step in range(10):
        # LLM 调用 span
        llm_span = trace.start_span(f"llm_call_step_{step}", {"messages": messages})
        try:
            resp = await client.chat.completions.create(...)
            trace.end_span(llm_span, outputs={"message": resp.choices[0].message.model_dump()})
        except Exception as e:
            trace.end_span(llm_span, error=str(e))
            raise

        # 每个 tool call 一个 span
        for tc in resp.choices[0].message.tool_calls or []:
            tool_span = trace.start_span(f"tool_{tc.function.name}",
                                          inputs=json.loads(tc.function.arguments))
            try:
                result = await run_tool(tc)
                trace.end_span(tool_span, outputs={"result": result})
            except Exception as e:
                trace.end_span(tool_span, error=str(e))
                result = f"Error: {e}"

    trace.end_span(root)
    trace.save()

保存的 JSON 长这样:

{
  "task_id": "abc123",
  "spans": [
    {"name": "agent_run", "inputs": {"task": "..."}, "start_time": ...},
    {"name": "llm_call_step_0", "inputs": {...}, "outputs": {"message": {...}}},
    {"name": "tool_get_weather", "inputs": {"city": "北京"}, "outputs": {"result": "15 度"}},
    {"name": "llm_call_step_1", ...},
    ...
  ]
}

有了结构化 trace 后,调试 Agent 就变成了翻 trace。找失败的 span、看那之前的 LLM 输出、找出哪里开始偏。一次调试从几小时压缩到几分钟。

生产的 Trace 工具

自己写 trace 能跑但不够。生产 Agent 通常用现成工具,几个主流的:

Langfuse——开源、自托管或云托管。对 OpenAI / Anthropic / LangChain / LlamaIndex 都有集成。可视化做得非常好——时间线视图、嵌套 span、token/成本统计、按 session 聚合。如果你要自己搭 Agent 平台,Langfuse 是目前的默认选项

LangSmith (LangChain 出品)——商业化 SaaS,和 LangChain / LangGraph 集成最深。做 Prompt 管理、eval、标注都很强。适合已经用 LangChain 栈的团队。

OpenTelemetry + OpenLLMetry——把 LLM 调用变成 OpenTelemetry 的 standard span。适合已经有 Datadog / Grafana / Honeycomb 之类 APM 基础设施的团队,把 Agent trace 融入现有观测体系。

Helicone——专注 LLM 代理和成本可视化。轻量,接入简单。

实务上经验是中小团队用 Langfuse,已经有 APM 栈的用 OpenLLMetry,重度用 LangChain 的用 LangSmith

埋点的关键信号

除了 LLM 调用和 tool call,有几个信号值得专门记录,让 trace 更有价值:

token 使用——每次 LLM 调用的 prompt tokens 和 completion tokens。是成本分析和上下文膨胀诊断的基础。

工具失败类型——tool 异常要打分类标签:timeout / invalid_args / permission_denied / external_error。按标签聚合能快速发现"哪类工具失败率高"。

Agent 决策的"理由"——如果你用 ReAct 式 Thought,把每一步的 Thought 单独记录,不只是塞在 messages 里。调试时按"Thought 包含 XX"做检索很有用。

上下文大小演化——每一步 messages 的总 token 数。上下文膨胀是 Agent 常见失败的前兆。

任务元数据——task_id、user_id、session_id、Agent 版本。按用户、按会话聚合 trace 时用。

Replay:从 trace 重放

Trace 的高级用法是Replay——从一个已记录的 trace 重新跑 Agent,验证"如果我改了 prompt、换了模型,在同样的输入下结果会怎样"。

实现思路:

async def replay(trace_id: str, modifications: dict = None):
    trace = load_trace(trace_id)
    initial_messages = trace.spans[0].inputs["messages"]

    # 应用修改(换模型、改 system prompt 等)
    if modifications:
        initial_messages = apply_mods(initial_messages, modifications)

    # 重新跑 Agent,但 tool_call 不真的执行——从 trace 里取记录的结果
    recorded_tool_results = {
        s["inputs"]["tool_call_id"]: s["outputs"]["result"]
        for s in trace.spans if s["name"].startswith("tool_")
    }

    new_trace = await run_agent(initial_messages, tool_executor=MockExecutor(recorded_tool_results))
    return diff(trace, new_trace)

为什么这有用?

调 Prompt——改了 system prompt,不用真跑 10 个线上任务验证,replay 10 条历史 trace 就能看新 prompt 下哪些任务会 regression。

换模型——从 gpt-4o 换到 gpt-5 或 claude-opus,用同一批历史任务跑 replay,对比新旧结果的质量。

复现 bug——用户说某任务跑错了,给 trace,你本地 replay,能精准重现那次失败。

Replay 的关键是tool 不真执行,用 trace 记录的结果。这样同一次 replay 能复现,不依赖外部服务的实时状态(数据库可能变了、网页可能改了、时间可能不同了)。

Eval:给 Agent 打分

Trace 告诉你"发生了什么",Eval 告诉你"这次跑得好不好"。Agent 的 eval 比一次性 LLM 的 eval 复杂得多,因为要评估的是整个多步交互的最终效果

几种 eval 策略:

任务完成率——针对一批 golden 任务,人工标注"成功标准",Agent 跑完后自动检查。例如:"订机票任务"的成功标准是"最终确认页出现"。这种精确但难覆盖复杂任务。

LLM-as-judge——跑完后让另一个 LLM(通常更强的模型)判断"这个 Agent 的最终输出达成了用户目标吗?",给分 1-5。便宜、可扩展,但有 bias。

async def judge(task: str, agent_output: str, trace: dict) -> dict:
    resp = await openai.chat.completions.create(
        model="o4",
        messages=[{"role": "system", "content": """你是严格的评审。
判断 Agent 的最终输出是否达成了用户的任务目标。
按以下维度打分(1-5):
- task_completion: 目标达成度
- correctness: 信息正确度
- efficiency: 步骤合理性(步骤是否过多或过少)
- format: 格式符合度
返回 JSON"""},
            {"role": "user", "content": f"任务: {task}\n输出: {agent_output}\ntrace 摘要: {summarize_trace(trace)}"}],
        response_format={"type": "json_object"},
    )
    return json.loads(resp.choices[0].message.content)

轨迹 eval——不只评最终输出,评中间过程。"Agent 调用了合适的工具吗?""有没有走不必要的弯路?""Thought 的推理清晰吗?" 更细但也更复杂。

对比 eval——两个 Agent 版本跑同一批任务,让 judge 判断"哪个输出更好"。pairwise 比 absolute 打分稳定得多。Chatbot Arena 就是这个原理。

回归测试集:Agent 的 CI

要做一个持续在生产跑的 Agent,必须有回归测试集——一批 golden 任务,每次改 prompt / 模型 / 工具前都跑一遍,对比新旧结果。

构建经验:

从真实失败案例挖。线上跑失败的任务、用户差评的任务、客服处理过的 Agent 问题,都是测试集素材。每周从线上捞 5~10 个补充进测试集。

分层。简单任务(检查基线能力)、中等任务(检查主要场景)、难任务(检查极限)。新版本在简单任务上不应回退,在难任务上可以接受偶尔回退但要有合理解释。

边界和对抗。故意加刁钻任务:工具故障、用户输入含 prompt injection、需要拒答的敏感请求。

测试集一开始可以小(30~50 个任务)。每条带 golden output 或 judge prompt,跑一次看通过率。新版本发布前跑全量,通过率回退超过阈值(比如 5%)就不允许发。

这套流程和传统软件的 CI 几乎一致,只是用例更难写、判断更模糊。有了它,Agent 从"手感调"变成"工程化迭代"。

可观测性驱动的改进循环

把前面所有加起来,一个成熟 Agent 团队的工作流是:

线上 Agent → Langfuse trace → 每周分析失败 trace →
补进测试集 → 改 prompt / 工具 → 本地 replay 验证 →
测试集回归 → 部署 → 循环

每个环节的工具都有相对成熟的选项,但真正的挑战在于把这个循环跑起来、跑顺。大多数团队卡在"捞失败 trace 太麻烦"或"每次都手动判断新版本好不好"这种操作细节。

如果这一篇你只带走一条,我希望是这条:先埋 trace,再优化 Agent。没 trace 时改 Agent 是蒙着眼睛开车;有 trace 后每一次改动都是数据支撑的决策。

小结

Agent 工程和传统软件工程最大的差别就在可观测性——非确定性让每一次调试都更复杂,传统的 log + breakpoint 远远不够。Trace、Replay、Eval 构成 Agent 工程的三个支柱。用 Langfuse 之类的工具把它们搭起来,然后专注在内容上。

下一篇是这个系列的工程篇高潮——生产化。上下文压缩、成本优化、延迟优化、失败恢复、安全边界,把前面所有篇讲过的东西收拢成"Agent 上生产要做的工程"。

相关阅读

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:16. 可观测性与评测:Agent 的 trace、replay 与回归测试

本文链接:https://www.sshipanoo.com/blog/ai/ai-agent/16-可观测性与评测/

本文最后一次更新为 天前,文章中的某些内容可能已过时!