feat(wiki): record per-run token usage so operators can see what each template burns

This commit is contained in:
matevip 2026-05-12 14:35:17 +08:00
parent e911af2192
commit 465a727be2
5 changed files with 87 additions and 8 deletions

View File

@ -58,6 +58,15 @@ public class WikiTransformationRunEntity {
*/
private Long outputPageId;
/** Prompt-side tokens reported by the provider (Spring AI Usage). */
private Long inputTokens;
/** Completion-side tokens reported by the provider. */
private Long outputTokens;
/** Provider's own total (usually input + output, but providers vary). */
private Long totalTokens;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -227,9 +227,10 @@ public class WikiTransformationExecutor {
ChatModel chatModel = buildChatModel(resolvedModelId);
run.setModelId(resolvedModelId);
String output = callOnce(chatModel, systemPrompt, userPrompt);
CallResult first = callOnce(chatModel, systemPrompt, userPrompt);
accumulateUsage(run, first);
if (wantJson) {
String coerced = coerceToJson(output);
String coerced = coerceToJson(first.text());
if (coerced != null) {
// Wrap in a fenced block so UI rendering and save-as-page
// keep the existing markdown contract. The raw JSON is the
@ -240,18 +241,22 @@ public class WikiTransformationExecutor {
log.info("[WikiTransformation] JSON parse failed for template={}; retrying with stricter reminder",
transformation.getName());
String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。";
String retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
String coercedRetry = coerceToJson(retry);
CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
accumulateUsage(run, retry);
String coercedRetry = coerceToJson(retry.text());
if (coercedRetry != null) {
return "```json\n" + coercedRetry + "\n```";
}
throw new IllegalStateException("LLM output is not valid JSON after one retry");
}
return output;
return first.text();
}
/** One LLM call, returns the cleaned output. Throws when the call yields blank. */
private String callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) {
/** Tuple returned from a single LLM call: cleaned text + usage (null when provider didn't surface usage). */
private record CallResult(String text, Long inputTokens, Long outputTokens, Long totalTokens) {}
/** One LLM call, returns the cleaned output + provider usage. Throws when the call yields blank. */
private CallResult callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) {
ChatResponse resp = chatModel.call(new Prompt(List.of(
new SystemMessage(systemPrompt), new UserMessage(userPrompt))));
String rawOutput = (resp == null || resp.getResult() == null
@ -264,7 +269,31 @@ public class WikiTransformationExecutor {
if (output.isBlank()) {
throw new IllegalStateException("LLM output was empty after cleanup");
}
return output;
Long in = null, out = null, total = null;
try {
if (resp.getMetadata() != null && resp.getMetadata().getUsage() != null) {
var u = resp.getMetadata().getUsage();
in = u.getPromptTokens() == null ? null : u.getPromptTokens().longValue();
out = u.getCompletionTokens() == null ? null : u.getCompletionTokens().longValue();
total = u.getTotalTokens() == null ? null : u.getTotalTokens().longValue();
}
} catch (Exception ignored) {
// Usage extraction is best-effort different providers expose it differently.
}
return new CallResult(output, in, out, total);
}
/** Add provider-reported usage onto the run row (accumulates across retries). */
private static void accumulateUsage(WikiTransformationRunEntity run, CallResult call) {
if (call.inputTokens() != null) {
run.setInputTokens((run.getInputTokens() == null ? 0L : run.getInputTokens()) + call.inputTokens());
}
if (call.outputTokens() != null) {
run.setOutputTokens((run.getOutputTokens() == null ? 0L : run.getOutputTokens()) + call.outputTokens());
}
if (call.totalTokens() != null) {
run.setTotalTokens((run.getTotalTokens() == null ? 0L : run.getTotalTokens()) + call.totalTokens());
}
}
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =

View File

@ -0,0 +1,8 @@
-- Record per-run token usage so operators can see which templates burn the
-- most tokens and which models produce the most expensive output. Spring AI
-- surfaces the values via ChatResponseMetadata.getUsage(); the executor
-- snapshots them into the run row after the LLM call.
ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS input_tokens BIGINT NULL;
ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS output_tokens BIGINT NULL;
ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS total_tokens BIGINT NULL;

View File

@ -0,0 +1,19 @@
-- Record per-run token usage. See h2 sibling for prose explanation.
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run'
AND COLUMN_NAME = 'input_tokens');
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN input_tokens BIGINT NULL', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run'
AND COLUMN_NAME = 'output_tokens');
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN output_tokens BIGINT NULL', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run'
AND COLUMN_NAME = 'total_tokens');
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN total_tokens BIGINT NULL', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -95,6 +95,9 @@
{{ rawTitleFor(run.rawId) }}
· {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }}
· {{ formatDuration(run.durationMs) }}
<span v-if="run.totalTokens" class="run-tokens">
· {{ formatTokens(run.inputTokens) }} / {{ formatTokens(run.outputTokens) }}
</span>
</span>
<span v-if="run.outputPageId" class="run-saved-badge">
{{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }}
@ -299,6 +302,9 @@ interface WikiTransformationRun {
createTime: string
triggeredBy: string
outputPageId: number | null
inputTokens: number | null
outputTokens: number | null
totalTokens: number | null
}
const { t } = useI18n()
@ -373,6 +379,13 @@ function formatDuration(ms: number | null): string {
return `${(ms / 1000).toFixed(1)}s`
}
function formatTokens(n: number | null): string {
if (n == null) return '—'
if (n < 1000) return String(n)
if (n < 1_000_000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'm'
}
async function loadAll() {
if (!store.currentKB) return
loading.value = true
@ -746,6 +759,7 @@ onMounted(async () => {
.run-status--running, .run-status--pending { background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
.run-status--cancelled { background: var(--mc-bg-muted); color: var(--mc-text-tertiary); }
.run-meta { color: var(--mc-text-tertiary); }
.run-tokens { color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 11px; }
.run-output {
margin-top: 6px;
padding: 10px 12px;