Administrator
发布于 2025-10-05 / 2116 阅读
24

Agent 应用的可观测性与调试困境

同一个问题跑五次,得到三种不同的结果

九月初用户反馈我们的运维 Agent 有时会给出错误的重启建议——明明是磁盘满了,它建议重启服务。我们拿到 session ID 去复现,同一个问题、同一份上下文,跑了五次:

次数结论步数耗时token
1磁盘满,建议清理日志(正确)418s21,400
2建议重启服务(错误)27s9,800
3磁盘满,建议清理日志(正确)419s22,100
4无法判断,请求更多信息631s38,700
5磁盘满,建议清理日志(正确)524s26,300

成功率 60%,而且失败的那次只用了 2 步——它压根没去查磁盘。传统的调试方法在这里全部失效:没有固定的代码路径,没有必现的 bug,断点打在哪里都不知道。

这篇讲我们怎么给一个非确定性系统建立可观测性。

先接受一个前提:不能只看输入输出

我们原来的日志是这样的:

2025-09-03 14:22:11 INFO  AgentService - 任务开始 taskId=T-8821
2025-09-03 14:22:13 INFO  AgentService - 调用工具 query_metrics
2025-09-03 14:22:16 INFO  AgentService - 调用工具 query_logs
2025-09-03 14:22:29 INFO  AgentService - 任务完成 taskId=T-8821 duration=18.2s

知道它调了什么,但不知道它为什么调。第 2 次失败的那次,日志显示它只调了一个 query_metrics 就下结论了。为什么?日志里完全没有线索。

非确定性系统的调试核心是:必须记录完整的决策上下文,而不只是动作序列。具体到 Agent,就是每一步模型「看到了什么」和「输出了什么」的完整原文。

把每一步做成 span

我们用 OpenTelemetry,但做了扩展。一个 Agent 任务的 trace 结构:

agent.run                                    [28.4s]
├─ agent.step.1 plan                         [3.1s]
│  ├─ llm.chat qwen-max                      [2.9s]
│  └─ tool.parse                             [0.2s]
├─ agent.step.2 act                          [8.7s]
│  ├─ llm.chat qwen-max                      [3.4s]
│  └─ tool.exec query_metrics                [5.2s]
│     └─ http.client cmdb                    [4.9s]
├─ agent.step.3 act                          [6.2s]
│  ├─ llm.chat qwen-max                      [2.8s]
│  └─ tool.exec query_logs                   [3.3s]
└─ agent.step.4 summarize                    [10.1s]
   └─ llm.chat qwen-max                      [10.0s]

光有这个结构还不够,关键是每个 llm.chat span 上挂的属性。这是我们的必填清单:

span.setAttribute("llm.model", "qwen-max");
span.setAttribute("llm.temperature", 0.2);
span.setAttribute("llm.top_p", 0.8);
span.setAttribute("llm.seed", seed);                    // 见下文
span.setAttribute("llm.prompt.hash", sha256(prompt));   // 用于对比
span.setAttribute("llm.prompt.tokens", 12400);
span.setAttribute("llm.completion.tokens", 380);
span.setAttribute("llm.finish_reason", "stop");
span.setAttribute("llm.request.id", vendorRequestId);   // 厂商侧 ID,工单必备
span.setAttribute("gen_ai.system", "qwen");
span.setAttribute("gen_ai.agent.step_index", 2);
span.setAttribute("gen_ai.agent.step_type", "act");

属性命名我们尽量遵循 OTel 的 GenAI 语义约定(gen_ai.*),这样以后换观测平台不用重做。厂商自己的字段用 llm.* 前缀区分。

prompt 和 completion 的原文:存不存、怎么存

这是最纠结的部分。要调试就必须看到原文,但原文有三个问题:体积大(单次 prompt 12 KB,日均 40 万次调用就是 4.8 GB/天)、敏感(用户数据、内网 IP)、以及成本。

我们的方案是分层存储 + 采样

数据存储位置保留期采样率
结构化属性(token、耗时、模型)OTel → 时序库30 天100%
prompt/completion 原文对象存储(按 traceId 索引)14 天失败 100%,成功 5%
工具调用参数和结果PostgreSQL90 天100%
模型原始输出(未解析)对象存储14 天失败 100%,成功 2%

触发失败采样的条件包括:任务返回错误、用户点了踩、步数超过阈值、token 超过阈值、人工确认被拒绝。这些是我们真正需要复盘的场景。

实现上,采样决策不能在请求开始时就做(那时候还不知道会不会失败),所以我们先把原文写到本地缓冲区,请求结束后再决定是否落盘:

public class DeferredTraceWriter {

    private final Map<String, TraceBuffer> pending = new ConcurrentHashMap<>();
    private final ScheduledExecutorService flusher =
            Executors.newSingleThreadScheduledExecutor();

    public void record(String traceId, String prompt, String completion) {
        pending.put(traceId, new TraceBuffer(prompt, completion));
    }

    public void onFinish(String traceId, TaskResult result) {
        TraceBuffer buf = pending.remove(traceId);
        boolean keep = shouldKeep(result);
        // 关键:采样决策在这里做,但 buffer 里已经有完整数据
        if (keep) {
            storage.put("traces/" + traceId + ".json", buf.toJson());
        }
        // 未保留的,仅把 hash 和结构信息发给 OTel
        metrics.record(traceId, buf.hash(), buf.tokenCount(), result.status());
    }

