From bb4f934b3dc6ec2bed2eb4dc35efb00809f6c4a0 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 7 Oct 2026 23:47:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=BC=93=E5=AD=98=E5=86=99?= =?UTF-8?q?=E9=87=8F=E4=B8=8E=E8=AF=B7=E6=B1=82=E8=BD=AE=E5=BB=93=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6=EF=BC=8C=E4=BE=BF=E4=BA=8E=E6=A0=B8=E5=AF=B9=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E7=BC=93=E5=AD=98=E6=98=AF=E5=90=A6=E7=9C=9F=E7=9A=84?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。 Co-authored-by: Cursor --- src/llm/client.ts | 161 ++++++++++++++++++++++++++---- src/llm/context-outline.ts | 181 ++++++++++++++++++++++++++++++++++ src/llm/stream-complete.ts | 9 +- src/llm/token-tracker.ts | 122 +++++++++++++++++++---- src/log.ts | 3 + src/server/stats-handlers.ts | 3 + src/stats/token-store.ts | 65 +++++++++++- src/types/context-trace.ts | 5 + tests/context-outline.test.ts | 114 +++++++++++++++++++++ tests/token-usage.test.ts | 15 ++- web/stats.css | 20 ++++ web/stats.html | 5 +- web/stats.js | 85 +++++++++++++++- 13 files changed, 738 insertions(+), 50 deletions(-) create mode 100644 src/llm/context-outline.ts create mode 100644 tests/context-outline.test.ts diff --git a/src/llm/client.ts b/src/llm/client.ts index ed9037b..77348ad 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -57,8 +57,33 @@ export type TokenUsage = { cachedTokens?: number; /** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */ cacheMissTokens?: number; + /** 上游若单独返回缓存写入 token(如 cache_creation_input_tokens) */ + cacheWriteTokens?: number; }; +/** 用量行上的调度信息。不包含提示词或产物正文。 */ +export type DictateUsageStep = "select" | "fill" | "fill-retry" | "reply" | "play"; + +export type UsageDetail = { + turnId?: string; + step?: DictateUsageStep; + retryIndex?: number; + segmentChars?: { + rules: number; + history: number; + products: number; + injected: number; + tail: number; + }; + plannedTags?: string[]; + succeededTags?: string[]; + failedTags?: string[]; + finish?: string; +}; + +/** complete 返回后由统计层写入 recordId,供补记成功或失败的 tag。 */ +export type UsageSink = { recordId?: string }; + export type CompleteResult = { content: string; /** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */ @@ -84,6 +109,14 @@ export type CompleteOptions = { label?: string; /** 取消进行中的 LLM 请求(停止并重试) */ signal?: AbortSignal; + /** + * 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游, + * 避免开头 message 一变就换缓存节点。其它渠道不发送。 + */ + sessionId?: string; + /** 统计补充。不进上游请求体。 */ + usageDetail?: UsageDetail; + usageSink?: UsageSink; }; export type ToolChoice = @@ -132,6 +165,33 @@ export type LlmProvider = { }; const JSON_OBJECT_HINT = "请用 JSON 对象回复。"; +const OPENROUTER_SESSION_ID_MAX = 256; + +/** OpenRouter 用 `session_id` 做粘滞路由;其它 OpenAI 兼容渠道不认识这个字段。 */ +export function isOpenRouterBaseUrl(baseUrl: string): boolean { + const trimmed = baseUrl.trim(); + if (!trimmed) return false; + try { + const withProtocol = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + const host = new URL(withProtocol).hostname.toLowerCase(); + return host === "openrouter.ai" || host.endsWith(".openrouter.ai"); + } catch { + return false; + } +} + +/** 合法则返回要写入请求体的 session_id;非 OpenRouter 或空 id 则不写。 */ +export function openRouterSessionId( + baseUrl: string, + sessionId: string | undefined, +): string | undefined { + if (!isOpenRouterBaseUrl(baseUrl)) return undefined; + const id = sessionId?.trim(); + if (!id) return undefined; + return id.slice(0, OPENROUTER_SESSION_ID_MAX); +} function messagesMentionJson(messages: ChatMessage[]): boolean { for (const message of messages) { @@ -210,6 +270,9 @@ export function buildRequestBody( body.stream_options = { include_usage: true }; } + const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId); + if (sessionId) body.session_id = sessionId; + return body; } @@ -232,6 +295,20 @@ function readCacheMissTokens(u: Record): number | undefined { return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens); } +function readCacheWriteTokens(u: Record): number | undefined { + const details = u.prompt_tokens_details ?? u.promptTokensDetails; + if (details && typeof details === "object") { + const row = details as Record; + const nested = readFiniteNumber(row.cache_write_tokens ?? row.cacheWriteTokens); + if (nested != null) return nested; + } + return readFiniteNumber( + u.cache_creation_input_tokens ?? + u.cache_write_input_tokens ?? + u.cacheWriteTokens, + ); +} + /** Parse OpenAI-compatible usage object, including provider-specific cache fields. */ export function parseUsage(raw: unknown): TokenUsage | undefined { if (!raw || typeof raw !== "object") return undefined; @@ -243,6 +320,7 @@ export function parseUsage(raw: unknown): TokenUsage | undefined { const cachedTokens = readCachedTokens(u); const cacheMissTokens = readCacheMissTokens(u); + const cacheWriteTokens = readCacheWriteTokens(u); return { promptTokens: Number.isFinite(prompt) ? prompt : 0, @@ -253,22 +331,66 @@ export function parseUsage(raw: unknown): TokenUsage | undefined { (Number.isFinite(completion) ? completion : 0), ...(cachedTokens != null ? { cachedTokens } : {}), ...(cacheMissTokens != null ? { cacheMissTokens } : {}), + ...(cacheWriteTokens != null ? { cacheWriteTokens } : {}), }; } -function extractMessageParts(message: Record | undefined): { +function stringifyToolArguments(value: unknown): string { + if (typeof value === "string" && value.trim()) return value; + if (value && typeof value === "object") return JSON.stringify(value); + return "{}"; +} + +function toolCallFromRow(row: Record, index: number): ParsedToolCall | null { + const fn = row.function; + const nested = fn && typeof fn === "object" ? (fn as Record) : null; + const source = nested ?? row; + const name = typeof source.name === "string" ? source.name.trim() : ""; + if (!name) return null; + const rawId = typeof row.id === "string" ? row.id.trim() : ""; + return { + id: rawId || `call-${index}`, + name, + arguments: stringifyToolArguments( + source.arguments ?? source.args ?? row.arguments ?? row.args ?? row.input, + ), + }; +} + +function embeddedContent(raw: unknown): { text: string | null; calls: ParsedToolCall[] } { + if (typeof raw === "string") return { text: raw.trim() || null, calls: [] }; + if (!Array.isArray(raw)) return { text: raw == null ? null : "", calls: [] }; + const texts: string[] = []; + const calls: ParsedToolCall[] = []; + raw.forEach((part, index) => { + if (!part || typeof part !== "object") return; + const row = part as Record; + if (typeof row.text === "string" && row.text.trim()) texts.push(row.text.trim()); + const fn = row.functionCall ?? row.function_call; + if (row.type === "tool_use" || (fn && typeof fn === "object")) { + const nested = fn && typeof fn === "object" ? (fn as Record) : row; + const parsed = toolCallFromRow( + { + id: row.id, + name: nested.name ?? row.name, + arguments: nested.args ?? nested.arguments ?? row.input ?? row.arguments, + }, + index, + ); + if (parsed) calls.push(parsed); + } + }); + return { text: texts.join("\n").trim() || null, calls }; +} + +/** 从一条 assistant message 取出正文和工具调用。对象参数、缺 id、content 片段里的调用都收。 */ +export function extractMessageParts(message: Record | undefined): { content: string | null; reasoning?: string; toolCalls: ParsedToolCall[]; } { if (!message) return { content: "", toolCalls: [] }; - const rawContent = message.content; - const content = - typeof rawContent === "string" - ? rawContent.trim() || null - : rawContent == null - ? null - : ""; + const embedded = embeddedContent(message.content); const reasoningRaw = (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) || @@ -280,21 +402,18 @@ function extractMessageParts(message: Record | undefined): { const toolCalls: ParsedToolCall[] = []; const rawCalls = message.tool_calls; if (Array.isArray(rawCalls)) { - for (const call of rawCalls) { - if (!call || typeof call !== "object") continue; - const c = call as Record; - const fn = c.function; - if (!fn || typeof fn !== "object") continue; - const f = fn as Record; - const name = typeof f.name === "string" ? f.name : ""; - const id = typeof c.id === "string" ? c.id : ""; - const args = - typeof f.arguments === "string" ? f.arguments : "{}"; - if (name && id) { - toolCalls.push({ id, name, arguments: args }); - } + rawCalls.forEach((call, index) => { + if (!call || typeof call !== "object") return; + const parsed = toolCallFromRow(call as Record, index); + if (parsed) toolCalls.push(parsed); + }); + } + for (const call of embedded.calls) { + if (!toolCalls.some((item) => item.id === call.id && item.name === call.name)) { + toolCalls.push(call); } } + const content = embedded.text; if (toolCalls.length > 0) { return { content, reasoning: reasoning || undefined, toolCalls }; diff --git a/src/llm/context-outline.ts b/src/llm/context-outline.ts new file mode 100644 index 0000000..4c52ef6 --- /dev/null +++ b/src/llm/context-outline.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import type { ToolDefinition } from "./client.js"; + +/** 一段请求的位置标签。只存标签、字数和哈希,不存正文。 */ +export type ContextSegment = { + tag: string; + chars: number; + hash: string; +}; + +/** 挂在一条回复上的各次请求轮廓,选题和填写都保留。 */ +export type RequestOutline = { + at: string; + caller: string; + step?: string; + turnId?: string; + model?: string; + promptTokens?: number; + completionTokens?: number; + cachedTokens?: number; + cacheMissTokens?: number; + cacheWriteTokens?: number; + segments: ContextSegment[]; +}; + +/** 和同会话上一条请求比,前缀从哪里开始对不上。 */ +export type CacheBreakInfo = { + /** 本窗口里没有更早的同会话请求 */ + cold: boolean; + /** -1:本条在上一请求的前缀里整段对上 */ + breakAt: number; + tag?: string; + /** 断点之前、和上一请求逐段相同的段数 */ + matched: number; +}; + +const SPLIT = + /(?:^|\n)(#{2,4} [^\n]+|最后一轮用户输入|【用户】|【助手】)/g; + +function hashText(text: string): string { + return createHash("sha256").update(text).digest("hex").slice(0, 12); +} + +function segment(tag: string, text: string): ContextSegment { + return { tag, chars: text.length, hash: hashText(text) }; +} + +function headingTag(title: string): string { + const beforeOrder = title.split("〔")[0]?.trim() ?? title; + const beforeParen = beforeOrder.split(/(|\(/)[0]?.trim() ?? beforeOrder; + const beforeAt = beforeParen.split(" @ ")[0]?.trim() ?? beforeParen; + const tag = (beforeAt || title).trim(); + return tag.length > 48 ? `${tag.slice(0, 48)}…` : tag; +} + +function collectMarks(content: string): Array<{ index: number; tag: string }> { + const marks: Array<{ index: number; tag: string }> = []; + let section = ""; + for (const match of content.matchAll(SPLIT)) { + const raw = match[1] ?? ""; + const start = (match.index ?? 0) + (match[0].startsWith("\n") ? 1 : 0); + if (raw.startsWith("#")) { + const level = /^#+/.exec(raw)?.[0].length ?? 2; + const title = raw.replace(/^#+\s*/, ""); + const tag = headingTag(title); + if (level === 2) section = tag; + marks.push({ index: start, tag }); + continue; + } + if (raw === "最后一轮用户输入" && section.startsWith("本轮")) { + marks.push({ index: start, tag: "最后一轮用户输入" }); + continue; + } + if ( + (raw === "【用户】" || raw === "【助手】") && + section.startsWith("更早的对话") + ) { + marks.push({ + index: start, + tag: raw === "【用户】" ? "更早·用户" : "更早·助手", + }); + } + } + return marks; +} + +/** 把一块正文切成有序标签。前缀里的标题与更早对话轮次分开,方便对上断点。 */ +export function outlineBlock(fallbackTag: string, content: string): ContextSegment[] { + if (!content) return []; + const marks = collectMarks(content); + if (marks.length === 0) return [segment(fallbackTag, content)]; + const out: ContextSegment[] = []; + if (marks[0]!.index > 0) { + const head = content.slice(0, marks[0]!.index); + if (head.trim()) out.push(segment(fallbackTag, head)); + } + for (let i = 0; i < marks.length; i++) { + const end = i + 1 < marks.length ? marks[i + 1]!.index : content.length; + out.push(segment(marks[i]!.tag, content.slice(marks[i]!.index, end))); + } + return out; +} + +function roleTag(role: string): string { + if (role === "system") return "规则"; + if (role === "user") return "用户"; + if (role === "assistant") return "助手"; + if (role === "tool") return "工具结果"; + return role || "消息"; +} + +/** 按实际送出的消息切段。工具定义若有,放在正文之前。 */ +export function outlineRequest( + messages: Array<{ role: string; content?: string | null }>, + tools?: ToolDefinition[], +): ContextSegment[] { + const out: ContextSegment[] = []; + if (tools?.length) { + out.push(segment("工具", JSON.stringify(tools))); + } + for (const message of messages) { + const content = typeof message.content === "string" ? message.content : ""; + out.push(...outlineBlock(roleTag(message.role), content)); + } + return out; +} + +/** + * 返回下一请求里第一段对不上的下标。 + * 下一请求比上一请求更短、但已有段全部相同,视为整段接上(-1)。 + */ +export function firstCacheBreak( + prev: Array>, + next: Array>, +): number { + const shared = Math.min(prev.length, next.length); + for (let i = 0; i < shared; i++) { + if (prev[i]!.tag !== next[i]!.tag || prev[i]!.hash !== next[i]!.hash) return i; + } + if (next.length > prev.length) return prev.length; + return -1; +} + +export function annotateCacheBreaks< + T extends { id: string; at: string; sessionId?: string; segments?: ContextSegment[] }, +>(records: T[]): Map { + const bySession = new Map(); + for (const record of records) { + const key = record.sessionId?.trim() || `__none__:${record.id}`; + const list = bySession.get(key) ?? []; + list.push(record); + bySession.set(key, list); + } + const marks = new Map(); + for (const list of bySession.values()) { + list.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id)); + let prev: ContextSegment[] | undefined; + for (const record of list) { + const segments = record.segments ?? []; + if (!prev) { + marks.set(record.id, { + cold: true, + breakAt: segments.length ? 0 : -1, + tag: segments[0]?.tag, + matched: 0, + }); + prev = segments; + continue; + } + const breakAt = firstCacheBreak(prev, segments); + marks.set(record.id, { + cold: false, + breakAt, + tag: breakAt >= 0 ? segments[breakAt]?.tag : undefined, + matched: breakAt < 0 ? segments.length : breakAt, + }); + prev = segments; + } + } + return marks; +} diff --git a/src/llm/stream-complete.ts b/src/llm/stream-complete.ts index 7a5600b..0faa26e 100644 --- a/src/llm/stream-complete.ts +++ b/src/llm/stream-complete.ts @@ -29,6 +29,9 @@ function applyToolCallDelta( const f = fn as Record; if (typeof f.name === "string") entry.name = f.name; if (typeof f.arguments === "string") entry.arguments += f.arguments; + else if (f.arguments && typeof f.arguments === "object" && !entry.arguments) { + entry.arguments = JSON.stringify(f.arguments); + } } acc.set(index, entry); } @@ -36,10 +39,10 @@ function applyToolCallDelta( function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] { const out: ParsedToolCall[] = []; - for (const [, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) { + for (const [index, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) { const name = entry.name?.trim(); - const id = entry.id?.trim(); - if (!name || !id) continue; + if (!name) continue; + const id = entry.id?.trim() || `call-${index}`; out.push({ id, name, arguments: entry.arguments || "{}" }); } return out; diff --git a/src/llm/token-tracker.ts b/src/llm/token-tracker.ts index 26bcca1..b4266da 100644 --- a/src/llm/token-tracker.ts +++ b/src/llm/token-tracker.ts @@ -6,7 +6,15 @@ import type { LlmProvider, StreamCallbacks, TokenUsage, + ToolDefinition, + UsageDetail, + UsageSink, } from "./client.js"; +import { + outlineRequest, + type ContextSegment, + type RequestOutline, +} from "./context-outline.js"; import { recordTokenUsage, toMessageTokenUsage, @@ -28,6 +36,9 @@ export type LlmTrackingContext = { pendingReasoning?: string; /** 全量请求上下文;挂到下一条「结果向」系统消息 */ pendingContextTrace?: LlmContextTrace; + /** 本条回复之前的各次请求轮廓;查看时选题和填写都在 */ + pendingOutlines?: RequestOutline[]; + lastSegments?: ContextSegment[]; }; function capturePendingTrace( @@ -41,12 +52,16 @@ function capturePendingTrace( caller: string | undefined, model?: string, generation?: CompleteOptions["generation"], + tools?: ToolDefinition[], ): void { + const segments = outlineRequest(messages, tools); + ctx.lastSegments = segments; ctx.pendingContextTrace = buildContextTrace({ caller: caller ?? "unknown", messages, model, generation, + segments, }); } @@ -55,9 +70,13 @@ function recordUsage( caller: string | undefined, model: string | undefined, usage: TokenUsage | undefined, + meta?: { detail?: UsageDetail; latencyMs?: number; sink?: UsageSink }, ): void { - if (!usage) return; - const record = recordTokenUsage({ + const segments = ctx.lastSegments; + ctx.lastSegments = undefined; + const detail = meta?.detail; + const record = usage + ? recordTokenUsage({ sessionId: ctx.sessionId, bookId: ctx.bookId, bookTitle: ctx.bookTitle, @@ -69,8 +88,36 @@ function recordUsage( totalTokens: usage.totalTokens, cachedTokens: usage.cachedTokens, cacheMissTokens: usage.cacheMissTokens, - }); - ctx.pendingUsage = toMessageTokenUsage(record); + cacheWriteTokens: usage.cacheWriteTokens, + turnId: detail?.turnId, + step: detail?.step, + retryIndex: detail?.retryIndex, + segmentChars: detail?.segmentChars, + plannedTags: detail?.plannedTags, + succeededTags: detail?.succeededTags, + failedTags: detail?.failedTags, + finish: detail?.finish, + latencyMs: meta?.latencyMs, + segments, + }) + : undefined; + if (record && meta?.sink) meta.sink.recordId = record.id; + if (record) ctx.pendingUsage = toMessageTokenUsage(record); + if (!segments?.length && !record) return; + const outline: RequestOutline = { + at: record?.at ?? new Date().toISOString(), + caller: record?.caller ?? caller ?? "unknown", + step: detail?.step, + turnId: detail?.turnId, + model: record?.model ?? model, + promptTokens: record?.promptTokens, + completionTokens: record?.completionTokens, + cachedTokens: record?.cachedTokens, + cacheMissTokens: record?.cacheMissTokens, + cacheWriteTokens: record?.cacheWriteTokens, + segments: segments ?? [], + }; + ctx.pendingOutlines = [...(ctx.pendingOutlines ?? []), outline]; } export class TokenTrackingProvider implements LlmProvider { @@ -79,6 +126,14 @@ export class TokenTrackingProvider implements LlmProvider { private readonly getContext: () => LlmTrackingContext, ) {} + /** 调用方未指定时,用本局会话 id,供 OpenRouter 粘滞路由。 */ + private withSession(options?: T): T { + const sessionId = this.getContext().sessionId?.trim(); + if (!sessionId) return (options ?? {}) as T; + if (options?.sessionId?.trim()) return options; + return { ...options, sessionId } as T; + } + async complete( messages: Parameters[0], options?: CompleteOptions, @@ -91,7 +146,8 @@ export class TokenTrackingProvider implements LlmProvider { messages, }, async () => { - const result = await this.inner.complete(messages, options); + const started = Date.now(); + const result = await this.inner.complete(messages, this.withSession(options)); const ctx = this.getContext(); capturePendingTrace( ctx, @@ -100,7 +156,11 @@ export class TokenTrackingProvider implements LlmProvider { result.model, options?.generation, ); - recordUsage(ctx, options?.caller, result.model, result.usage); + recordUsage(ctx, options?.caller, result.model, result.usage, { + detail: options?.usageDetail, + latencyMs: Date.now() - started, + sink: options?.usageSink, + }); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); } @@ -130,7 +190,8 @@ export class TokenTrackingProvider implements LlmProvider { }, async () => { let reasoningBuf = ""; - const result = await inner.completeStream!(messages, options, { + const started = Date.now(); + const result = await inner.completeStream!(messages, this.withSession(options), { onReasoningDelta: (delta) => { reasoningBuf += delta; const ctx = this.getContext(); @@ -147,7 +208,11 @@ export class TokenTrackingProvider implements LlmProvider { result.model, options?.generation, ); - recordUsage(ctx, options?.caller, result.model, result.usage); + recordUsage(ctx, options?.caller, result.model, result.usage, { + detail: options?.usageDetail, + latencyMs: Date.now() - started, + sink: options?.usageSink, + }); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); } @@ -169,7 +234,11 @@ export class TokenTrackingProvider implements LlmProvider { tools: options.tools, }, async () => { - const result = await this.inner.completeWithTools(messages, options); + const started = Date.now(); + const result = await this.inner.completeWithTools( + messages, + this.withSession(options), + ); const ctx = this.getContext(); capturePendingTrace( ctx, @@ -177,8 +246,13 @@ export class TokenTrackingProvider implements LlmProvider { options.caller, result.model, options.generation, + options.tools, ); - recordUsage(ctx, options.caller, result.model, result.usage); + recordUsage(ctx, options.caller, result.model, result.usage, { + detail: options.usageDetail, + latencyMs: Date.now() - started, + sink: options.usageSink, + }); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); } @@ -206,15 +280,20 @@ export class TokenTrackingProvider implements LlmProvider { }, async () => { let reasoningBuf = ""; - const result = await inner.completeWithToolsStream!(messages, options, { - onReasoningDelta: (delta) => { - reasoningBuf += delta; - const ctx = this.getContext(); - ctx.pendingReasoning = reasoningBuf; - callbacks.onReasoningDelta?.(delta); + const started = Date.now(); + const result = await inner.completeWithToolsStream!( + messages, + this.withSession(options), + { + onReasoningDelta: (delta) => { + reasoningBuf += delta; + const ctx = this.getContext(); + ctx.pendingReasoning = reasoningBuf; + callbacks.onReasoningDelta?.(delta); + }, + onContentDelta: callbacks.onContentDelta, }, - onContentDelta: callbacks.onContentDelta, - }); + ); const ctx = this.getContext(); capturePendingTrace( ctx, @@ -222,8 +301,13 @@ export class TokenTrackingProvider implements LlmProvider { options.caller, result.model, options.generation, + options.tools, ); - recordUsage(ctx, options.caller, result.model, result.usage); + recordUsage(ctx, options.caller, result.model, result.usage, { + detail: options.usageDetail, + latencyMs: Date.now() - started, + sink: options.usageSink, + }); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); } diff --git a/src/log.ts b/src/log.ts index 07c031f..104f4d1 100644 --- a/src/log.ts +++ b/src/log.ts @@ -98,6 +98,8 @@ const TOOL_LABEL: Record = { review_blackboard: "审黑板", finish: "结束", insert: "写入产物", + reply_user: "回复用户", + select_nodes: "选定节点", write_product: "写入产物", delete: "删除产物", declare_variable: "声明变量", @@ -214,6 +216,7 @@ export function labelCaller(caller: string | undefined): string { if (who === "main_agent" || who.startsWith("main_agent:")) return "总管"; if (who === "intake_extract") return "需求抽取"; if (who === "dictate_agent") return "对话落盘"; + if (who.startsWith("listen_serial_")) return "听书连载"; if (who === "preset-probe") return "预设探测"; if (who.startsWith("model-compare:")) { return `模型对比 ${who.slice("model-compare:".length)}`; diff --git a/src/server/stats-handlers.ts b/src/server/stats-handlers.ts index 56b3950..0d5ef4d 100644 --- a/src/server/stats-handlers.ts +++ b/src/server/stats-handlers.ts @@ -14,6 +14,7 @@ import { SUGGESTED_PRICING_DOCS, } from "../stats/pricing.js"; import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js"; +import { annotateCacheBreaks } from "../llm/context-outline.js"; async function readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -77,6 +78,7 @@ export async function handleStatsApi( const records = readRecords({ ...query, limit: query.limit ?? 100 }); const catalog = resolveRateCatalog(); const cost = buildCostReport(summaryRecords, catalog); + const breaks = annotateCacheBreaks(summaryRecords); json(res, 200, { summary: { ...summarizeRecords(summaryRecords), @@ -92,6 +94,7 @@ export async function handleStatsApi( ...r, estimatedCost: priced.cost, unmatchedRate: priced.unmatched, + cacheBreak: breaks.get(r.id) ?? null, }; }), }); diff --git a/src/stats/token-store.ts b/src/stats/token-store.ts index 6885983..37f2f59 100644 --- a/src/stats/token-store.ts +++ b/src/stats/token-store.ts @@ -1,7 +1,8 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; +import type { ContextSegment } from "../llm/context-outline.js"; export type TokenUsageRecord = { id: string; @@ -17,7 +18,25 @@ export type TokenUsageRecord = { totalTokens: number; cachedTokens?: number; cacheMissTokens?: number; + cacheWriteTokens?: number; messageId?: string; + turnId?: string; + step?: string; + retryIndex?: number; + segmentChars?: { + rules: number; + history: number; + products: number; + injected: number; + tail: number; + }; + /** 有序标签与哈希,不含正文 */ + segments?: ContextSegment[]; + plannedTags?: string[]; + succeededTags?: string[]; + failedTags?: string[]; + finish?: string; + latencyMs?: number; }; /** Token usage attached to a chat message in SessionView */ @@ -97,7 +116,18 @@ export function recordTokenUsage( totalTokens: partial.totalTokens, cachedTokens: partial.cachedTokens, cacheMissTokens: partial.cacheMissTokens, + cacheWriteTokens: partial.cacheWriteTokens, messageId: partial.messageId, + turnId: partial.turnId, + step: partial.step, + retryIndex: partial.retryIndex, + segmentChars: partial.segmentChars, + segments: partial.segments, + plannedTags: partial.plannedTags, + succeededTags: partial.succeededTags, + failedTags: partial.failedTags, + finish: partial.finish, + latencyMs: partial.latencyMs, }; appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8"); return record; @@ -238,6 +268,39 @@ export function getSessionTokenTotals(sessionId: string): { }; } +export function patchTokenUsage( + id: string, + patch: Partial< + Pick< + TokenUsageRecord, + "plannedTags" | "succeededTags" | "failedTags" | "finish" + > + >, +): void { + if (!id) return; + const files = listStatFiles(); + for (let i = files.length - 1; i >= 0; i--) { + const file = files[i]!; + const lines = readFileSync(file, "utf8").split(/\r?\n/); + let changed = false; + const next = lines.map((line) => { + if (!line.trim()) return line; + try { + const record = JSON.parse(line) as TokenUsageRecord; + if (record.id !== id) return line; + changed = true; + return JSON.stringify({ ...record, ...patch }); + } catch { + return line; + } + }); + if (changed) { + writeFileSync(file, next.join("\n"), "utf8"); + return; + } + } +} + export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage { return { totalTokens: record.totalTokens, diff --git a/src/types/context-trace.ts b/src/types/context-trace.ts index 67a183c..281b103 100644 --- a/src/types/context-trace.ts +++ b/src/types/context-trace.ts @@ -1,3 +1,4 @@ +import type { ContextSegment } from "../llm/context-outline.js"; import type { GenerationParameters } from "./preset.js"; /** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */ @@ -11,6 +12,8 @@ export type LlmContextTrace = { caller: string; createdAt: string; messages: LlmContextMessage[]; + /** 有序标签,供查看排列和缓存断点;不含正文 */ + segments?: ContextSegment[]; /** 便于列表展示 */ charCount: number; model?: string; @@ -57,6 +60,7 @@ export function buildContextTrace(params: { }>; model?: string; generation?: GenerationParameters; + segments?: ContextSegment[]; }): LlmContextTrace { const messages = snapshotRequestMessages(params.messages); const charCount = messages.reduce((n, m) => n + m.content.length, 0); @@ -64,6 +68,7 @@ export function buildContextTrace(params: { caller: params.caller, createdAt: new Date().toISOString(), messages, + ...(params.segments?.length ? { segments: params.segments } : {}), charCount, model: params.model, ...(params.generation && Object.keys(params.generation).length diff --git a/tests/context-outline.test.ts b/tests/context-outline.test.ts new file mode 100644 index 0000000..aa33823 --- /dev/null +++ b/tests/context-outline.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + annotateCacheBreaks, + firstCacheBreak, + outlineRequest, +} from "../src/llm/context-outline.js"; + +const sample = [ + "规则开头", + "", + "## 写入规则", + "固定规则", + "", + "## 更早的对话", + "", + "【用户】", + "第一句", + "", + "【助手】", + "回过", + "", + "## 当前产物(已写入黑板)", + "", + "### 设计.氛围 〔order=0〕", + "雨", + "", + "### 设计.角色 〔order=1〕", + "甲", + "", + "## 本轮注入", + "", + "### topology @ 3", + "#### task", + "做一件事", + "", + "## 本轮", + "", + "方案进度", + "", + "最后一轮用户输入", + "再改一下", +].join("\n"); + +describe("outlineRequest", () => { + it("按标题、对话轮次和最后一轮输入切开,不留下正文", () => { + const segments = outlineRequest([ + { role: "system", content: sample.slice(0, sample.indexOf("## 更早的对话")) }, + { role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) }, + ]); + expect(segments.map((item) => item.tag)).toEqual([ + "规则", + "写入规则", + "更早的对话", + "更早·用户", + "更早·助手", + "当前产物", + "设计.氛围", + "设计.角色", + "本轮注入", + "topology", + "task", + "本轮", + "最后一轮用户输入", + ]); + expect(JSON.stringify(segments)).not.toContain("再改一下"); + expect(JSON.stringify(segments)).not.toContain("雨"); + const again = outlineRequest([ + { role: "system", content: sample.slice(0, sample.indexOf("## 更早的对话")) }, + { role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) }, + ]); + expect(again.map((item) => item.hash)).toEqual(segments.map((item) => item.hash)); + }); + + it("工具定义放在正文之前", () => { + const segments = outlineRequest( + [{ role: "user", content: "你好" }], + [ + { + type: "function", + function: { name: "insert", description: "写", parameters: {} }, + }, + ], + ); + expect(segments.map((item) => item.tag)).toEqual(["工具", "用户"]); + }); +}); + +describe("firstCacheBreak", () => { + it("标出第一处标签或哈希不同的位置", () => { + const prev = outlineRequest([{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) }]); + const changed = sample.replace("雨", "雪"); + const next = outlineRequest([{ role: "user", content: changed.slice(changed.indexOf("## 更早的对话")) }]); + const index = firstCacheBreak(prev, next); + expect(next[index]?.tag).toBe("设计.氛围"); + expect(firstCacheBreak(prev, prev)).toBe(-1); + expect(firstCacheBreak(prev, prev.slice(0, 3))).toBe(-1); + expect(firstCacheBreak(prev.slice(0, 3), prev)).toBe(3); + }); +}); + +describe("annotateCacheBreaks", () => { + it("按同会话时间比较,窗口里第一条算冷启动", () => { + const a = outlineRequest([{ role: "user", content: "第一" }]); + const b = outlineRequest([{ role: "user", content: "第二" }]); + const marks = annotateCacheBreaks([ + { id: "2", at: "2026-10-02T02:00:00.000Z", sessionId: "s", segments: b }, + { id: "1", at: "2026-10-02T01:00:00.000Z", sessionId: "s", segments: a }, + { id: "9", at: "2026-10-02T03:00:00.000Z", sessionId: "other", segments: a }, + ]); + expect(marks.get("1")).toMatchObject({ cold: true, matched: 0 }); + expect(marks.get("2")).toMatchObject({ cold: false, breakAt: 0, tag: "用户", matched: 0 }); + expect(marks.get("9")?.cold).toBe(true); + }); +}); diff --git a/tests/token-usage.test.ts b/tests/token-usage.test.ts index 1bd9a8f..ffbe5a0 100644 --- a/tests/token-usage.test.ts +++ b/tests/token-usage.test.ts @@ -44,6 +44,16 @@ describe("parseUsage", () => { expect(usage?.cacheMissTokens).toBe(100); }); + it("parses cache write tokens when the provider returns them", () => { + const usage = parseUsage({ + prompt_tokens: 100, + completion_tokens: 10, + total_tokens: 110, + cache_creation_input_tokens: 40, + }); + expect(usage?.cacheWriteTokens).toBe(40); + }); + it("returns undefined for empty usage", () => { expect(parseUsage(null)).toBeUndefined(); expect(parseUsage({})).toBeUndefined(); @@ -52,8 +62,10 @@ describe("parseUsage", () => { describe("TokenTrackingProvider", () => { it("stores pending usage with cache fields on context", async () => { + let seenSession: string | undefined; const inner: LlmProvider = { - async complete(): Promise { + async complete(_messages, options): Promise { + seenSession = options?.sessionId; return { content: "ok", model: "test-model", @@ -71,6 +83,7 @@ describe("TokenTrackingProvider", () => { const ctx: LlmTrackingContext = { sessionId: "sess-1" }; const provider = new TokenTrackingProvider(inner, () => ctx); await provider.complete([], { caller: "worker:write-rules" }); + expect(seenSession).toBe("sess-1"); expect(ctx.pendingUsage).toMatchObject({ totalTokens: 15, diff --git a/web/stats.css b/web/stats.css index 66ea884..2c01991 100644 --- a/web/stats.css +++ b/web/stats.css @@ -396,6 +396,26 @@ border-bottom: none; } +.stats-table tbody tr.stats-call { + cursor: pointer; +} +.stats-table tbody tr.stats-detail td { + padding-top: 8px; + padding-bottom: 12px; + white-space: normal; +} +.stats-table .stats-system-note { + margin: 0 0 6px; + color: var(--st-muted, var(--muted, #6b7280)); + font-size: 12px; +} +.stats-table .stats-segments { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} .stats-table tbody tr:hover td { background: color-mix(in srgb, var(--st-surface) 58%, var(--st-bg)); } diff --git a/web/stats.html b/web/stats.html index 3b48802..80ab5e6 100644 --- a/web/stats.html +++ b/web/stats.html @@ -117,12 +117,13 @@ 时间 + 步骤 caller model prompt - completion - total + 未缓存 cached + 断开 花销 diff --git a/web/stats.js b/web/stats.js index deb9094..759a6af 100644 --- a/web/stats.js +++ b/web/stats.js @@ -187,6 +187,57 @@ function renderModels(rows) { .join(""); } +function uncachedTokens(record) { + if (record.cacheMissTokens != null) return record.cacheMissTokens; + if (record.promptTokens != null && record.cachedTokens != null) { + return Math.max(0, record.promptTokens - record.cachedTokens); + } + return null; +} + +function breakLabel(info) { + if (!info) return "—"; + if (info.cold) return "冷启动"; + if (info.breakAt < 0) return "整段接上"; + return info.tag || "断开"; +} + +function breakNote(record) { + const info = record.cacheBreak; + const segments = record.segments || []; + if (!segments.length) return "这次没有分段标签。"; + if (!info || info.cold) { + return "本页没有更早的同会话请求可比。"; + } + if (info.breakAt < 0) { + let note = "与上一条同会话请求的前缀整段相同。"; + if (!record.cachedTokens) note += " 账单缓存是 0。"; + return note; + } + const tag = segments[info.breakAt]?.tag || info.tag || "(上一条更长)"; + let note = `前 ${info.matched ?? info.breakAt} 段与上一条相同,从「${tag}」起前缀不再一致。`; + if (!record.cachedTokens && info.breakAt > 0) { + note += " 账单缓存是 0。"; + } + return note; +} + +function segmentLines(record) { + const info = record.cacheBreak; + const segments = record.segments || []; + return segments + .map((seg, i) => { + let mark = " "; + if (info && !info.cold) { + if (info.breakAt < 0 || i < info.breakAt) mark = "已接上"; + else if (i === info.breakAt) mark = "断开 "; + else mark = "之后 "; + } + return `${String(i + 1).padStart(2, "0")} ${mark} ${escapeHtml(seg.tag)} ${formatNum(seg.chars)}字 ${escapeHtml(seg.hash)}`; + }) + .join("\n"); +} + function renderRows(records) { const tbody = document.getElementById("rows"); const empty = document.getElementById("empty"); @@ -206,15 +257,35 @@ function renderRows(records) { const cost = r.unmatchedRate || !r.estimatedCost ? `—` : formatMoney(r.estimatedCost); - return ` + const chars = r.segmentChars; + const charLine = chars + ? `规则 ${formatNum(chars.rules)} · 更早对话 ${formatNum(chars.history)} · 产物 ${formatNum(chars.products)} · 注入 ${formatNum(chars.injected)} · 末尾 ${formatNum(chars.tail)}` + : ""; + const tags = [ + r.turnId ? `turn ${escapeHtml(r.turnId)}` : "", + r.plannedTags?.length ? `计划 ${escapeHtml(r.plannedTags.join("、"))}` : "", + r.succeededTags?.length ? `已写入 ${escapeHtml(r.succeededTags.join("、"))}` : "", + r.failedTags?.length ? `未完成 ${escapeHtml(r.failedTags.join("、"))}` : "", + ].filter(Boolean).join(" · "); + return ` ${formatAt(r.at)} + ${escapeHtml(r.step || "—")} ${escapeHtml(r.caller || "—")} ${escapeHtml(r.model || "—")} ${formatNum(r.promptTokens ?? 0)} - ${formatNum(r.completionTokens ?? 0)} - ${formatNum(r.totalTokens ?? 0)} + ${uncachedTokens(r) == null ? "—" : formatNum(uncachedTokens(r))} ${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)} + ${escapeHtml(breakLabel(r.cacheBreak))} ${cost} + + + +

系统信息:断点、分段标签和哈希只给你对照,不会发给模型。「工具」是 tools 字段。

+

${escapeHtml(breakNote(r))} 写出 ${formatNum(r.completionTokens ?? 0)}。

+ ${charLine ? `

${charLine}

` : ""} + ${tags ? `

${tags}

` : ""} +
${segmentLines(r) || "(无分段)"}
+ `; }) .join(""); @@ -394,6 +465,14 @@ document.getElementById("pricing-sources").addEventListener("click", async (ev) } }); +document.getElementById("rows")?.addEventListener("click", (event) => { + const row = event.target.closest("tr.stats-call"); + if (!row) return; + const detail = row.nextElementSibling; + if (!detail?.classList.contains("stats-detail")) return; + detail.hidden = !detail.hidden; +}); + (() => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);