Skip to content

Feat/add llm statistics - #579

Open
rootdeng wants to merge 6 commits into
mateaix:devfrom
rootdeng:feat/add-llm-statistics
Open

rootdeng wants to merge 6 commits into
mateaix:devfrom
rootdeng:feat/add-llm-statistics

Conversation

@rootdeng

@rootdeng rootdeng commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
  • 容器与底层维度(LlmStatisticsDecorator 及 LlmStatisticsScheduler):可观测每一发底层的首 Token 耗时、非流式调用时耗及完成时局,从 LLM 客户端层面上排查接口延迟。
  • 用户请求维度(ChatController):可观测用户在会话气泡中发送消息后到全响应落盘的整体历时。通过比较两者,您能够在发生响应卡顿时,非常快速且准确地辨别是底层大模型接口发生阻塞还是由 MateClaw 本地业务逻辑层(数据库访问、上下文整合或内存调度等) 引起的耗时。

rootdeng and others added 6 commits August 4, 2026 13:49
Specifies the architecture, data models, decorator, and scheduler timing metrics.

Co-Authored-By: Claude <noreply@anthropic.com>
… capacity limits

Co-Authored-By: Claude <noreply@anthropic.com>
…ncy tracking

Co-Authored-By: Claude <noreply@anthropic.com>
This aggregates min, max, average, median, p90, and p99 metrics. Reverted JVM-compile lombok overrides in pom.xml to preserve production JDK 21 safety.

Co-Authored-By: Claude <noreply@anthropic.com>
This completes decorator wrapping for all chat models built by the factory.

Co-Authored-By: Claude <noreply@anthropic.com>
Measures overall request-level latency for both synchronous and SSE streaming dialogues.

Co-Authored-By: Claude <noreply@anthropic.com>

@mateaix mateaix left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢 PR!方向是有价值的 —— 能区分「大模型接口慢」和「本地业务逻辑慢」确实是目前缺的一块观测能力。

我把分支拉下来实测了一遍,但目前还不能合,下面按严重程度列一下。


🔴 阻塞项

1. 编译不通过

[ERROR] ChatController.java:[728,78] cannot find symbol
  symbol:   variable streamStart
  location: class vip.mate.channel.web.ChatController

long streamStart 加在了 ChatController.java:250,那里是 approval 恢复流 的 sseExecutor.execute(...) lambda 内部;而使用它的日志加在 728 行,属于 主聊天流 的 lambda(起始于 570 行)。两个 lambda 作用域不互通。

实际效果是:真正想统计的主流程没有起始时间戳,approval 分支多了个没人用的局部变量。麻烦把 streamStart 挪到 570 行那个 lambda 的开头,并在本地跑一次 mvn -q compile 确认。

2. 会破坏 SummarizingNode 的 Anthropic 分支

ProviderChatModelFactory.buildFor() 现在无条件把所有 ChatModel 包一层装饰器。但 SummarizingNode.java:206 依赖具体类型判断:

if (chatModel instanceof AnthropicChatModel) {   // 包装之后永远是 false
    opts = AnthropicChatOptions.builder()
            .thinking(ThinkingType.DISABLED, 0).build();
} else {
    opts = OpenAiChatOptions.builder().build();   // 拿 OpenAI options 去打 Anthropic
}

调用链是 AgentGraphBuilder:569 → buildRuntimeChatModel() → buildFor(),产物在 AgentGraphBuilder:985 传给 SummarizingNode,所以这条分支必然被打断。该方法上方的注释写得很明确:不显式关掉 thinking 的话会继承 thinkingLevel=high,「浪费 100+ 秒」—— 这个 PR 会把它重新引入。

解决方式二选一:

  • 装饰器提供 unwrap() / 实现某个可穿透的接口,并把 SummarizingNode 的判断改成穿透后再 instanceof;
  • 或者干脆不用装饰器(见下面「关于实现路径」)。

3. 被取消的流一条都不会被记录

装饰器只挂了 doOnComplete 和 doOnError,这两个回调在 cancel 时都不触发。而 NodeStreamingChatHelper 有四处会主动 subscription.dispose():

  • 用户点击 stop
  • thinking-only 软上限触发
  • 内容重复检测触发
  • 10 分钟总超时

也就是说,「最该被观测的慢请求 / 卡死请求」恰好全部不会进入统计,p99 会被系统性低估 —— 这跟这个功能本身的目的是矛盾的。建议改用 doFinally,并在 metric 里带上终止原因(complete / error / cancel)。


🟡 数据质量与性能

4. 成功和失败混在一起算分位数

