你的Agent每次调用花了多少钱?我手写了一个Token追踪器,发现了3个意外

上周我的Agent跑了一整晚的任务链–5个模型轮番上阵,42次API调用,第二天醒来一看账单,懵了。花了多少钱?哪个模型最慢?输出质量有没有退化?全不知道。

这不是我一个人的问题。所有在生产环境跑Agent的人,迟早会碰到同一个坑:你的Agent在烧钱,你却连账本都没开。

踩完这些坑,我总结出一条铁律:生产级Agent必须有三层监控–Token追踪(花钱账本)+ 质量预警(健康体检)+ 调用链可视化(病历档案)。缺任何一层,你都是在盲人摸象。

这篇文章从Week6的4个AtomicInteger计数器开始,一路走到Week7的生产级可观测性四件套。全Java手写,零依赖,控制台直接看。每一步踩的坑、发现的意外,我都原样写出来。

第一部分:Token追踪器–你的Agent烧了多少钱?

Week6的时候,我对Token的监控就是4个AtomicInteger–输入Token数、输出Token数、总调用数、总Token数。简单粗暴,能跑就行。

但当你真正跑起多模型Agent,这玩意儿就不够用了。GLM-4和GLM-5.1的定价不一样,你混着用,一个AtomicInteger怎么可能告诉你哪个模型烧钱多?更别说成本了–我光知道用了10000个Token,但到底是花了0.5元还是5元?不知道。

所以Week7我把计数器升级成了ProductionTokenTracker,改了三个地方:

1. 微元整数存成本,不碰double

这是个容易被忽视的细节。double浮点数累加10000次,偏移能到0.01。看起来很小?但Agent一天跑几千次调用,这个偏移会累积,月底对账的时候你会发现账本和实际差了好几块。

我的做法是把1元拆成1000000个微元(micro-cent),用long整数存。加法永远不会丢精度,显示的时候除一下就行:

// 成本存储用微元(1元 = 1,000,000微元),避免浮点累加偏移
private final AtomicLong totalCostMicroCents = new AtomicLong(0);

public void addCost(double yuan) {
    long microCents = (long)(yuan * 1_000_000);
    totalCostMicroCents.addAndGet(microCents);
}

public double getTotalCostYuan() {
    return totalCostMicroCents.get() / 1_000_000.0;
}

2. 按模型分类统计

多模型Agent的标配。每个模型有自己的统计桶:

private final ConcurrentHashMap<String, ModelStats> modelStats = new ConcurrentHashMap<>();

public static class ModelStats {
    private final AtomicLong inputTokens = new AtomicLong(0);
    private final AtomicLong outputTokens = new AtomicLong(0);
    private final AtomicLong callCount = new AtomicLong(0);
    private final AtomicLong costMicroCents = new AtomicLong(0);
    // ... 延迟统计见下文
}

这样你能一眼看到GLM-5.1烧了多少钱、GLM-4调了多少次。之前用一个大桶混着装,根本没法对比。

3. P50/P95/P99分位数延迟

为啥不用平均值?因为极端值会把平均值拉偏到离谱的程度。比如99次调用都是200ms,1次调用是15秒–平均值是350ms,看起来还行,但95%的用户体验是200ms。P95才是真实体验的指标。

我用了一个简单的高效分位数算法–固定桶计数器,不需要存所有样本:

// 延迟分位数统计(桶式计数,不需要存所有样本)
private final AtomicLongArray latencyBuckets = new AtomicLongArray(50); // 50个桶,每桶200ms

public void recordLatency(long latencyMs) {
    int bucket = (int) Math.min(latencyMs / 200, 49);
    latencyBuckets.incrementAndGet(bucket);
}

public long getP95LatencyMs() {
    // 从高桶往低桶累加,找到95%分位点
    long total = getTotalCalls();
    long target = (long)(total * 0.95);
    long accumulated = 0;
    for (int i = 49; i >= 0; i--) {
        accumulated += latencyBuckets.get(i);
        if (accumulated >= total - target) {
            return (i + 1) * 200;
        }
    }
    return 200;
}

整个追踪器挂在LangChain4j的ChatModelListener上,每次模型调用自动触发,不用手动埋点:

public class ProductionTokenTracker implements ChatModelListener {
    @Override
    public void onRequest(ChatModelRequest request) {
        requestStartTimes.put(request.id(), System.currentTimeMillis());
    }

