feat(core): 实现 Token 消耗统计系统

- 扩展 SessionStats schema 添加 token 统计字段
- 添加 TokenUsageInfo 类型和 ChatResult.usage 字段
- AgentMessageHandler 从 AI SDK response 提取 usage
- AgentExecutor 返回 usage 到执行结果
- 新增 TokenStatsManager 管理统计:
  - updateSessionStats: 更新会话 token 统计
  - mergeChildSessionStats: 合并子会话统计到父会话
  - getSessionStats/getProjectStats: 查询统计
- Agent.chat() 完成后自动更新统计
- Task 工具完成后合并子会话统计
- 新增 REST API: /api/stats/sessions/:id, /api/stats/projects/:id
- 添加 TokenStatsManager 单元测试 (12 tests)
This commit is contained in:
2025-12-18 16:11:00 +08:00
parent 2c8a95daeb
commit bac32fe8f6
17 changed files with 1054 additions and 27 deletions
+31 -2
View File
@@ -6,7 +6,7 @@ import {
type Tool as AITool,
type LanguageModel,
} from 'ai';
import type { Tool, ToolResult, AgentConfig, ContentBlock } from '../types/index.js';
import type { Tool, ToolResult, AgentConfig, ContentBlock, TokenUsageInfo } from '../types/index.js';
import { buildZodSchema } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js';
import type {
@@ -95,6 +95,7 @@ export class AgentExecutor {
let fullResponse = '';
let steps = 0;
let usage: TokenUsageInfo | undefined;
// 工具调用时间追踪(用于计算持续时间)
const toolStartTimes = new Map<string, number>();
@@ -200,7 +201,9 @@ export class AgentExecutor {
}
}
await result.response;
const response = await result.response;
// 提取 usage 信息
usage = this.extractUsage(response);
} else {
// 非流式模式
const result = await generateText({
@@ -214,6 +217,8 @@ export class AgentExecutor {
fullResponse = result.text;
steps = result.steps.length;
// 提取 usage 信息
usage = this.extractUsage(result.response);
}
return {
@@ -221,6 +226,7 @@ export class AgentExecutor {
text: fullResponse,
steps,
sessionId: context.parentSessionId ?? 'standalone',
usage,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -416,4 +422,27 @@ export class AgentExecutor {
return blocks;
}
/**
* 从 AI SDK 响应中提取 usage 信息
*/
private extractUsage(response: unknown): TokenUsageInfo | undefined {
// AI SDK 的 response 对象包含 usage 字段
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resp = response as any;
const usage = resp?.usage;
if (!usage) {
return undefined;
}
return {
promptTokens: usage.promptTokens ?? 0,
completionTokens: usage.completionTokens ?? 0,
totalTokens: usage.totalTokens ?? (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0),
// Anthropic API 特有的缓存字段
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
};
}
}