同一个问题跑五次,得到三种不同的结果
九月初用户反馈我们的运维 Agent 有时会给出错误的重启建议——明明是磁盘满了,它建议重启服务。我们拿到 session ID 去复现,同一个问题、同一份上下文,跑了五次:
| 次数 | 结论 | 步数 | 耗时 | token |
|---|---|---|---|---|
| 1 | 磁盘满,建议清理日志(正确) | 4 | 18s | 21,400 |
| 2 | 建议重启服务(错误) | 2 | 7s | 9,800 |
| 3 | 磁盘满,建议清理日志(正确) | 4 | 19s | 22,100 |
| 4 | 无法判断,请求更多信息 | 6 | 31s | 38,700 |
| 5 | 磁盘满,建议清理日志(正确) | 5 | 24s | 26,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% |
| 工具调用参数和结果 | PostgreSQL | 90 天 | 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 的监控面板和普通服务完全不同。我们的面板分四块:
- 健康度:任务成功率、平均步数、平均 token、平均耗时(按小时,对比昨天同期)
- 成本:token 消耗趋势、按模型拆分、单任务成本 P50/P95/P99、异常消耗告警
- 工具维度:每个工具的调用次数、成功率、P99 耗时、被拒率
- 漂移检测:同一批标准测试问题每天跑一次,看成功率有没有变化
第四块最重要也最容易被忽略。Agent 的效果会悄悄退化——模型厂商静默更新、知识库内容变化、工具接口调整,任何一个都可能导致效果下降,而且不会有任何报错。
我们有个 200 题的回归集,每天凌晨跑一遍,成功率画成折线图。八月底那条线从 84% 掉到 76%,查下来是某天知识库同步脚本挂了,有一批文档没更新进去。如果只看错误率监控,这个永远不会报警。
先到这
《Agent 应用的可观测性与调试困境》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。