    @Override
    public void onResponse(ChatModelResponse response) {
        String model = response.modelName();
        long latency = System.currentTimeMillis() - requestStartTimes.get(response.id());
        ModelStats stats = modelStats.computeIfAbsent(model, k -> new ModelStats());
        stats.inputTokens.addAndGet(response.tokenUsage().inputTokenCount());
        stats.outputTokens.addAndGet(response.tokenUsage().outputTokenCount());
        stats.callCount.incrementAndGet();
        stats.addCost(calculateCost(model, response.tokenUsage()));
        stats.recordLatency(latency);
    }
}

对比一下OpenClaw的session_status机制–OpenClaw能告诉你session用了多少Token、当前模型是什么,但它不区分模型,不算成本,不做分位数。它是全局视角,我们是手术刀级别的精细统计。

3个意外发现

跑了两天数据之后,我发现了三个挺吓人的事儿:

意外1:输出Token比输入Token贵4倍

GLM-5.1的定价:输入¥0.5/M Token,输出¥2/M Token。意味着同样的1000 Token,输出比输入贵4倍。我之前一直以为"差不多",结果光输出Token就占总成本的70%以上。优化Agent的时候,缩减输出比缩减输入效果好得多。

意外2:P95比平均值高3倍

平均值告诉我"一次调用大概350ms",但P95是1200ms。啥意思?每20次调用里就有1次超过1.2秒。平均值骗人–它把极端值"摊平"了,让你以为一切正常。真正影响用户体验的是P95,不是平均值。

意外3:模型切换不改价格表 → 成本统计全是0

这个坑最隐蔽。我的calculateCost方法里有个价格映射表:

private static final Map<String, PricingInfo> PRICING_TABLE = Map.of(
    "glm-4", new PricingInfo(0.001, 0.002),  // 输入/输出每千Token
    "glm-5.1", new PricingInfo(0.0005, 0.002)
);

有天我把Agent切换成了glm-4-plus,价格表里没这个key,calculateCost返回0。跑了200次调用,账本显示¥0.00。我还以为自己优化得很成功,结果月底真实账单啪啪打脸。

教训:价格表必须在模型切换的时候同步更新,或者用兜底策略(未知模型按最高价估算)。

第二部分:ASCII Dashboard面板–终端里的监控大屏

有了数据,得能看见。我不想引入什么Grafana、Prometheus–跑Agent的开发环境就一台机器,装一堆依赖太麻烦。所以用纯ASCII画了一个Dashboard,零依赖,控制台直接看。

Dashboard有6个区域,每个解决一个具体问题:

  1. 概览区:总调用数、总Token、总成本–一眼看全局
  2. 模型对比区:每个模型的调用数和成本–谁烧钱多一目了然
  3. 延迟分布区:P50/P95/P99–比平均值靠谱
  4. 成本分析区:按模型的成本占比–优化优先级
  5. 趋势图区:ASCII柱状图–看调用趋势
  6. 对比OpenClaw区:把我们的数据和session_status对齐–双保险

最有意思的是趋势图。我用renderTrendChart画ASCII柱状图,纯字符串拼出来的:

还有一个区域我觉得特别实用,是成本分析区。它不光列出每个模型花了多少钱,还会自动算占比,让你一眼看到哪个模型最"奢侈":

public String renderCostAnalysis() {
    StringBuilder sb = new StringBuilder();
    sb.append("\n  ╔═ 成本分析 ═╗\n");
    double totalCost = getTotalCostYuan();
    for (Map.Entry<String, ModelStats> entry : modelStats.entrySet()) {
        double modelCost = entry.getValue().getTotalCostYuan();
        double percentage = (modelCost / totalCost) * 100;
        sb.append(String.format("  │ %-12s ¥%.4f (%.1f%%) %s\n",
            entry.getKey(),
            modelCost,
            percentage,
            renderPercentageBar(percentage)));
    }
    sb.append(String.format("  │ 总计        ¥%.4f\n", totalCost));
    sb.append("  ╚═══════════╝\n");
    return sb.toString();
}

控制台效果:

  ╔═ 成本分析 ═╗
  │ glm-5.1     ¥3.200 (71.4%) ████████████████████
  │ glm-4       ¥1.280 (28.6%) ████████
  │ 总计        ¥4.480
  ╚═══════════╝
  ⚠️ glm-5.1占比71.4%,建议对简单任务切换glm-4

