fix(experimental): weight turn cache hit by measured prompt tokens

This commit is contained in:
mektpoy committed 2026-10-26 13:25:51 +08:00
1 parent 1d751809a1
commit 559c4c689d
8 files changed
+57 -27

No files matched your search

@@ -24,8 +24,8 @@
en: e001601f2f9779b1
zh: 077d3703b8c82774
/deepseek-ai-dsh-experimental-client-ui-perf/understand-the-implementation:
en: 289e8a539820fa4d
zh: 5dad2cfac77761e8
en: 611ac4f20831030d
zh: 11051434b5271feb
/deepseek-ai-dsh-experimental-client-ui-perf/further-exploration:
en: 285cfe1d7a99af34
zh: fbcee76e0c2f20fb
@@ -57,6 +57,8 @@ Four Conversation Definitions fold the event window into nodes for one view targ
| `src/client/PerfView.tsx` | Scrollport with scroll-to-top paging, table, time bars, step accordion, copy controls, and the prompt image pill with its preview and viewer |
| `src/client/locales.ts` | Every visible string, in Chinese, English, and Japanese |
The turn cache-hit cell is token-weighted across the steps that reported usage (summed cache reads ÷ summed prompt tokens), so one transient cold step is not hidden by a warmer neighbour; the tooltip shows the summed read/input/write breakdown. A step row keeps its own measured share.
Windows are bounded: when a step's `step/start` is outside the loaded window, its start-dependent columns show `—` with a `start unknown` marker rather than a guessed value. Scrolling to the top asks the Session for one older page; the view offsets the scroll position by the height it prepended, so the row being read stays in place.
</details>
@@ -57,6 +57,8 @@ kind: "package-reference"
| `src/client/PerfView.tsx` | 表格、时间条、步骤手风琴、复制按钮,以及提问图片标签、预览与看图器 |
| `src/client/locales.ts` | 全部可见文案,中、英、日三份 |
轮次的缓存命中率按报了 usage 的各步做 token 加权(缓存读之和 ÷ 提示词 token 之和),不会因为相邻步命中高就把某一次冷启动盖掉;tooltip 展示汇总后的读/输入/写明细。步骤行仍显示该步自己的实测比例。
会话窗口是有界的:某一步的 `step/start` 不在已加载窗口里时,与起点有关的列显示 `—` 并标「起止未知」,不估算数值。滚到顶部会向 Session 请求更早的一页;视图按新增内容的高度补偿滚动位置,正在读的行不会跳走。
</details>
@@ -150,6 +150,8 @@ interface MetricsValues {
readonly tool: number | null
readonly cacheRead: number | null
readonly cacheWrite: number | null
/** Weighted prompt denominator of a turn's cache rate; step rows use `context`. */
readonly cacheContext?: number | null
readonly ttftPending?: number
readonly modelPending?: number
readonly toolPending?: number
@@ -802,7 +804,9 @@ const COLUMN_DETAIL: Readonly<
if (!isNumber(value.cacheRead) && !isNumber(value.cacheWrite)) return t('column.cache.title')
const read = value.cacheRead ?? 0
const write = value.cacheWrite ?? 0
const context = isNumber(value.context) ? value.context : read + write
const context = isNumber(value.cacheContext)
? value.cacheContext
: isNumber(value.context) ? value.context : read + write
const input = Math.max(0, context - read - write)
return t('cache.detail', {
read: formatTokens(read),
@@ -44,16 +44,26 @@ function turnContext(steps: readonly PerfStepMetrics[]): number | null {
return context
}
/** Cache breakdown measured last in the turn, from the same step whose hit rate the row shows. */
function turnCache(steps: readonly PerfStepMetrics[]): { read: number | null; write: number | null } {
let read: number | null = null
let write: number | null = null
/** Token-weighted cache level of the turn, over the steps that reported both read and prompt tokens. */
function turnCache(steps: readonly PerfStepMetrics[]): {
rate: number | null
read: number | null
write: number | null
context: number | null
} {
let read = 0
let write = 0
let context = 0
let measured = false
for (const step of steps) {
if (!isNumber(step.cacheHit)) continue
read = step.cacheRead
write = step.cacheWrite
if (!isNumber(step.cacheRead) || !isNumber(step.context)) continue
read += step.cacheRead
write += step.cacheWrite ?? 0
context += step.context
measured = true
}
return { read, write }
const level = measured && context > 0 ? Math.min(1, read / context) : null
return { rate: level, read: measured ? read : null, write: measured ? write : null, context: measured ? context : null }
}
/** Tool tallies by descending count; ties keep call order. */
@@ -144,16 +154,12 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
&& !last.running && isNumber(first.startTime) && isNumber(last.endTime)
? Math.max(0, last.endTime - first.startTime)
: null
// Last cache hit measured in the turn: the prompt grows, so the tail value is
// the turn's actual level rather than the first step's cold start.
let cacheHit: number | null = null
let answer: string | null = null
let retries = 0
let toolErrors = 0
let toolOutput = 0
let lastStepWall: number | null = null
for (const step of steps) {
if (isNumber(step.cacheHit)) cacheHit = step.cacheHit
if (step.answer !== null) answer = step.answer
retries += step.retries
toolErrors += step.toolErrors
@@ -182,9 +188,10 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
toolWall: toolWallMeasured ? toolWallSum : null,
toolPending: toolPairable === 0 ? 0 : toolPending,
tools: toolCounts,
cacheHit,
cacheHit: cache.rate,
cacheRead: cache.read,
cacheWrite: cache.write,
cacheContext: cache.context,
retries,
toolErrors,
toolOutput,
@@ -139,10 +139,14 @@ export interface PerfTurnMetrics {
readonly toolWall: number | null
readonly toolPending: number
readonly tools: readonly PerfToolCount[]
/** Token-weighted cache-read share across the steps that reported usage. */
readonly cacheHit: number | null
/** Cache breakdown of the last step that reported usage, for the turn tooltip. */
/** Cache-read tokens summed across the steps that reported usage. */
readonly cacheRead: number | null
/** Cache-write tokens summed across the steps that reported usage. */
readonly cacheWrite: number | null
/** Prompt tokens summed across the steps that reported usage; the weight of `cacheHit`. */
readonly cacheContext: number | null
readonly retries: number
readonly toolErrors: number
readonly toolOutput: number
@@ -204,30 +204,35 @@ describe('turn aggregation', () => {
expect(noOutput.tps).toBeNull()
})
it('keeps the last measured context and cache level of the turn', () => {
it('weights the turn cache level by measured prompt tokens and keeps the last context', () => {
const turn = aggregateTurn(4, [
metricsOf({ step: 1, context: 100, cacheHit: 0.2, cacheRead: 10, cacheWrite: 5, wall: 10 }),
metricsOf({ step: 2, context: 200, cacheHit: 0.5, cacheRead: 20, cacheWrite: 6, wall: 20 }),
metricsOf({ step: 1, context: 100, cacheHit: 0.2, cacheRead: 20, cacheWrite: 5, wall: 10 }),
metricsOf({ step: 2, context: 200, cacheHit: 0.5, cacheRead: 100, cacheWrite: 6, wall: 20 }),
metricsOf({ step: 3, context: 300, wall: 30 }),
])
expect(turn.context).toBe(300)
expect(turn.cacheHit).toBe(0.5)
expect(turn.cacheRead).toBe(20)
expect(turn.cacheWrite).toBe(6)
expect(turn.cacheHit).toBeCloseTo(120 / 300)
expect(turn.cacheRead).toBe(120)
expect(turn.cacheWrite).toBe(11)
expect(turn.cacheContext).toBe(300)
expect(turn.lastStepWall).toBe(30)
const heldBack = aggregateTurn(4, [
metricsOf({ step: 1, context: 100, cacheHit: 0.2, cacheRead: 10, cacheWrite: 5, wall: 10 }),
metricsOf({ step: 1, context: 100, cacheHit: 0.2, cacheRead: 20, cacheWrite: 5, wall: 10 }),
metricsOf({ step: 2, cacheHit: null, cacheRead: 99, cacheWrite: 99, wall: null }),
])
expect(heldBack.context).toBe(100)
expect(heldBack.cacheHit).toBe(0.2)
expect(heldBack.cacheRead).toBe(10)
expect(heldBack.cacheRead).toBe(20)
expect(heldBack.cacheWrite).toBe(5)
expect(heldBack.cacheContext).toBe(100)
expect(heldBack.lastStepWall).toBe(10)
const bare = aggregateTurn(4, [metricsOf({ cacheHit: 0.4 })])
expect(bare.cacheHit).toBe(0.4)
expect(bare.cacheHit).toBeNull()
expect(bare.cacheRead).toBeNull()
expect(bare.cacheWrite).toBeNull()
expect(bare.cacheContext).toBeNull()
const empty = aggregateTurn(4, [metricsOf({ context: 0, cacheRead: 0 })])
expect(empty.cacheHit).toBeNull()
})
it('sums the sequential step tool spans', () => {
@@ -124,6 +124,7 @@ function turn(overrides: Partial<PerfTurnMetrics> = {}): PerfTurnMetrics {
cacheHit: null,
cacheRead: null,
cacheWrite: null,
cacheContext: null,
retries: 0,
toolErrors: 0,
toolOutput: 0,
@@ -728,8 +729,9 @@ describe('turn rows', () => {
turn({ turn: 2, cacheHit: 0.1, cacheRead: null, cacheWrite: 200, context: 1_000 }),
turn({ turn: 3, cacheHit: 0.4, cacheRead: 100, cacheWrite: null, context: null }),
turn({ turn: 4, cacheHit: 0.2, cacheRead: null, cacheWrite: null }),
turn({ turn: 5, cacheHit: 0.15, cacheRead: 100, cacheWrite: 200, cacheContext: 2_000 }),
],
stepCount: 4,
stepCount: 5,
})
const full = cellsOf(rowWithPrefix(say('turn.label', { turn: 1 })))
expect(full[5]?.getAttribute('title')).toBe(say('cache.detail', {
@@ -747,6 +749,10 @@ describe('turn rows', () => {
expect(readOnly[3]?.textContent).toBe('—')
const absent = cellsOf(rowWithPrefix(say('turn.label', { turn: 4 })))
expect(absent[5]?.getAttribute('title')).toBe(say('column.cache.title'))
const weighted = cellsOf(rowWithPrefix(say('turn.label', { turn: 5 })))
expect(weighted[5]?.getAttribute('title')).toBe(say('cache.detail', {
read: formatTokens(100), input: formatTokens(1_700), write: formatTokens(200),
}))
})
it('scales turn bars against the turns visible on screen', () => {