perf: 会话统计增量聚合——emitStats/buildContextInfo 不再每轮全量遍历

This commit is contained in:
Rick Ye
2026-09-10 12:27:33 +08:00
parent 8216d70fcf
commit cafb6f34de
+111 -70
View File
@@ -467,13 +467,14 @@ export class AgentService {
* 类目:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他;
* 技能块注入在系统提示词内,从提示词估算中拆出(不重复计数)。 */
private buildContextInfo(session: AgentSession): ContextUsageInfo {
const agg = this.aggregateSession(session)
const breakdown: ContextBreakdownEntry[] = [
{ key: 'messages', label: '消息', tokens: 0 },
{ key: 'customTools', label: '自定义工具', tokens: 0 },
{ key: 'builtinTools', label: '系统工具', tokens: 0 },
{ key: 'messages', label: '消息', tokens: agg.breakdown.get('messages') ?? 0 },
{ key: 'customTools', label: '自定义工具', tokens: agg.breakdown.get('customTools') ?? 0 },
{ key: 'builtinTools', label: '系统工具', tokens: agg.breakdown.get('builtinTools') ?? 0 },
{ key: 'systemPrompt', label: '系统提示词', tokens: 0 },
{ key: 'skills', label: '技能', tokens: 0 },
{ key: 'other', label: '其他', tokens: 0 }
{ key: 'other', label: '其他', tokens: agg.breakdown.get('other') ?? 0 }
]
const byKey = new Map(breakdown.map((b) => [b.key, b]))
const systemTokens = Math.ceil(session.systemPrompt.length / 4)
@@ -481,44 +482,12 @@ export class AgentService {
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
byKey.get('skills')!.tokens = skillsTokens
for (const message of session.messages) {
const m = message as { role?: string; toolName?: unknown; content?: unknown }
switch (m.role) {
case 'user':
case 'assistant':
byKey.get('messages')!.tokens += estimateMessageTokens(message)
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateMessageTokens(message)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateMessageTokens(message)
break
default:
break
}
}
const usage = session.getContextUsage()
let lastOutputTokens: number | undefined
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i] as { role?: string; usage?: { output?: unknown } }
if (m.role === 'assistant' && m.usage && typeof m.usage.output === 'number') {
lastOutputTokens = m.usage.output
break
}
}
return {
tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null,
contextWindow: usage?.contextWindow ?? 0,
percent: usage && typeof usage.percent === 'number' ? usage.percent : null,
...(lastOutputTokens !== undefined ? { lastOutputTokens } : {}),
...(agg.lastOutputTokens !== undefined ? { lastOutputTokens: agg.lastOutputTokens } : {}),
breakdown
}
}
@@ -538,42 +507,114 @@ export class AgentService {
return value
}
// ---- 会话统计增量聚合 ----
// emitStats 每条 assistant 消息落地都会触发;长会话下每次全量遍历估算
// token 是 O(全部消息)。消息列表正常只追加:以「已聚合前缀长度 + 前缀
// 末条消息引用」为锚点,每次只聚合新增尾部;压缩/回溯/会话切换/运行时
// 重建后消息对象引用必变(或长度回退),锚点失配即自动整体重算。
private sessionAgg: {
count: number
anchor: unknown
cacheInput: number
cacheRead: number
cacheWrite: number
toolCalls: number
toolErrors: number
/** 消息侧 breakdown(systemPrompt/skills 在外层另算) */
breakdown: Map<string, number>
lastOutputTokens: number | undefined
} | null = null
private aggregateSession(session: AgentSession) {
const messages = session.messages
let agg = this.sessionAgg
if (
agg === null ||
messages.length < agg.count ||
(agg.count > 0 && messages[agg.count - 1] !== agg.anchor)
) {
agg = {
count: 0,
anchor: null,
cacheInput: 0,
cacheRead: 0,
cacheWrite: 0,
toolCalls: 0,
toolErrors: 0,
breakdown: new Map([
['messages', 0],
['customTools', 0],
['builtinTools', 0],
['other', 0]
]),
lastOutputTokens: undefined
}
}
for (let i = agg.count; i < messages.length; i++) {
const m = messages[i] as {
role?: string
toolName?: unknown
content?: unknown
isError?: unknown
usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
}
const bump = (key: string): void => {
agg!.breakdown.set(key, (agg!.breakdown.get(key) ?? 0) + estimateMessageTokens(messages[i]))
}
switch (m.role) {
case 'assistant': {
bump('messages')
if (m.usage) {
agg.cacheInput += Number(m.usage.input ?? 0) || 0
agg.cacheRead += Number(m.usage.cacheRead ?? 0) || 0
agg.cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
if (typeof m.usage.output === 'number') agg.lastOutputTokens = m.usage.output
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') agg.toolCalls += 1
}
}
break
}
case 'user':
bump('messages')
break
case 'toolResult': {
if (m.isError === true) agg.toolErrors += 1
bump(
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
bump('other')
break
default:
break
}
}
agg.count = messages.length
agg.anchor = messages.length > 0 ? messages[messages.length - 1] : null
this.sessionAgg = agg
return agg
}
private async emitStats(): Promise<void> {
try {
const runtime = await this.getRuntime()
const session = runtime.session
const stats = session.getSessionStats()
const usage = session.getContextUsage()
const agg = this.aggregateSession(session)
// 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite),
// 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应
let cacheInput = 0
let cacheRead = 0
let cacheWrite = 0
let toolCalls = 0
let toolErrors = 0
for (const message of session.messages) {
const m = message as {
role?: string
usage?: { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
content?: unknown
isError?: unknown
}
if (m.role === 'assistant') {
if (m.usage) {
cacheInput += Number(m.usage.input ?? 0) || 0
cacheRead += Number(m.usage.cacheRead ?? 0) || 0
cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') toolCalls += 1
}
}
} else if (m.role === 'toolResult' && m.isError === true) {
toolErrors += 1
}
}
const denominator = cacheInput + cacheRead + cacheWrite
const denominator = agg.cacheInput + agg.cacheRead + agg.cacheWrite
const info: SessionStatsInfo = {
tokens: { ...stats.tokens },
cost: stats.cost,
@@ -581,15 +622,15 @@ export class AgentService {
context: this.buildContextInfo(session),
...(denominator > 0
? {
cacheHitRate: Math.round((cacheRead / denominator) * 100),
cacheTokens: { read: cacheRead, write: cacheWrite, input: cacheInput }
cacheHitRate: Math.round((agg.cacheRead / denominator) * 100),
cacheTokens: { read: agg.cacheRead, write: agg.cacheWrite, input: agg.cacheInput }
}
: {}),
...(toolCalls > 0
...(agg.toolCalls > 0
? {
toolStats: {
calls: toolCalls,
successRate: Math.round(((toolCalls - toolErrors) / toolCalls) * 100)
calls: agg.toolCalls,
successRate: Math.round(((agg.toolCalls - agg.toolErrors) / agg.toolCalls) * 100)
}
}
: {})