1. 任务还没结束CPU 却只有 20%小周盯着迟迟没出结果的门店经营日报又看了一眼监控CPU 只有20%。“机器看着挺闲任务到底慢在哪里”小林指了指卡片下面的时间“先别急着换机器。这 20%描述的是哪次采样”把窗口拉回前半小时两人看到最后一个点确实是 20%中间却采到过90% 和 95%。同一组样本的均值约为46.67%。最后一个低值不能代表整个任务期间都很空闲。反过来采到一个高值也不能直接认定 CPU 就是慢任务的根因。要先对齐同一次运行、实际执行节点和时间再检查任务当时是在计算还是在等待。这篇先给出一份可以照着走的检查清单再用一个独立的虚构场景把 CPU 趋势做成 MCP 工具。没有读过前文也可以先用清单检查自己的监控结论。案例中的平台、节点、任务和指标均为虚构教学设定。本文只实现 CPU 趋势查询运行阶段、执行节点关联、队列与日志证据需要从相应系统另行取得。先查这 5 件事检查项具体查什么查完如何继续① 对象有没有对上记录运行实例、执行尝试、当次执行节点以及任务所在阶段节点或尝试对不上先修正对象不能拿整集群平均值代替目标节点② 时间有没有对上记录变慢窗口、时区、CPU 样本时间同时看开始、结束是否包含边界最新点早于故障窗口或来自另一段历史就重查对应窗口③ 看的是什么 CPU确认整机、进程还是容器口径一起看最新点、样本均值、已观测峰值和原始点只看到一个 20%还不足以描述趋势不同分母的百分比不能直接比较④ 样本有没有缺记录采样间隔、实际点数、预期点数与缺失位置核对重复点如何处理有缺口就注明未知不要把缺失补成 0也不要用点位齐全宣称每秒都已观测⑤ 下一条证据是什么对齐任务阶段查队列等待、线程等待或 I/O 等相关记录尚未开始计算先追等待原因已在计算再结合相关指标与日志验证资源瓶颈拿这份清单排查时前四项是在确认“监控数字能说明什么”第五项才开始追“为什么慢”。可以直接复制的排查记录运行实例 / 执行尝试 当次执行节点 / 任务阶段 变慢窗口 / 时区 / 起止边界 CPU 指标口径整机、进程或容器百分比的分母是什么 窗口内最新点时间、数值 样本均值 / 已观测峰值 采样间隔 / 实际点数 / 预期点数 缺失、重复或异常点 已经确认的事实 仍待验证的原因 下一项检查及需要的证据没有拿到的字段就写“未取得”。这份记录是人工排查模板不能把空白补成工具已经返回的事实。动手部分使用lab-03-06教学工程技术基线为 Java 17、Spring Boot 4.1.0、Spring AI 2.0.1。2. 把半小时摊开最后是 20%中间却到过 95%第二篇的pressure场景只提供过 B 节点 09:00 的 86%、09:09 的 88%不足以展示一个完整趋势。本篇新增spike专门用于练习趋势查询。它和pressure是两组独立教学样本不是我们后来从同一次运行中查到了更多记录。仍然把观察时刻固定在2026-09-16 09:10 08:00。这次查看demo-worker-b查询窗口是[08:40, 09:10)左边的方括号表示包含 08:40右边的小括号表示不包含 09:10。我们约定每 5 分钟一个采样点所以这个窗口应有 6 个点采样时间CPU08:4020%08:4525%08:5090%08:5595%09:0030%09:0520%图 1画出实际采到的点便于区分观测与未观测的时间。图中没有把点之间的状态补出来。小周看完说“原来中间上去过。”这句话有依据。但“整个半小时一直很高”“高了整整十分钟”现有样本都不能严格证明。3. 三个 CPU 数字分别能说明什么先说最容易混淆的“当前值”。本次结果中最后一个点在09:05数值是20%。查询时刻则是 09:10。因此我们把字段命名为latestInWindow也就是“窗口内最新样本”没有直接叫currentCpu。如果把窗口改成早上八点窗口内最新值仍然属于那段历史不能因为它排在返回列表最后就把它当成当前值。再看平均值(20 25 90 95 30 20) / 6 46.6666... ≈ 46.67%这里求的是6 个已采样数值的算术平均所以字段叫sampleMean。它是方便概括这组样本的数字不等于已经知道每一秒的 CPU也不代表把整段时间的真实利用率做了精确积分。最后看峰值max(20, 25, 90, 95, 30, 20) 95%我们称它为observedPeak即“已观测峰值”。它证明采到过 95%但不能排除采样间隔内出现过更高的瞬时值。数字本篇结果能说明什么窗口内最新值09:05 的 20%最后一次采样的观测样本均值46.67%已采到的 6 个数值的平均已观测峰值95%已采到的最大数值采样间隔300 秒计划多久采一个点点位覆盖率6/6100%这个固定网格上的预期点是否齐全小林把最后一行圈了出来“点位覆盖率 100%也不意味着每一秒都被观测过。”4. 两个高点没采到平均值会立刻变得好看接下来小林切换到gaps样本。这组样本保留同一个窗口和采样计划但故意移除 08:50、08:55 的两个点。现在剩下20、25、30、20工具算出的结果变成样本均值 95 / 4 23.75% 已观测峰值 30% 点位覆盖率 4 / 6 ≈ 66.67%图 2在这个专门构造的案例里恰好缺掉了两个高值。真实缺失时段的数值通常未知不能从已有点反推出去。小周说“如果只返回均值和峰值助手会觉得这段时间很平稳。”正是如此。所以统计值应该和窗口、实际点数、预期点数一起返回。没有采到的两个位置也不能补成 0。补零以后均值会变成95/6≈15.83%它只是在计算中凭空加入了两次“CPU 为零”的观测。有些数据源会使用插值或保持前值但那也必须是明确的统计规则并标识哪些值经过填补。本篇保持最简单的约定只统计实际样本缺失就是缺失。如果整个窗口都没有样本就返回sampleMeannull、observedPeaknull、latestInWindownull而不是三个零。5. 给趋势查询加上边界避免一句话拉出无限数据小林给新工具起名ops_cpu_trend。参数包括节点、场景、开始时间和结束时间。参数示例或限制nodeId本节只准备了demo-worker-b的趋势scenespike、gaps、emptystart带时区偏移的 ISO-8601 时间包含该时刻end带时区偏移的 ISO-8601 时间不包含该时刻窗口长度大于 0不超过 60 分钟时间对齐起止都按 5 分钟网格对齐观察边界结束时间不能晚于固定的 09:10为什么要限制时间对齐因为本篇要用“窗口时长 / 采样间隔”计算期望点数。这个算法依赖固定网格不是任意监控数据都能照抄的公式。如果允许 08:42 到 09:07是否应有 08:45、08:50 等点就必须另外说明采样网格从哪里开始。这里先把输入限制在已经定义清楚的情况。真实查询还要明确指标口径。例如整机 CPU 利用率、某个进程 CPU 百分比和某个容器的配额使用率分母可能不同不能只看它们都叫 CPU 就混在一起。本篇的cpuPercent是约定好的节点 CPU 百分比教学值88表示 88%没有从原始计数器推导利用率的过程。6. 让服务计算数字让助手解释数字趋势服务做四件事检查输入、取窗口样本、按时间排序、计算统计量。完整实现如下packageexample.qinghe;importio.swagger.v3.oas.annotations.media.Schema;importjava.time.Duration;importjava.time.OffsetDateTime;importjava.time.format.DateTimeParseException;importjava.util.Comparator;importjava.util.List;importjava.util.Set;importjava.util.stream.IntStream;importorg.springframework.stereotype.Service;/** 固定 5 分钟采样网格的 CPU 教学统计不插值、不补零。 */ServicepublicclassTrendService{publicrecordPoint(OffsetDateTimesampledAt,doublecpuPercent){}publicrecordTrendResult(StringnodeId,Stringscene,Stringunit,OffsetDateTimeobservedAt,OffsetDateTimestart,OffsetDateTimeend,Stringinterval,intsampleIntervalSeconds,intexpectedPoints,intactualPoints,doublecoveragePercent,Schema(nullabletrue)DoublesampleMean,Schema(nullabletrue)DoubleobservedPeak,Schema(nullabletrue)PointlatestInWindow,StringlatestFreshness,ListPointpoints){}publicTrendResultquery(StringnodeId,Stringscene,Stringstart,Stringend){if(!demo-worker-b.equals(nodeId)){thrownewIllegalArgumentException(本节趋势样本仅提供 demo-worker-b);}if(scenenull||!Set.of(spike,gaps,empty).contains(scene)){thrownewIllegalArgumentException(scene 仅支持 spike/gaps/empty);}OffsetDateTimefromparse(start),toparse(end);longsecondsDuration.between(from,to).getSeconds();if(seconds0||seconds3600||to.isAfter(DemoData.NOW)||!aligned(from)||!aligned(to)){thrownewIllegalArgumentException(窗口须为 5 分钟对齐的 (0,60] 分钟end 不晚于实验观察时刻);}ListPointpointsfixture(scene).stream().filter(p-!p.sampledAt().isBefore(from)p.sampledAt().isBefore(to)).sorted(Comparator.comparing(Point::sampledAt)).toList();intexpected(int)seconds/300;Doublemeanpoints.isEmpty()?null:round(points.stream().mapToDouble(Point::cpuPercent).average().orElseThrow());Doublepeakpoints.isEmpty()?null:points.stream().mapToDouble(Point::cpuPercent).max().orElseThrow();Pointlatestpoints.isEmpty()?null:points.get(points.size()-1);returnnewTrendResult(nodeId,scene,PERCENT,DemoData.NOW,from,to,[start,end),300,expected,points.size(),round(100.0*points.size()/expected),mean,peak,latest,DemoData.freshness(latestnull?null:latest.sampledAt()),points);}staticListPointfixture(Stringscene){if(scene.equals(empty))returnList.of();double[]values{20,25,90,95,30,20};returnIntStream.range(0,values.length).filter(i-!scene.equals(gaps)||(i!2i!3)).mapToObj(i-newPoint(DemoData.at(08:40).plusMinutes(5L*i),values[i])).toList();}privatestaticOffsetDateTimeparse(Stringinput){if(inputnull||input.length()40){thrownewIllegalArgumentException(时间必须为带偏移的 ISO-8601例如 2026-09-16T08:40:0008:00);}try{returnOffsetDateTime.parse(input);}catch(DateTimeParseExceptionex){thrownewIllegalArgumentException(时间必须为带偏移的 ISO-8601,ex);}}privatestaticbooleanaligned(OffsetDateTimetime){returntime.getNano()0Math.floorMod(time.toEpochSecond(),300)0;}privatestaticdoubleround(doublevalue){returnMath.round(value*100)/100.0;}}这里的两个条件!p.sampledAt().isBefore(from)p.sampledAt().isBefore(to)对应的就是[start,end)。这样查询相邻窗口时边界上的一个采样点不会同时落入前后两个窗口。样本均值和峰值在 Java 中计算助手直接使用计算结果。这样可以测试和复核不需要每次让模型重新心算。当前样本是内存中构造的有限列表时间戳唯一、CPU 在 0100 之间。接入真实数据源时应把过滤与聚合尽量下推并先约定重复时间戳、异常数值和采样中断的处理方式。本文代码没有实现这些外部数据清洗流程。工具入口如下packageexample.qinghe;importorg.springframework.ai.mcp.annotation.McpTool;importorg.springframework.ai.mcp.annotation.McpToolParam;importorg.springframework.context.annotation.Profile;importorg.springframework.stereotype.Component;/** 第 05 篇工具入口业务校验与计算由服务完成。 */ComponentProfile({ch05,ch06})publicclassTrendTools{privatefinalTrendServiceservice;publicTrendTools(TrendServiceservice){this.serviceservice;}McpTool(nameops_cpu_trend,description查询固定教学时钟下的 CPU 样本返回窗口、间隔、样本均值、已观测峰值和缺失覆盖率不推断慢任务根因。,generateOutputSchematrue,annotationsMcpTool.McpAnnotations(readOnlyHinttrue,destructiveHintfalse))publicTrendService.TrendResultquery(McpToolParam(description本节只提供 demo-worker-b 的趋势样本,requiredtrue)StringnodeId,McpToolParam(descriptionspike/gaps/empty与前文 pressure 是独立样本,requiredtrue)Stringscene,McpToolParam(description左闭起点带偏移 ISO-86015 分钟对齐,requiredtrue)Stringstart,McpToolParam(description右开终点带偏移 ISO-86015 分钟对齐窗口最长 60 分钟且不晚于 2026-09-16T09:10:0008:00,requiredtrue)Stringend){returnservice.query(nodeId,scene,start,end);}}小周注意到描述里写了“不推断慢任务根因”。小林解释“工具提供的是一段 CPU 观测。就算我们采到了 95%也要先确认这是任务相关节点、相关时间还要看任务有没有真正进入计算。”7. 跑一下完整、缺失和空窗口在lab-03-06目录执行切换前先结束自己启动的旧章节服务mvntestpackagejava-jartarget/qinghe-ops-lab-1.0.0.jar--spring.profiles.activech05新开终端查询python3 mcp_call.py ops_cpu_trend\{nodeId:demo-worker-b,scene:spike,start:2026-09-16T08:40:0008:00,end:2026-09-16T09:10:0008:00}实际structuredContent的统计字段如下。为便于阅读这里省略points数组六个原始点就是第二节的表格{nodeId:demo-worker-b,scene:spike,unit:PERCENT,observedAt:2026-09-16T09:10:0008:00,start:2026-09-16T08:40:0008:00,end:2026-09-16T09:10:0008:00,interval:[start,end),sampleIntervalSeconds:300,expectedPoints:6,actualPoints:6,coveragePercent:100.0,sampleMean:46.67,observedPeak:95.0,latestInWindow:{sampledAt:2026-09-16T09:05:0008:00,cpuPercent:20.0},latestFreshness:FRESH}再分别查询gaps和empty场景实际/预期点数样本均值已观测峰值窗口内最新值spike6/646.67%95%09:05 的 20%gaps4/623.75%30%09:05 的 20%empty0/6nullnullnull最后把结束时间改为08:50。在[08:40,08:50)中只应返回 08:40、08:45 两个点峰值是 25%08:50 的 90% 不属于这个窗口。该窗口内最新点已经距离观察时刻很久因此latestFreshnessSTALE。这不妨碍它描述历史窗口它只是提醒我们不要拿历史值回答“现在怎么样”。配套测试覆盖了这些统计结果以及倒置窗口、零长度窗口、超过一小时、不带时区、未来终点和网格未对齐等输入。8. 采样间隔不规则时不要悄悄把均值换个含义小周又想到了另一种情况“如果有的点隔一分钟有的点隔二十分钟直接平均还成立吗”算术平均当然可以算但它依旧只表示“这些样本的平均”每个点的权重相同。采样密集的时段会贡献更多点。如果想回答“整段时间平均占用了多少”就要先明确每个值代表瞬时观测还是前一个采样周期的平均再决定能否按持续时长加权、缺失区间如何处理。没有这些前提直接换成加权公式也不一定更正确。所以本篇刻意把字段叫作sampleMean把采样间隔和缺失比例放在旁边。先把正在计算的东西说准确比给一个看起来专业的数字更有用。9. 回到最初的问题CPU 低为什么仍可能慢现在可以给小周一段更可靠的回答。下面是依据spike教学数据编写的预期回答不是真实模型实测在 08:40 至 09:10 的左闭右开窗口中节点 B 有 6 个 CPU 样本按本例 5 分钟网格点位覆盖率为 100%。窗口内最新点是 09:05 的 20%样本均值约为 46.67%已观测峰值为 95%。08:50 和 08:55 两次采样分别为 90% 和 95%所以不能仅凭最后一个低值认为整个窗口都很空闲。这些点不足以证明高 CPU 持续了多久也不能单独确定它造成任务变慢。下一步应确认任务在该窗口所处的阶段、实际关联节点以及调度或计算引擎的等待状态。如果返回的是gaps回答还必须增加一句缺失两次预期采样均值和峰值仅来自已有四个点缺失时段未知。CPU 低的时候任务可能在等待调度、等待 I/O也可能还没获得计算资源。这些只是接下来要验证的方向当前这组 CPU 样本没有替我们证明其中任何一个。小周想了想“那我得先知道它到底在等什么。”下一篇我们就沿着同一次运行区分调度等待、合适的执行资源不足以及计算队列等待。
阅读完成 · 觉得有帮助?