71.4%–光一个模型就占了大头。如果你不看占比,光看绝对数"3块2",觉得不算多。但一算比例才发现,8成钱都花在同一个模型上。优化的时候,先砍大头才有用。

public String renderTrendChart(String title, List<Long> data, int maxWidth) {
    long max = data.stream().mapToLong(Long::longValue).max().orElse(1);
    StringBuilder sb = new StringBuilder();
    sb.append("\n  ╔═ " + title + " ═╗\n");
    for (int i = 0; i < data.size(); i++) {
        long value = data.get(i);
        int barWidth = (int)((value / (double)max) * maxWidth);
        sb.append(String.format("  %-3s │%s %d\n",
            labelForIndex(i),
            repeat("█", barWidth),
            value));
    }
    sb.append("  ╚══════╝\n");
    return sb.toString();
}

private String repeat(String s, int count) {
    return s.repeat(Math.max(0, count));
}

控制台效果大概是这样的:

  ╔═ Token使用趋势(最近10次) ═╗
  01  │████████████████████ 2450
  02  │█████████████████    2100
  03  │███████████████████  2300
  04  │█████████            980   ← 这里突然少了
  05  │███████████████████████ 2800
  06  │████████████          1500
  07  │██████████████        1800
  08  │█████████████████████ 2600
  09  │███                   320   ← 又突然少了
  10  │████████████████      1900
  ╚══════╝

04和09那两次明显矮了–说明模型在那两次生成了很短的输出。如果正好是关键任务节点,这可不是好事。

成本优化建议自动生成也是这个Dashboard的一个实用功能。它会自动扫描当前数据,发现贵模型就提示切换:

public List<String> generateOptimizationSuggestions() {
    List<String> suggestions = new ArrayList<>();
    for (Map.Entry<String, ModelStats> entry : modelStats.entrySet()) {
        String model = entry.getKey();
        ModelStats stats = entry.getValue();
        double avgCostPerCall = stats.getTotalCostYuan() / stats.callCount.get();
        if (avgCostPerCall > CHEAP_MODEL_THRESHOLD) {
            suggestions.add("⚠️ " + model + " 单次调用成本¥" + format(avgCostPerCall)
                + ",建议切换到更便宜的模型处理简单任务");
        }
    }
    return suggestions;
}

还有一个挺实用的设计–速度分级进度条。根据延迟把每个模型标成快/正常/慢/很慢:

  延迟评估:
  glm-4       [████████████████████████████] 快      P50=180ms
  glm-5.1     [████████████████████████      ] 正常    P50=450ms
  glm-4-plus  [██████████████                ] 慢      P50=800ms

对比OpenClaw的heartbeat健康检测–OpenClaw定时检测Agent"活着没",我们检测"活得好不好"。活着和活得好是两回事。一个Agent可能心跳正常,但每次调用都要10秒、输出质量在悄悄退化。Dashboard就是让你看到"活得好不好"。

第三部分:质量下降预警–Agent"悄悄变笨"你怎么知道?

Token追踪告诉你烧了多少钱,Dashboard让你看见数据,但还有一个更隐蔽的问题:Agent可能在悄悄变笨。

"变笨"不是我瞎说。LLM的质量退化有两种常见情况:

  • 模型提供商后台调了参数(你不知道)
  • 你的Prompt在多轮对话中累积了噪声(你没注意到)

这两种情况都不会报错–Agent照样跑,照样返回结果,只是结果越来越短、越来越不靠谱。如果不监控,你就是温水煮青蛙。

我定了三条监控规则,每个对应一种退化模式:

规则1:输出长度异常

突然变短,说明模型在偷懒或者输出被截断了;突然变长,说明模型在幻觉或者重复输出。两种都不是好信号。

检测方法分两层:绝对阈值 + 相对偏差。绝对阈值是硬杠–输出低于50个Token,大概率不正常。相对偏差是跟基线比–如果近5次输出的平均值跟基线偏差超过40%,告警。

public Alert checkOutputLength(String model, int outputTokens) {
    ModelBaseline baseline = baselines.get(model);
    // 绝对阈值:低于50 Token大概率是截断或偷懒
    if (outputTokens < ABSOLUTE_MIN_THRESHOLD) {
        return Alert.critical(model + " 输出仅 " + outputTokens + " Token,可能截断或偷懒");
    }
    // 相对偏差:近5次平均值跟基线偏差超过40%
    double recentAvg = getRecentAverage(model, 5);
    double deviation = Math.abs(recentAvg - baseline.avgOutputLength) / baseline.avgOutputLength;
    if (deviation > RELATIVE_DEVIATION_THRESHOLD) {
        return Alert.warning(model + " 输出长度偏差 " + (deviation * 100) + "%,基线="
            + baseline.avgOutputLength + ",近期=" + recentAvg);
    }
    return Alert.none();
}