LlmRequestMetric 没有 success 字段,call() 的 catch 分支和成功分支记录的是同一种指标。一次 20ms 就返回的 401 会和正常调用一起进 p50/p99,把延迟数据拉偏。建议加字段并在报表里分开统计。

5. ConcurrentLinkedQueue.size() 是 O(n)

if (requestMetrics.size() >= MAX_CAPACITY) { ... }

ConcurrentLinkedQueue.size() 需要遍历整个队列。这段在每次 LLM 调用时都会执行,队列满时就是每次遍历 10000 个节点。另外 check-then-poll 不是原子操作,并发下队列会超过上限。

建议配 AtomicInteger 计数,或者换成带锁的 ArrayDeque。

6. 队列满之后每次调用都打一条 WARN

实测输出:

WARN LlmStatisticsCollector -- [LlmStatisticsCollector] Capacity limit (10000) reached. Evicted oldest metric from: ...
WARN LlmStatisticsCollector -- [LlmStatisticsCollector] Capacity limit (10000) reached. Evicted oldest metric from: ...
(每插入一条就一行)

日调用量超过 1 万次的实例,日志会被这条 WARN 淹没。淘汰是设计内的正常行为,不该用 WARN;要提示的话,降到 DEBUG 或者做成「首次触发只打一次」。

7. 跨天重置有重复逻辑和竞态

reportStatistics() 的 cron 0 */15 * * * ? 在 00:00:00 也会触发,和 midnightReset() 的 0 0 0 * * ? 撞在同一秒,执行顺序不确定。如果 midnightReset() 先跑,collector.reset() 会先清空缓冲区,随后 reportStatistics() 里当天的最终报表就变成空的,静默丢失。

这两套机制是重复的,留一套即可。


🟡 工程约定

8. 缺少开关和配置项

@Scheduled 是硬编码的,没有任何 mate.* 配置项可以关闭或调整间隔。本项目的约定是「可调项放 application.yml,Java 字段默认值只作兜底」。

另外空闲实例每天会往日志里写 96 行 === LLM Performance stats: No requests recorded today yet ===,对桌面版用户是纯噪音。建议:

mate:
  llm:
    statistics:
      enabled: false          # 默认关闭
      report-interval: 15m
      max-buffer-size: 10000

并在无数据时直接跳过输出。

9. 内联全限定类名

LlmStatisticsScheduler 里的 java.util.Objects::nonNull 需要改成顶部 import java.util.Objects; + Objects::nonNull。项目规范要求所有类型引用用简单名 + import,不写内联 FQN。

10. 新增类没有任何注释

本仓库的类基本都有 Javadoc 说明「为什么这么做」。新增的四个类目前一行注释都没有,尤其是装饰器和调度器这类会影响全局链路的组件,建议补上设计意图。

11. 文档文件

docs/superpowers/specs/2026-08-04-llm-statistics-design.md 把四个类的完整源码又抄了一遍(356 行)。代码一改文档就腐烂,而且 docs/ 目录是面向用户的文档站,不放内部设计稿。建议直接删掉,把设计意图浓缩进类的 Javadoc。


关于实现路径

有个更根本的问题想和你确认一下:这套东西和现有能力有相当大的重叠。

  • AnthropicChatModelBuilder / OpenAiCompatibleChatModelBuilder / ClaudeCodeChatModelBuilder 都已经注入了 Micrometer 的 ObservationRegistry,Spring AI 原生就会发 gen_ai.client.operation 观测事件,里面自带耗时。
  • ChatStreamTracker 和 NodeStreamingChatHelper 里已经有 firstTokenReceived 的追踪逻辑(目前用于心跳间隔切换)。

所以更省事、也更不容易碰坏现有链路的做法可能是写一个 ChatModelObservationHandler,接到已有的 ObservationRegistry 上 —— 这样天然规避掉上面的第 2 点(不用包装 ChatModel)和第 3 点(observation 的生命周期覆盖 cancel),指标也能顺带对接 actuator,而不是只能翻日志。

如果有非用装饰器不可的理由(比如需要拿到 Spring AI observation 里没有的字段),也说一下,我们可以在这个方案上继续推进。


测试

把编译错误临时注释掉之后,5 个测试是全绿的。不过覆盖面还比较浅:

  • LlmStatisticsSchedulerTest 只断言了「跑完不抛异常」,getPercentile() 的边界(空集合、单元素、n=2 时 p50 取下界)都没验证;
  • 跨天重置、并发写入、cancel 场景都没有用例。

上面第 3、4、7 点都属于「有测试就能提前发现」的类型,建议补一下。


再次感谢,这个方向值得做,主要是实现细节需要再打磨一轮。上面第 1~3 点是必须解决的,其余可以按优先级逐步来。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants