feat(delegation): 本轮 token 总量页脚 + 子 Agent 用量向上滚加

- 新增 DelegatedUsageAccumulator:按根会话累加每个完成子 Agent 用量,根 Agent
  在 _usage_final 处一次性 drain 整棵子树、中间层得 0,每个子只计一次、无重复计数
- runSingleChild 增 accumulateToParent:同步/并行/计划步骤委派计入父轮,游离异步不计
- ReAct / Plan-Execute 在 _usage_final 处 drain 并加进 token + 附委派分解字段,
  doFinally 清理防泄漏;该事件同时驱动实时 SSE 与 mate_message 落库,实时/刷新一致
- 前端消息底部新增 Σ<total> tok 徽标(tooltip 含委派分解),总量仅取 message 用量
- 测试:补 DelegatedUsageAccumulator 接线,委派全套 59 绿
This commit is contained in:
matevip 2026-06-30 17:21:10 +08:00
parent 6854f8cc44
commit fa4e7018a0
14 changed files with 269 additions and 17 deletions

View File

@ -0,0 +1,95 @@
package vip.mate.agent.delegation;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-conversation accumulator for delegated sub-agent token usage.
*
* <p>When a parent turn delegates work to sub-agents, each child runs as its own
* agent invocation in a separate conversation, so its token usage never lands in
* the parent graph's own usage counters. This accumulator lets the delegation
* layer record each completed child's usage keyed by the <em>root</em>
* (user-facing) conversation, so the parent turn's {@code _usage_final} emission
* can roll the whole sub-tree up into the turn total surfaced live on the SSE
* stream and persisted on the assistant message.
*
* <p><b>No double counting across nesting:</b> every descendant (child,
* grandchild, ) records against the same root conversation, because the
* delegation context carries the original root forward. The root agent drains
* the full tree exactly once at its {@code _usage_final}; intermediate agents
* drain their own conversation key, which holds nothing. A child agent's own
* usage (returned to its parent and recorded once by the parent's delegation
* call) is therefore counted a single time.
*
* <p>Exposed via a static accessor because the StateGraph agents that emit
* {@code _usage_final} are built per-config and are not Spring-managed beans, so
* they cannot receive this singleton by constructor injection.
*/
@Component
public class DelegatedUsageAccumulator {
private static volatile DelegatedUsageAccumulator instance;
@PostConstruct
void register() {
instance = this;
}
/** Returns the singleton, or {@code null} before the context is ready. */
public static DelegatedUsageAccumulator getInstance() {
return instance;
}
private record Usage(AtomicLong prompt, AtomicLong completion) {
Usage() {
this(new AtomicLong(), new AtomicLong());
}
}
private final Map<String, Usage> byConversation = new ConcurrentHashMap<>();
/** Record one completed child's usage against its root conversation. */
public void add(String rootConversationId, int promptTokens, int completionTokens) {
if (rootConversationId == null || rootConversationId.isBlank()) {
return;
}
if (promptTokens <= 0 && completionTokens <= 0) {
return;
}
Usage u = byConversation.computeIfAbsent(rootConversationId, k -> new Usage());
if (promptTokens > 0) {
u.prompt().addAndGet(promptTokens);
}
if (completionTokens > 0) {
u.completion().addAndGet(completionTokens);
}
}
/** Token pair carrier for a drained accumulation. */
public record Drained(long promptTokens, long completionTokens) {
public boolean isEmpty() {
return promptTokens <= 0 && completionTokens <= 0;
}
}
/** Atomically read and remove the accumulated delegated usage for a conversation. */
public Drained drain(String conversationId) {
if (conversationId == null) {
return new Drained(0, 0);
}
Usage u = byConversation.remove(conversationId);
return u == null ? new Drained(0, 0) : new Drained(u.prompt().get(), u.completion().get());
}
/** Discard any accumulation for a conversation — leak guard on error/cancel. */
public void clear(String conversationId) {
if (conversationId != null) {
byConversation.remove(conversationId);
}
}
}

View File

@ -13,6 +13,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager;
@ -282,10 +283,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(conversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"promptTokens", promptTokens,
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -304,6 +313,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
})
// Leak guard: discard delegated usage if the turn ends without
// emitting _usage_final (error / cancel).
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(conversationId);
});
} catch (Exception e) {
setState(AgentState.ERROR);
@ -434,10 +449,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
})
// 流正常完成后追加内部 usage 事件
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(conversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"promptTokens", promptTokens,
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -457,6 +480,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
})
// Leak guard: discard delegated usage if the turn ends without
// emitting _usage_final (error / cancel).
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(conversationId);
});
} catch (Exception e) {
setState(AgentState.ERROR);

View File

@ -11,6 +11,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
@ -144,6 +145,10 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// Root conversation for this turn used to roll delegated sub-agent
// token usage into the turn's _usage_final and to clear the accumulator
// on terminal so an errored turn never leaks an entry.
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
// 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
@ -209,10 +214,21 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
// Roll delegated sub-agent usage (whole sub-tree, keyed by this
// root conversation) into the turn total so the assistant
// message reflects what the orchestrator + all children cost.
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(usageConversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"promptTokens", promptTokens,
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -223,6 +239,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
.doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
})
// Leak guard: if the turn ends without emitting _usage_final
// (error / cancel), discard any delegated usage left for this
// conversation so it can't bleed into a later turn.
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(usageConversationId);
});
}

View File

@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Value;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
@ -152,6 +153,7 @@ public class DelegateAgentTool {
private final SubagentRegistry subagentRegistry;
private final AuditEventService auditEventService;
private final AsyncTaskService asyncTaskService;
private final DelegatedUsageAccumulator delegatedUsageAccumulator;
/** Max characters of the task description persisted in {@code request_json}.
* Anything longer is truncated full task is still inside the running
@ -342,7 +344,7 @@ public class DelegateAgentTool {
ChildResult result;
try {
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId,
parentOrigin, rootConversationId, subagentId, childDepth);
parentOrigin, rootConversationId, subagentId, childDepth, true);
} finally {
// Cleanup relay + registry regardless of how the child returned
// (success / exception / interruption) so we never leak entries.
@ -561,7 +563,7 @@ public class DelegateAgentTool {
for (PreparedChild p : prepared) {
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId,
parentOriginParallel, rootConvFinal, p.subagentId, childDepth),
parentOriginParallel, rootConvFinal, p.subagentId, childDepth, true),
DELEGATION_EXECUTOR);
// Broadcast per-child completion as soon as each child finishes
@ -869,9 +871,12 @@ public class DelegateAgentTool {
currentUser,
() -> {
try {
// Detached async child: its usage belongs to the later
// task_output retrieval, not the spawning turn, so do not
// roll it into the parent's _usage_final.
ChildResult childResult = runSingleChild(0, target, task,
parentConversationId, childConversationId, parentOrigin,
rootConvAsync, subagentId, childDepth);
rootConvAsync, subagentId, childDepth, false);
return childResult.toToolResponse(target.getName());
} finally {
subagentRegistry.get(subagentId).ifPresent(rec -> {
@ -1081,7 +1086,8 @@ public class DelegateAgentTool {
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
String parentConversationId, String childConversationId,
ChatOrigin parentOrigin,
String rootConversationId, String subagentId, int childDepth) {
String rootConversationId, String subagentId, int childDepth,
boolean accumulateToParent) {
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (relayChildEvents) {
streamTracker.register(childConversationId);
@ -1109,6 +1115,14 @@ public class DelegateAgentTool {
target.getId(), task, childConversationId, childOrigin);
long durationMs = System.currentTimeMillis() - startTime;
String rawResult = chatResult.content();
// Roll this child's usage up to the root (user-facing) turn so the
// parent's _usage_final reflects the whole delegation sub-tree.
// Skipped for detached async children, whose result belongs to a
// later task_output retrieval, not the spawning turn.
if (accumulateToParent) {
delegatedUsageAccumulator.add(rootConversationId,
chatResult.promptTokens(), chatResult.completionTokens());
}
// Measure lengths before truncation so ChildResult carries accurate metadata.
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens());

View File

@ -57,7 +57,8 @@ class DelegateAgentToolDenyListTest {
vip.mate.task.AsyncTaskService asyncTaskService = mock(vip.mate.task.AsyncTaskService.class);
tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService,
objectMapper, registry, auditEventService, asyncTaskService);
objectMapper, registry, auditEventService, asyncTaskService,
new vip.mate.agent.delegation.DelegatedUsageAccumulator());
}
@AfterEach

View File

@ -41,6 +41,8 @@ class DelegateAgentToolTest {
@Mock ConversationService conversationService;
@Mock AuditEventService auditEventService;
@Spy SubagentRegistry subagentRegistry = new SubagentRegistry();
@Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator =
new vip.mate.agent.delegation.DelegatedUsageAccumulator();
@InjectMocks DelegateAgentTool delegateAgentTool;

View File

@ -66,7 +66,8 @@ class DelegateAsyncTaskOutputAttributionTest {
void setUp() {
tool = new DelegateAgentTool(
agentService, agentMapper, streamTracker, conversationService,
objectMapper, subagentRegistry, auditEventService, asyncTaskService);
objectMapper, subagentRegistry, auditEventService, asyncTaskService,
new vip.mate.agent.delegation.DelegatedUsageAccumulator());
}
@AfterEach

View File

@ -69,7 +69,8 @@ class DelegateAsyncToolTest {
void setUp() {
tool = new DelegateAgentTool(
agentService, agentMapper, streamTracker, conversationService,
objectMapper, subagentRegistry, auditEventService, asyncTaskService);
objectMapper, subagentRegistry, auditEventService, asyncTaskService,
new vip.mate.agent.delegation.DelegatedUsageAccumulator());
// resolveParentConversationId reads from ToolExecutionContext first;
// seed it so the async delegation has a parent to attach the task to.
ToolExecutionContext.set("parent-conv-1", "user-1");

View File

@ -50,6 +50,8 @@ class DelegateEventSequenceTest {
@Mock ConversationService conversationService;
@Mock AuditEventService auditEventService;
@Spy SubagentRegistry subagentRegistry = new SubagentRegistry();
@Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator =
new vip.mate.agent.delegation.DelegatedUsageAccumulator();
@InjectMocks DelegateAgentTool delegateAgentTool;

View File

@ -408,6 +408,23 @@
class="action-model"
:title="replyModelTitle"
>{{ replyModel }}</span>
<!-- Turn token total (assistant only): own usage + delegated sub-agents -->
<span
v-if="role === 'assistant' && tokenUsage"
class="action-tokens"
:title="tokenUsage.delegated > 0
? $t('chat.tokenUsageTooltipDelegated', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
output: tokenUsage.output.toLocaleString(),
delegated: tokenUsage.delegated.toLocaleString(),
})
: $t('chat.tokenUsageTooltip', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
output: tokenUsage.output.toLocaleString(),
})"
>Σ {{ fmtTokens(tokenUsage.total) }} tok</span>
<!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) -->
<span
v-if="role === 'assistant' && routingBadge"
@ -457,7 +474,7 @@ import { storeToRefs } from 'pinia'
import PlanStepsPanel from './PlanStepsPanel.vue'
import UserMessageContent from './UserMessageContent.vue'
import type { BrowserAction } from './BrowserTimeline.vue'
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta, DelegationNode } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError'
const { t, locale } = useI18n()
@ -1001,6 +1018,46 @@ const useSegmentedView = computed(() =>
segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→'))
)
/**
* Total token consumption for this assistant turn, rolled up the way a
* multi-agent orchestrator should report it: the parent message's own usage
* PLUS every delegated sub-agent's usage (depth-1 delegation segments and
* their nested children). Returns null when nothing is known yet.
*/
const tokenUsage = computed(() => {
const m = props.message
if (m.role !== 'assistant') return null
// Total comes solely from the message usage, which the backend already rolls
// delegated sub-agent tokens into (so live and reloaded values match and there
// is no double counting against the segment sum below).
const input = m.promptTokens || 0
const output = m.completionTokens || 0
const total = input + output
if (total <= 0) return null
// Informational breakdown for the tooltip: how much of that total came from
// delegated sub-agents. Derived from the delegation segments, so it is present
// live and degrades to 0 after reload (the segments are not persisted).
let delegated = 0
const addNodes = (nodes?: DelegationNode[]) => {
if (!nodes) return
for (const n of nodes) {
delegated += (n.promptTokens || 0) + (n.completionTokens || 0)
addNodes(n.children)
}
}
for (const s of segments.value) {
if (s.type !== 'tool_call') continue
delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0)
addNodes(s.childTimeline?.children)
}
return { input, output, total, delegated: Math.min(delegated, total) }
})
/** Compact token count, e.g. 67890 → "67.9k". */
function fmtTokens(n: number): string {
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
}
/**
* Group segments by iterationIndex so each ReAct iteration renders as its own
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
@ -1772,6 +1829,18 @@ watch(isGenerating, (generating) => {
white-space: nowrap;
}
.action-tokens {
font-size: 11px;
color: var(--mc-text-tertiary, #94a3b8);
margin-left: 4px;
padding: 1px 6px;
border-radius: 4px;
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
user-select: text;
white-space: nowrap;
}
.action-routing {
font-size: 11px;
color: var(--mc-primary, #d96d46);

View File

@ -1091,6 +1091,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (resultPreview) seg.toolResult = resultPreview
const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens)
if (suffix) seg.toolArgs = (seg.toolArgs || '').trimEnd() + suffix
// Keep tokens as numbers too so the message footer can roll this child
// up into the turn total (the suffix above is display-only).
if (promptTokens) seg.delegPromptTokens = promptTokens
if (completionTokens) seg.delegCompletionTokens = completionTokens
return true
}
if (subagentId) {

View File

@ -196,6 +196,8 @@ export default {
downloadFailed: 'Download failed: {reason}',
regenerate: 'Regenerate',
replyModel: 'Reply model: {model}',
tokenUsageTooltip: 'This turn used {total} tokens ({input} in · {output} out)',
tokenUsageTooltipDelegated: 'This turn used {total} tokens ({input} in · {output} out), of which {delegated} came from delegated sub-agents',
routing: {
kind: {
image: 'image',

View File

@ -196,6 +196,8 @@ export default {
downloadFailed: '下载失败:{reason}',
regenerate: '重新生成',
replyModel: '本条回复模型: {model}',
tokenUsageTooltip: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}',
tokenUsageTooltipDelegated: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}),其中子 Agent 委派占 {delegated}',
routing: {
kind: {
image: '图片',

View File

@ -214,6 +214,13 @@ export interface MessageSegment {
toolSuccess?: boolean
/** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */
toolCallId?: string
/**
* For a top-level delegation segment (toolName starts with "→"): the depth-1
* child agent's own token usage, kept as numbers (alongside the human-readable
* suffix in toolArgs) so the message footer can roll children up into a turn total.
*/
delegPromptTokens?: number
delegCompletionTokens?: number
/** type=content */
text?: string
/** type=phase */