规则2:错误率飙升

用滑动窗口看最近20次调用的出错比例。如果30%都失败了,立刻告警:

public Alert checkErrorRate(String model) {
    RingBuffer<Boolean> window = errorWindows.get(model);
    long errorCount = window.stream().filter(b -> b).count();
    double errorRate = errorCount / (double)window.size();
    if (errorRate > ERROR_RATE_THRESHOLD) {
        return Alert.critical(model + " 错误率 " + (errorRate * 100) + "%,滑动窗口 "
            + window.size() + " 次中 " + errorCount + " 次出错");
    }
    return Alert.none();
}

这里有个取舍值得说说:为啥滑动窗口是20而不是50或100?20是个平衡点–太少(比如5)容易被偶然波动误触发,太多(比如100)会让早期问题反应太慢。Agent的典型调用间隔是秒级,20次大概覆盖最近2-3分钟的行为变化。这个时间窗足够捕捉突发问题,又不至于被远古噪声拖累。

Ring Buffer的另一个好处是内存友好–固定大小,不膨胀。如果用ArrayList存全部历史,一个Agent跑一万次调用,你就得存一万条记录。Ring Buffer永远只占20个槽位,老数据自动丢弃。

public class RingBuffer<T> {
    private final Object[] buffer;
    private int head = 0;
    private int count = 0;

    public void add(T item) {
        buffer[head] = item;
        head = (head + 1) % buffer.length;
        if (count < buffer.length) count++;
    }

    public Stream<T> stream() {
        return IntStream.range(0, count)
            .mapToObj(i -> buffer[(head - count + i + buffer.length) % buffer.length]);
    }
}

规则3:延迟退化

近5次的P95延迟如果比基线翻倍,告警。这个坑不少人会踩:基线只建一次,不更新。

为啥?因为如果基线跟着实际数据"滑动",那退化就会被"温水煮青蛙"掩盖。假设基线本来是200ms,现在退化到了400ms。如果基线也跟着更新到400ms,那下次500ms的时候,基线又更新到500ms…你永远检测不到退化,因为基线一直在"追"退化。

public Alert checkLatencyDegradation(String model) {
    ModelBaseline baseline = baselines.get(model); // 只建一次,不更新
    long recentP95 = calculateRecentP95(model, 5);
    if (recentP95 > baseline.p95Latency * 2) {
        return Alert.warning(model + " P95延迟退化:基线=" + baseline.p95Latency
            + "ms,近期=" + recentP95 + "ms(翻倍)");
    }
    return Alert.none();
}

告警怎么传达给开发者?我用观察者模式–AlertHandler接口。你可以把告警打印到控制台、发到飞书、记到日志,随你:

public interface AlertHandler {
    void onAlert(Alert alert);
}

// 注册多个处理器,告警会广播给所有注册者
private final List<AlertHandler> alertHandlers = new ArrayList<>();

private void fireAlert(Alert alert) {
    for (AlertHandler handler : alertHandlers) {
        handler.onAlert(alert);
    }
}

对比OpenClaw的heartbeat机制:OpenClaw检测的是"Agent有没有响应"–活没活着。我们检测的是"Agent活得好不好"–输出质量有没有退化、延迟有没有飙升。两者完全不同层级。

打个比方:heartbeat是看心电图有没有波形,质量预警是看心率是不是从60变到了120。波形存在不代表健康,心率异常才真正值得警惕。

第四部分:调用链可视化–谁调用了谁,一目了然

前面三个组件解决了"花多少钱"和"活得好不好"的问题,但还有一个问题没解决:谁调用了谁?哪个节点最耗时?

Week6的TraceListener记录的是扁平的轨迹列表–一条条日志排下来,看不出嵌套关系。像这样:

[CodeReviewer] → onRequest
[CodeReviewer] → onResponse (耗时800ms)
[CodeAnalyzer] → onRequest
[CodeAnalyzer] → onResponse (耗时1200ms)
[CodeSuggester] → onRequest
[CodeSuggester] → onResponse (耗时500ms)

