feat(llm): 在 compaction 中按路由为图片请求压力计价

Closes #2848.

- dsh-llm 新增 LlmAdapter.imageRequestPricing 同步钩子与 LlmImageRequestPricing/LlmImageRequestPrice 词汇,ctx.llm 按路由解析
- llm-deepseek 用官方公布的 v4 视觉计算器逐句移植(14px patch、3:1 降采样、384 上限、最坏对齐 pad)实现该钩子,复现请求投影的最旧优先 offload 与像素预算缩放;纯几何 requestImageDimensions 上移到 dsh-attachment
- token-meter 表层 fold 存储与路由无关的节点事实,measure() 按生效 envelope 的路由为图片出现处定价;锚点存快照并按同一路由重定价;TokenSurfaceNode 同时携带路由价 tokens 与固定启发式 heuristicTokens
- compaction 触发、保留与选段读取同一套路由价,记录的 shadowedTokenCount 保持启发式以维持 O(1) 投影 fold 一致
- llm-replay 支持按模型的 imageRequestTokens 声明;新增 keyless 的 image-compaction ACP 快照场景端到端验证装配应用
This commit is contained in:
creatixchu
2026-08-24 19:15:30 +08:00
parent de6d83a0fa
commit 42164508c8
76 changed files with 1845 additions and 278 deletions
@@ -1878,3 +1878,119 @@ describe('automatic listener and loader composition', () => {
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
})
describe('route-priced image pressure', () => {
const IMAGE_VISUAL_TOKENS = 300
const IMAGE_HANDLE_TEXT = 'request preview'
class PricedContextAdapter extends ContextAdapter {
override imageRequestPricing(): { priceImages: (images: readonly unknown[]) => Array<{ visualTokens: number; text: string }> } {
return {
priceImages: images => images.map(() => ({
visualTokens: IMAGE_VISUAL_TOKENS,
text: IMAGE_HANDLE_TEXT,
})),
}
}
}
function pricedContext(contextWindow = 1_000): Context {
const ctx = new Context()
void new LlmRuntime(ctx)
void new TokenMeter(ctx)
ctx.llm.registerAdapter([MODEL], new PricedContextAdapter(contextWindow))
return ctx
}
/** Closed short-text turns whose user messages each carry one image. */
function imageConversation(turns = 4): Session {
const session = Session.create(SessionId(`image-dense-${turns}`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [
{ type: 'text', text: `image turn ${turn}` },
{
type: 'image',
attachment: {
attachmentId: AttachmentId(`sha256:${String(turn).repeat(8)}`),
mediaType: 'image/png',
bytes: 2048,
width: 800,
height: 800,
name: `shot-${turn}`,
},
},
],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn, step: 1 })
if (turn === 1) {
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
}
session.append('assistant/message', {
turn,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: `ok ${turn}` }],
source: {
kind: 'model',
...{ provider: MODEL, model: MODEL },
},
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
session.append('turn/start', { turn: turns + 1 })
return session
}
it('selects an image-dense range only when the routed price counts visual tokens', () => {
const session = imageConversation()
const routed = pricedContext().tokenMeter.measure(session)
const neutral = createContext().tokenMeter.measure(session)
expect(routed.surfaceTokens).toBeGreaterThan(neutral.surfaceTokens + 4 * IMAGE_VISUAL_TOKENS - 200)
expect(routed.nodes.map(node => node.seq)).toEqual(neutral.nodes.map(node => node.seq))
expect(routed.nodes.map(node => node.heuristicTokens)).toEqual(neutral.nodes.map(node => node.tokens))
// The same verbatim tail budget retains almost everything under the
// neutral heuristic but forces a cut once visual tokens are counted.
expect(selectCompactableRange(session, neutral, 350)).toBeNull()
const range = selectCompactableRange(session, routed, 350)
expect(range).not.toBeNull()
})
it('triggers pressure compaction from routed visual tokens and logs heuristic shadow prices', async () => {
const ctx = pricedContext(1_000)
const session = imageConversation()
const before = ctx.tokenMeter.measure(session)
const compact = new TestCompactionEngine(ctx, {
auto: false,
thresholdRatio: 0.8,
retainTokens: 350,
})
// The same history stays below the 800-token threshold without pricing.
const neutralResult = await compactIfNeeded(service({
auto: false,
thresholdRatio: 0.8,
retainTokens: 350,
}), session)
expect(neutralResult).toBeNull()
const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL)
expect(result).not.toBeNull()
const summaryEvent = session.events.find(event => event.type === 'compaction/summary')
expect(summaryEvent).toBeDefined()
const shadowedHeuristic = before.nodes
.filter(node => result?.shadowedSeqs.includes(node.seq))
.reduce((total, node) => total + node.heuristicTokens, 0)
expect(summaryEvent?.data.shadowedTokenCount).toBe(shadowedHeuristic)
})
})