    boolean shouldKeep(TaskResult r) {
        return r.isError()
            || r.stepCount() > 20
            || r.totalTokens() > 80_000
            || r.hasHumanRejection()
            || r.isUserThumbDown()
            || ThreadLocalRandom.current().nextDouble() < 0.05;
    }
}

内存开销是可控的——同时在进行中的任务最多几百个,每个几十 KB,峰值不到 100 MB。

存储成本:失败 + 5% 成功采样,日均 210 MB,一个月 6.3 GB,对象存储费用可以忽略。

seed:让非确定性变成半确定性

上面这套解决了「事后能查」,但解决不了「无法复现」。我们加了 seed 支持。

大部分推理服务支持 seed 参数(qwen 和 DeepSeek 都支持,OpenAI 也支持但只保证 best-effort)。传入相同 seed 且其他参数一致时,输出在大多数情况下可复现。

long seed = isReplayMode
    ? replaySeed                                  // 复现模式用指定 seed
    : ThreadLocalRandom.current().nextLong();

ChatOptions opts = ChatOptions.builder()
        .model("qwen-max")
        .temperature(0.2)
        .topP(0.8)
        .seed(seed)                               // 记录到 span 和 DB
        .build();

有了 seed,我们做了个「复现」按钮:输入 traceId,系统取出当时的完整 prompt、参数、seed,重新跑一遍。实测复现成功率约 78%——不是 100%,因为厂商的负载均衡、批处理调度会引入额外不确定性,但已经从「完全无法复现」变成了「大概率能复现」。

这个能力上线后,我们处理线上问题反馈的平均时间从 2.4 天降到 4 小时。最大的变化是不用再跟用户说「麻烦您再描述一下当时的情况」。

顺带说一句:seed 不是万能的。厂商文档里一般写「best effort」,而且模型版本更新后同样的 seed 输出会变。所以用它做回归测试要谨慎,我们只在调试时用。

决策过程记录:让模型自己说为什么

光看 prompt 和 completion 有时候还是不够。模型内部为什么会选这个工具,从输出里只能看到结果看不到理由。我们加了一个轻量的「思考日志」要求:

// 在系统提示词里要求结构化输出决策理由
String SYSTEM = """
    ...
    每次调用工具前,必须先用以下格式输出你的判断:
    <reasoning>
    当前已知:[已经确认的事实]
    信息缺口:[还缺什么]
    选择工具:[工具名]
    选择理由:[为什么是这个工具,一句话]
    预期获得:[希望从这个工具得到什么]
    </reasoning>
    """;

这段 reasoning 我们会在工具执行前解析出来,作为当前 span 的属性记录:

span.setAttribute("agent.reasoning.known", parsed.known());
span.setAttribute("agent.reasoning.gap", parsed.gap());
span.setAttribute("agent.reasoning.why_this_tool", parsed.reason());
span.setAttribute("agent.reasoning.expected", parsed.expected());

这个改动带来了几个意外收益:

  • 调试时一眼能看出模型当时掌握的信息,不用去反推。上面那个「只调了 query_metrics 就下结论」的 case,reasoning 里写的是「当前已知:服务响应变慢。信息缺口:无」,说明它误以为信息已经足够,问题出在规划阶段而不是执行阶段;
  • 可以在监控里统计「信息缺口」字段的高频词,反过来优化工具集。我们发现「缺少历史故障记录」出现了 340 次,于是加了故障库检索工具;
  • 准确率提升了约 6 个百分点。要求模型显式陈述已知和未知,本身就是一种 prompt 层面的 grounding。

代价是每次多消耗约 120 个输出 token,占总成本的 2.8%。

有了数据之后,我们看到了什么

跑了一个月,从 24 万次任务里统计出来的几个有意思的数字:

现象数据
同一 prompt 不同结果的比例34%(pairwise 对比 500 组)
失败任务中「规划阶段就错了」的比例61%
失败任务中「工具调用出错」的比例17%
失败任务中「总结阶段出错」的比例22%
工具被重复调用(同参数)的比例8.3%
步数 > 20 的长尾任务占比3.1%(消耗 28% 的成本)

「规划阶段就错了」占 61% 这个数字改变了我们的优化方向。之前我们一直在优化工具的实现质量(超时、重试、结果格式),但这些只影响 17% 的失败。真正的瓶颈在第一步规划——模型对用户意图的理解、对可用工具的判断。

基于这个发现,我们做了两件事:一是把系统提示词里工具描述的篇幅扩大了一倍(加「什么时候不该用」的反例),二是给规划步骤单独换了更强的模型和更低的 temperature(0.2 → 0.05)。整体成功率从 71% 提到 83%。

监控面板怎么设计

Agent 的监控面板和普通服务完全不同。我们的面板分四块:

  1. 健康度:任务成功率、平均步数、平均 token、平均耗时(按小时,对比昨天同期)
  2. 成本:token 消耗趋势、按模型拆分、单任务成本 P50/P95/P99、异常消耗告警
  3. 工具维度:每个工具的调用次数、成功率、P99 耗时、被拒率
  4. 漂移检测:同一批标准测试问题每天跑一次,看成功率有没有变化

第四块最重要也最容易被忽略。Agent 的效果会悄悄退化——模型厂商静默更新、知识库内容变化、工具接口调整,任何一个都可能导致效果下降,而且不会有任何报错。

我们有个 200 题的回归集,每天凌晨跑一遍,成功率画成折线图。八月底那条线从 84% 掉到 76%,查下来是某天知识库同步脚本挂了,有一批文档没更新进去。如果只看错误率监控,这个永远不会报警。

先到这

《Agent 应用的可观测性与调试困境》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考