这3条日志是平铺的,你看不出CodeReviewer内部调了CodeAnalyzer和CodeSuggester。想分析瓶颈?你得自己脑补调用树。

我的解决方法是用栈模拟嵌套调用,把扁平列表重构成树形:

public class CallTreeBuilder implements ChatModelListener {
    private final Deque<CallNode> callStack = new ConcurrentLinkedDeque<>();
    private CallNode root = null;

    @Override
    public void onRequest(ChatModelRequest request) {
        CallNode node = new CallNode(request.modelName(), request.id());
        if (callStack.isEmpty()) {
            root = node;  // 根节点
        } else {
            callStack.peek().addChild(node);  // 子节点
        }
        callStack.push(node);
    }

    @Override
    public void onResponse(ChatModelResponse response) {
        CallNode node = callStack.pop();
        node.setLatency(System.currentTimeMillis() - node.startTime);
        node.setOutputTokens(response.tokenUsage().outputTokenCount());
    }
}

重构后的树形结构,用ASCII渲染出来的效果:

CodeReviewer [800ms, 150 tokens]
  ├── CodeAnalyzer [1200ms, 300 tokens]  ← 最耗时节点
  └── CodeSuggester [500ms, 80 tokens]

一眼就能看到瓶颈在CodeAnalyzer–它耗了1200ms,占了总时间的60%。这比看扁平日志效率高太多了。

自动瓶颈分析也很简单–遍历树的每个节点,找耗时最高的:

public CallNode findBottleneck(CallNode root) {
    CallNode bottleneck = root;
    for (CallNode child : root.children) {
        CallNode childBottleneck = findBottleneck(child);
        if (childBottleneck.latency > bottleneck.latency) {
            bottleneck = childBottleneck;
        }
    }
    return bottleneck;
}

对比OpenClaw的session transcript–OpenClaw记录的是线性的对话历史,一条条往下排。它能看到"发生了什么",但看不到"谁嵌套了谁"。这就像看日志和看OpenTelemetry Span树的区别:日志是线性输出,Span树是嵌套结构。你需要的是后者。

类比一下:你去医院看病,医生先看你的病历档案(扁平的就诊记录),但真正能看出病因的是各项检查的关联关系(哪个指标异常导致了另一个异常)。调用链可视化就是给你的Agent做"关联分析"。

结尾:四件套 vs OpenClaw–谁的场景用谁

踩完这些坑,总结几条实用的东西。

组件 解决的问题 对比 OpenClaw 适用场景
Token追踪器 烧了多少钱、谁烧得多 session_status不区分模型、不算成本 多模型混用、成本敏感项目
ASCII Dashboard 终端直看数据趋势 无内建可视化、需外部工具 开发环境、单机部署
质量预警 Agent变笨了你知道吗 heartbeat只检测"活着没",不检测质量 生产环境、长时运行Agent
调用链可视化 谁调了谁、瓶颈在哪 session transcript是线性的、无树形结构 多Agent协作、复杂任务链

怎么选也简单:

  • 单模型、短任务 → Token追踪器就够了,成本是唯一的关注点
  • 多模型、成本敏感 → Token追踪器 + Dashboard,看清谁烧钱
  • 长时运行、质量重要 → 四件套全上,特别是质量预警,别让Agent悄悄变笨
  • 多Agent协作 → 调用链可视化必上,否则瓶颈分析纯靠猜

四件套加起来不到2000行代码,零外部依赖,控制台直接看。比引入Prometheus + Grafana那套运维体系轻得多,但覆盖了Agent场景最关键的三个维度:钱、质量、调用关系。

OpenClaw的可观测性体系是平台级别的–它管的是多个Agent、多个session、跨用户的全局状态。我们的四件套是Agent级别的–管的是单个Agent的健康、成本和内部逻辑。两者互补,不是替代。

下次聊Week7 Day7–Agent部署与运维,把这四件套怎么打包成生产环境可用的体系说清楚。

如果你正在搞Agent项目,我建议先上Token追踪器–这是投入产出比最高的一个。200行代码就能让你知道每次调用花了多少钱,比事后看账单靠谱多了。质量预警和调用链可视化可以等Agent跑稳定了再加,但Token追踪应该是从Day1就有的。

有一句话我一直觉得挺对:没有监控的系统不是生产系统,是实验。 你可以实验,但别把实验当成生产。四件套就是帮你把实验变成生产的那根线。


每天分享AI Agent实战经验。踩坑不藏私,干货不打折。*

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