跳到主要内容

数据截至 (上游 commit 7538cc96774b)

缓存优先:上下文工程与省钱三件套

30 秒导读: 本章只回答一个问题——一次 model step 到底把什么字节发给模型、为什么必须这么发。答案是:请求被硬切成两段,前面那段(system prompt + 工具 schema + few-shot)必须逐字节不变,后面那段(对话历史 + 工具结果)可以随便修、随便砍、随便折叠。前段不变,provider 的 prompt cache 才能复用;后段被压住,请求才不会无限膨胀。

本章不讲控制流(见 02-agent-loop)、不讲工具目录怎么生成(见 04-tools-and-gates)、也不讲 HTTP 请求体最终长什么样(见 05-model-layer)。本章只讲装进请求的内容,以及装之前做了什么手脚


1. 这是什么:为什么"前缀"值钱

1.1 一句话背景

主流大模型 provider 都提供 prompt cache(提示缓存):如果这次请求的开头一段字节和上次完全一样,这段就不重新算,按更便宜的价格计费。

关键在"开头一段"和"完全一样"这两个词:

  • 缓存是前缀匹配,不是集合匹配。第 1 个字节变了,后面全废。
  • 匹配的是字节,不是语义。把工具 schema 里两个字段换个顺序,语义没变,缓存全丢。

1.2 所以 Kun 的做法

Kun 把每次请求物理切成两段,并给前段起了个名字叫 ImmutablePrefix(不可变前缀):

发给模型的字节,从头到尾
══════════════════════════════════════

① systemPrompt ┐
② tools[](名字排序) ├─ 稳定前缀:指纹上锁,逐字节不变 ← 想被缓存
③ few-shot 示例 ┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
④ modeInstruction / contextInstructions ┐
⑤ history(压缩摘要 + 最近若干轮) ├─ 易变尾巴:每回合都在变
⑥ 本轮输入 / 附件 / 工具结果 ┘

这张图的读法:越靠上越该一动不动,越靠下越自由。Kun 的全部上下文工程,就是在保证"上面三层不动"的前提下,把下面三层压进预算。

1.3 项目自己把这条规矩写进了 system prompt

不是外部推断,而是 Kun 的 system prompt 正文里就有一节 Cache behavior,明说稳定指令与稳定工具 schema 要跨回合逐字节稳定,可变内容必须排在稳定前缀之后(kun/src/prompt/kun-system-prompt.ts:41-46,常量 KUN_SYSTEM_PROMPT)。

它甚至在开头自我说明"这份契约刻意保持稳定,放在每个请求最前面,好让 provider 的缓存在 Code / Write / Claw / plan / 工具续跑之间复用同一段前缀"(同文件 :4)。


2. 顶层全景:从磁盘上的历史到发出去的请求

一次 modelStep 里,历史要过六道工序才变成请求体。先看流水线,再逐道拆:

磁盘 session log


① 治愈 healLoadedHistoryItems 补 id / 丢坏项(每 turn 只做一次)


② 截断 effectiveHistoryAfterLatestCompaction 只留最后一次压缩点之后


③ 配对修复 repairModelHistoryItems 孤儿 tool_result 不发


④ 按需压缩 compactIfNeeded 超阈值就把老的折成一条摘要


⑤ 瘦身 图片封顶 → token economy → 历史卫生


⑥ 装配 baseRequest ← 稳定前缀 + 上面这坨

各道工序的落点:

工序干什么入口符号文件
① 治愈补缺失 id、丢掉结构非法的 itemhealLoadedHistoryItemskun/src/loop/history-healing.ts:9
② 截断跳到最后一次有效压缩点effectiveHistoryAfterLatestCompactionkun/src/loop/compaction-history.ts:3
③ 配对修复保证 tool_call / tool_result 严格配对repairModelHistoryItemskun/src/domain/model-history-repair.ts:11
④ 压缩超阈值折叠成摘要 itemContextCompactor.planCompaction / compactkun/src/loop/context-compactor.ts:96,142
⑤ 瘦身截图封顶、描述压缩、工具结果限额capToolResultImages / applyTokenEconomyToRequest / applyRequestHistoryHygienetool-result-image.ts:144 / token-economy.ts:84 / request-history-hygiene.ts:70
⑥ 装配拼成 ModelRequestbaseRequest 字面量kun/src/loop/model-request-composer.ts:82-117

主线在新结构里分三段看:装配准备(kun/src/loop/model-step-preparation-service.ts)里 :151-173(治愈)、:207-209(截断+修复);压缩在 kun/src/loop/model-step-service.ts:254(compactIfNeeded);发送前组装在 kun/src/loop/model-request-composer.ts——:98(图片封顶)、:124(economy)、:127(卫生)。


3. 稳定前缀:结构、指纹、四个唯一入口

3.1 它要解决的小问题

如果代码里任何地方都能顺手 prefix.systemPrompt += "...",那前缀迟早会被污染,而且没人知道是谁污染的。Kun 的对策是:把前缀做成只能通过四个函数改的值对象,每改一次就重算一次指纹、涨一次版本号。

3.2 结构

// kun/src/cache/immutable-prefix.ts:9 ImmutablePrefix
export type ImmutablePrefix = {
systemPrompt: string
tools: { name: string; description: string; inputSchema: Record<string, unknown> }[]
pinnedConstraints: string[] // 必须活过压缩的硬约束
fewShots: TurnItem[]
fingerprint: string // 稳定指纹
revision: number // 每次显式修改 +1
}

四个字段各自的角色:

字段装什么谁在用
systemPrompt运行时基座契约直接当 ModelRequest.systemPrompt 发出
tools工具目录快照(参与指纹)与实际发送的 toolSpecs 分开算指纹,见 §3.6
pinnedConstraints压缩时必须逐条抄进摘要的约束ContextCompactor.compact
fewShotsfew-shot 示例直接当 ModelRequest.prefix 发出(kun/src/loop/model-request-composer.ts:97)

3.3 四个唯一入口

改前缀只有这四个函数,全部走同一个私有 mutate,而 mutate 一定会重算 fingerprint、把 revision 加一:

函数改什么行号
setSystemPrompt系统提示词kun/src/cache/immutable-prefix.ts:150
setTools工具目录(会先规范化):147
setPinnedConstraints钉住的约束:154
setFewShotsfew-shot:158

这四个都返回新对象(mutate{ ...prefix, ...patch },:130-139),不原地改——所以"上一版前缀"永远还在,可以拿来做 §3.5 的漂移对比。

创建入口只有 createImmutablePrefix(:100)。生产环境唯一一处真实调用在 kun/src/server/runtime-composition-core.ts:122,装的是 KUN_SYSTEM_PROMPT 和三条 pinnedConstraints,其中一条就是"keep the stable Kun prefix byte-stable for prompt-cache reuse"。

3.4 指纹怎么算:排序 + key 规范化

先看直觉。 两个语义完全一样的工具列表,只要 JSON 里键的顺序不同,JSON.stringify 出来就是两串不同的字节,哈希也就不同。所以哈希之前必须先做规范化(canonicalization):把所有对象的 key 递归排序,把工具按名字排序。

// 示意,非源码:规范化的核心想法
function canonical(v) {
if (Array.isArray(v)) return v.map(canonical) // 数组顺序有意义,保留
if (typeof v !== 'object' || !v) return v
const out = {}
for (const k of Object.keys(v).sort()) out[k] = canonical(v[k]) // key 排序
return out
}
// 重点看:数组不排,只有对象的 key 排 —— 参数顺序是语义的一部分

真实实现分三层,都在 kun/src/cache/immutable-prefix.ts:

  • canonicalize(:27)——递归给对象 key 排序,数组保序。
  • normalizeTools(:44)——只保留 name / description / inputSchema 三个字段,schema 走 canonicalizeSchema,最后namelocaleCompare 排序(:51)。工具目录的枚举顺序因此不影响指纹。
  • buildFingerprint(:82)——把 {systemPrompt, 规范化后的 tools, pinned, few-shot 的缓存投影} 一起 sha256,取前 16 个 hex 字符(hashObject,:23)。

还有一个容易漏的细节:few-shot 只有一部分字段参与指纹fewShotCacheShape(:54)对 assistant_reasoning / approval / user_input / compaction / error 一律返回 null,随后被过滤掉(:92)。也就是说,思考链和 GUI 审批这类不该影响缓存身份的东西,即便混进 few-shot 也不会动指纹。

3.5 自检:指纹漂移怎么被抓住

光算指纹不够,还得有人定期核对"我记的指纹"和"我现在的内容"是否还对得上。

// kun/src/cache/immutable-prefix.ts:162 verifyImmutablePrefix
const expected = buildFingerprint(prefix)
if (expected !== prefix.fingerprint) throw new Error(`immutable prefix fingerprint drift: ...`)

这是重算并比对,不是读缓存值——所以任何绕过四个入口的偷改(比如直接 prefix.tools.push(...))都会在这里炸。

它什么时候跑,由 shouldVerifyImmutablePrefix(:96)决定:

环境是否校验依据
非 productionprocess.env.NODE_ENV !== 'production'
production 且设了 KUN_VERIFY_IMMUTABLE_PREFIX=1常量 VERIFY_IMMUTABLE_PREFIX_IN_PROD(:21)
production 且没设不跑同上

调用点在装配准备的第一行(kun/src/loop/model-step-preparation-service.ts:90-92)——每一步模型调用之前都查一次。注意这里的取舍:线上默认关掉是为了省掉每步一次的 sha256,开关留给排障时打开。

出了漂移要定位是哪一块变的,用 describeFingerprintDrift(:172):它逐字段比 systemPrompt / tools / pinnedConstraints / fewShots,返回 { drift, changedFields }。比 tools 时同样先 normalizeTools 再哈希(:178),所以只是排序变了不会被误报为漂移。

3.6 前缀里绝不能放什么:一个不用正则的探测器

规则来自 system prompt 正文: 可变的用户内容、文件片段、工具结果、时间戳、选中文本、工作区状态、生成的摘要,全都必须排在稳定前缀之后(kun/src/prompt/kun-system-prompt.ts:43)。

执行靠一个探测器: detectVolatilePrefixContent(kun/src/cache/prefix-volatility.ts:24)扫描 systemPromptfewShots,找四类"一看就是每次都不一样"的 token:

类别判定函数典型样子
uuidisCanonicalUuid(:107)550e8400-e29b-41d4-a716-446655440000
iso8601isIso8601(:119)2026-07-24T10:00:00Z
hex_hashisHexHash(:140)32/40/64 位十六进制串
jwtisJwt(:144)三段 base64url,前两段能解出 JSON 对象

这个模块的注释明确写了故意不用正则(:17-23):UUID、无横线 UUID、MD5/SHA、ISO 日期、JWT 的形状互相重叠,写成结构化的 token 解析,误报时更好调试。JWT 判定尤其讲究——不是看形状像三段,而是真的 base64url 解码前两段、确认都是 JSON 对象(:148-149)。

它是探测器,不是拦截器:结果只被塞进 input_cached 这个 pipeline stage 的 details 里(kun/src/loop/model-step-preparation-service.ts:190-194,格式化函数 prefixVolatilityStageDetailskun/src/loop/model-step-preparation-helpers.ts:213),字段有 prefixVolatileTokenCount / prefixVolatileTokenKinds / prefixVolatileFields,外加一个自嘲式的 noRegexDetector: true。发现了污染只上报,不阻断。


4. 工具目录:另一条独立的指纹线

4.1 为什么要单独一条

ImmutablePrefix.tools设计上的工具目录;而每一步真正发出去的 toolSpecs,是 toolHost.listTools(toolContext) 当场算出来的(listModelTools,kun/src/loop/turn-context-resolver.ts:263-273)——它受 Skill 激活、allowedToolNames、Plan 模式、MCP provider 上下线影响。这两者可能不一致,所以要单独指纹。

// kun/src/cache/tool-catalog-fingerprint.ts:11 buildToolCatalogFingerprint
return {
fingerprint: hashObject(canonicalTools),
toolCount, toolNames,
toolHashes: Object.fromEntries(canonicalTools.map((t) => [t.name, hashObject(t)]))
}

比前缀指纹多出来的是 toolHashes——每个工具单独一个哈希。有了它,才能回答"到底是新增了工具,还是改了老工具"。

规范化逻辑(normalizeToolSpecs :23canonicalize :44)和 immutable-prefix.ts 里那份逐行同构——两处各写了一遍,没有抽公共模块。

4.2 只许增,不许改

Kun 对目录变化的态度是分级的:

本步的目录指纹 vs 同一 key 下上一步的

┌──────────┴──────────┐
指纹相同 指纹不同
│ │
none 有新增 && 老工具哈希全部没变?
┌──────┴───────┐
是 否
│ │
additive breaking
继续,注入提示 记 drift 遥测 + 提示,
目录冻结到下一 turn

判定函数 isAdditiveToolCatalogChange 有两份同构实现——逐步指纹比对那份在 kun/src/loop/loop-telemetry.ts:92,冻结目录延迟比对那份在 kun/src/loop/turn-tool-catalog.ts:64——两条硬要求相同:必须真的有新名字,并且所有旧名字的哈希一个都不能变——少一个工具、改一句 description、动一个 schema 字段,全部落进 breaking

处置差异写在 buildToolCatalogDriftMessage(kun/src/loop/model-step-preparation-helpers.ts:173)里:additive 说"继续用刷新后的工具表";breaking 的处置 0.3.0 起改了——不再停 turn,而是 turn 内冻结目录、变更延迟到下一 turn 生效(deferred),在 turn 边界检测到的则直接应用(applied),两种都记一条 drift 遥测(kun/src/loop/model-step-preparation-service.ts:355-373)。早先的 if (breaking) return 'stop' 刹车已移除(另见 02-agent-loop.md §3.2)。

4.3 拿什么当"同一个上下文"

漂移不是全局比,而是按一个复合 key 分桶(recordToolCatalogFingerprint,kun/src/loop/loop-telemetry.ts:66-78):

key = JSON({ threadId, workspace, mode, model, activeSkillIds(排序),
allowedToolNames(排序), userInputDisabled,
guiDesignCanvas, guiDesignMode, guiDesignArtifact })

这个 key 的设计意图很明确:正常切模式、切模型、激活 Skill 本来就会换一套工具,不该算漂移。只有"同样的上下文条件下工具却变了"才是可疑的。

快照表用 Map 的 delete-then-set 手搓了一个 LRU,上限 MAX_TOOL_CATALOG_SNAPSHOTS = 256(:3:86-90)。

4.4 顺带说说 cache/ 里那两个通用缓存

LruCache(kun/src/cache/lru-cache.ts:9)和 TtlLruCache(kun/src/cache/ttl-lru-cache.ts:12)是标准实现:前者靠 Map 的插入序做 LRU,get 会 delete-then-set 把条目提到最新;后者在它外面包一层 { value, expiresAt },get 时发现过期就当 miss 并删掉,另有 sweep() 批量清理(:59)。

诚实说明: 全仓搜下来,TtlLruCache 只被自己的构造函数用了 LruCache,除此之外运行时热路径没有使用点;两者从 kun/src/cache/index.tskun/src/index.ts:19 导出,属于对外提供的公共工具类。上面 §4.3 那个快照表也是手搓的,没有复用 LruCache


5. 发送前的第一道功课:先让历史"合法"

5.1 它要解决的小问题

Kun 的 session log 里存的是给 GUI 看的东西:审批、用户输入提示、思考块,和给模型看的 tool_call / tool_result 混在一起。而 provider 的 API 严格得多:一个 assistant 的 tool_call 块,后面必须跟上每个 callId 恰好一条结果

最典型的翻车:用户在工具跑到一半按了中断,磁盘上只剩一个 tool_call 没有结果;或者反过来,只剩一个 tool_result 找不到它的 call。直接发出去,provider 会 400。

5.2 配对修复的算法

扫描历史,遇到 tool_call 就往后吃成一个"调用块"
┌──────────────────────────────────────────────┐
│ tool_call(A) tool_call(B) tool_call(C) │ ← 同一次响应的多个调用
└──────────────────────────────────────────────┘
│ 越过桥接项(reasoning/approval/…)

┌──────────────────────────────────────────────┐
│ tool_result(A) tool_result(C) │ ← B 没结果
└──────────────────────────────────────────────┘


保留 A、C 的 call 与 result;B 的 call 被丢掉,不发

repairModelHistoryItems(kun/src/domain/model-history-repair.ts:11)的三个要点:

  1. 连续的 tool_call 被吃成一组(:26-33),同 callId 去重——这就是任务描述里说的"同一响应的多个 tool_call 重组为一条合法 assistant 消息"的数据基础。
  2. 只有 result 集合里出现过的 callId,对应的 call 才保留(:41);没有配对结果的 call 被过滤(:50-62)。
  3. 允许中间夹"桥接项"——isToolResultBridgeItem(:98)放行 assistant_reasoning / approval / user_input / error;assistant_text 只在"还没看到任何 result 且属于同一 turn"时才算桥接(:109)。这条约束防止把下一轮的正文误当成本轮的桥接。

函数在没改动时原样返回入参数组(:63),这个"引用不变即未改动"的约定被上层直接用来做变更检测。

5.3 加载时的治愈:一次血的教训

healLoadedHistoryItems(kun/src/loop/history-healing.ts:9)在配对修复之外再补两件事:丢掉结构非法的 item(缺 kind、tool 项缺 callId/toolName),给缺 id 的 item 合成一个 item_healed_<index>_<kind>

真正值得学的是它怎么判断"变没变"。文件顶部的注释直说(:10-15):以前用两次全量 JSON.stringify 深比,大 thread 上每步阻塞事件循环好几秒,把 /health 探针饿死(KunAgent/Kun#621)。现在改成按引用判等——normalizeLoadedItem 在无需改写时原样返回入参(:56),repairModelHistoryItems 无改动时返回原数组,于是 healed !== item / repaired !== normalized 就是廉价的变更信号。

调用侧还加了一层节流:只在 stepIndex === 0 治愈一次(kun/src/loop/model-step-preparation-service.ts:151-153),因为一个 turn 内部循环只会追加格式良好的 item。


6. 瘦身三件套:图片封顶、描述压缩、工具结果限额

三件套按固定顺序叠加,注意它们作用域不同:

默认是否生效作用范围入口
图片封顶总是整个发送历史capToolResultImages
token economy默认关(enabled: false)仅当前 turn 的 itemapplyTokenEconomyToRequest
历史卫生无条件跑仅当前 turn 的 itemapplyRequestHistoryHygiene

6.1 截图只留最近 3 张

一个 computer-use 会话里,每张截图的 base64 都是几十万字符。capToolResultImages(history, MAX_FORWARDED_TOOL_IMAGES)(kun/src/loop/tool-result-image.ts:292,常量在 kun/src/loop/model-request-composer.ts:20,值为 3)只给最近 3 条带图的工具结果保留 payload,更早的换成一句提示:"older screenshot omitted to save context; take another screenshot if you need the current view"(tool-result-image.ts:42)。

配套的还有一个关键常量:IMAGE_TOOL_RESULT_TOKEN_ESTIMATE = 1_200(:30)。所有 token 估算器遇到图片一律按这个固定值计价,而不是按 base64 长度——否则一张截图会被算成几十万 token,直接把压缩逼疯。

isModelVisibleImageOutput(:89)是三层管道共用的那个谓词,只认 kindimage(read 工具)或 computer_screenshot(computer_use)两种输出(:39);generate_image 这种落盘的输出故意排除在外

6.2 token economy:默认关的那一半

DEFAULT_TOKEN_ECONOMY_CONFIG(kun/src/loop/token-economy.ts:19)的形状值得单独看:

{
enabled: false, // 总开关默认关
compressToolDescriptions: true,
compressToolResults: true,
conciseResponses: true,
historyHygiene: { maxCumulativeToolResultTokens: 120_000, keepRecentToolResults: 4 }
}

注意这个陷阱: enabled: falseapplyTokenEconomyToRequest 直接原样返回(:90),上面三个 compress* 全部无效;但 historyHygiene 那一块照样生效,因为 kun/src/loop/model-request-composer.ts:127 是在 economy 之外单独调用 hygiene 的。代码注释把这层意图写明了:hygiene 是 token 估算与传输前的最后一道边界(:75-78)。

开启后它做三件事:

  1. 追加 TOKEN_ECONOMY_INSTRUCTION(:35)——四句话,要模型简洁作答、原样保留代码/命令/路径/URL/标识符/报错、看到"内容被省略"就用更窄的 read/grep/bash 范围重取。
  2. compactToolSpec(:105)压缩每个工具的 description 和 schema 里所有 description 字段。
  3. compactHistoryItem(:113)按工具名分派压缩结果:bash / read / grep / find / ls 各有专门策略,其余走通用路径(:392-405)。

compressProse(:141)是这里最有意思的一个函数:它删填充词、客套话、模糊语("just/really/basically"、"please/thanks"、"perhaps/i think"),还删冠词。但删之前先做保护——protectTechnicalSegments(:163)把代码围栏、行内代码、URL、函数调用、含斜杠的路径、UPPER_SNAKE 常量、点号标识符、semver 全部替换成占位符,压完再还原。没有这层保护,"the API" 会被删成 "API" 无所谓,但 a/b/c.ts 里的 a 就出事了。

6.3 历史卫生:两级限额

applyRequestHistoryHygiene(kun/src/loop/request-history-hygiene.ts:70)是那张总是张着的网。它分两级:

第一级:单条上限。 默认值在 :35-44:

限额默认值
单条工具结果行数320
单条工具结果字节32 KB
单条工具结果 token8 000
已完成 tool_call 的字符串参数8 KB / 2 000 token
数组元素上限80

超限时不是简单截断头部,而是 selectCacheUsefulLines(:322)按"头 25% + 尾 35% + 命中错误关键词的行"三块挑(SIGNAL_LINE_RE:51 匹配 error/failed/panic/traceback/timeout 等),最后附一行 [cache hygiene: omitted N line(s), …] 说明省了多少。

第二级:累计预算。 applyCumulativeToolResultBudget(:125)从最新往最旧走,累加每条结果的估算 token;keepRecentToolResults(默认 4)条最近的无条件全额保留,其余在预算(默认 120 000)用完之后整条折成一行摘要(digestStaleToolResult,:175),摘要里带上工具名、大致 token 数、原文首行预览,并提示"re-run the tool or use narrower read/grep/bash ranges"。

作用域是个关键细节。 shouldCleanItem(:140-141)只处理 item.turnId === scope.currentTurnId 的项,而装配侧传的正是本轮 turnId(kun/src/loop/model-request-composer.ts:127-131)。所以这两级限额约束的是单个 turn 内部积累的工具结果(一个跑几百步的 agentic turn),跨 turn 的膨胀交给压缩管

还有一处 tool_call 的参数压缩只对"已经拿到结果的调用"生效(:95,靠 pairedToolCallIds)——因为参数原文已经没用了,结果里有全部信息。


7. 压缩:什么时候折、折成什么

7.1 思路

前面几层都是"每条变小",压缩是"很多条变一条"。触发靠估算 + provider 上报的双保险,产物是一条 compaction item,它替代掉一大截老历史。

7.2 阈值从哪来:两次取小

contextThresholdsForModel(kun/src/loop/model-context-profile.ts:127)先按模型 id 找 profile,再做一次安全帽:

// :138-147
const maxSoft = profile.contextWindowTokens ? floor(window * 0.75) : profile.softThreshold
const maxHard = profile.contextWindowTokens ? floor(window * 0.85) : profile.hardThreshold
return { softThreshold: min(profile.softThreshold, maxSoft),
hardThreshold: min(profile.hardThreshold, maxHard) }

也就是说,哪怕配置文件写了 98% / 99%,也会被硬压回 75% / 85%。注释解释了原因(:134-137:95-101):在 98% 才压,压完没有余量,下一条大工具结果就能冲破窗口——这正是之前"上下文疯长、工具表被挤掉"的成因。

具体数字:

模型上下文窗口softhard
deepseek-v4-pro / deepseek-v4-flash1 000 000750 000850 000
无 profile 的模型(回退)(未声明)96 000108 800

回退值的注释老实承认了局限(:86-90):它假设窗口 ≥128k,32k 的自定义端点如果不注册 profile,可能在第一次压缩之前就爆窗

7.3 不信任离谱的 prompt_tokens

这是本章最值得抄走的一段防御。

背景: provider 上报的 prompt_tokens 本该是"这次请求的输入长度",但有的 provider 把累计缓存读取量也加了进去。注释点名 MiniMax-M3 曾上报到真实值的约 25 倍,prompt_cache_hit_tokens 单项就超过整个已存对话——物理上不可能(kun/src/loop/context-compactor.ts:105-112)。

后果: 信了它,进度条永远钉在 100%,压缩疯狂空转,而实际上下文其实很小。

对策: 一条信任上界。

// kun/src/loop/context-compactor-types.ts:12
export const PROMPT_TOKEN_TRUST_FACTOR = 6
// kun/src/loop/context-compactor-helpers.ts:101 trustworthyPromptTokens
if (estimate > 0 && reported > estimate * PROMPT_TOKEN_TRUST_FACTOR) return undefined // 丢弃,改用本地估算

6 倍这个宽度是刻意选的(注释在 kun/src/loop/context-compactor-types.ts:3-11):既能吸收本地估算的合理低估(图片结果、角色/格式 token),又能抓住数量级级别的膨胀。丢弃时会打一条 warn,并按模型做 60 秒去重(warnInflatedPromptTokens,kun/src/loop/context-compactor-helpers.ts:114,间隔常量 :92)。

最终参与判定的值是 Math.max(估算值, 可信的上报值 ?? 0)(:118)——两者取大,宁可早压不可晚压。

7.4 估算器为什么不是 length / 4

ContextEstimator.estimateText(kun/src/loop/context-estimator.ts:48)对 ASCII 按 4 字符 1 token 打包,非 ASCII 一律按 1 字符 1 token,组合记号(̀-ͯ 等)算 0。

理由写在类注释里(:9-20):中日韩用户是这个 App 的主力,朴素的 length / 4 会把中文严重低估,导致压缩在真正爆窗之后才触发。

另有 estimateRequestOverheadTokens(kun/src/loop/model-request-estimator.ts:88)补上"每回合都发但不在 item 里"的那部分:system prompt + few-shot + 工具 schema。它作为估算下限加进去(kun/src/loop/history-compaction-service.ts:104-110),专治"进程刚重启、没有 provider 计数"时的系统性低估。

7.5 计划与折叠

planCompaction(kun/src/loop/context-compactor.ts:63)算出 tokens 后分三档:

档位触发线keepRecent(保留最近几条原文)
normal≥ soft4
aggressive≥ soft + 60% × (hard − soft)2
force≥ hard1

compact(:142)的切分:

[ frozen 冻结头 ][ ————— head 折叠区 ————— ][ tail 保留区 ]
│ │ │
原样留 换成一条 compaction 原样留
item(带 pinnedConstraints
+ sourceDigest + 摘要)

三个容易踩的边界都处理了:

  • trimTrailingToolCalls(:231)先砍掉尾部没有结果的 tool_call。
  • repairTailStartForToolResults(:241)检查 tail 里有没有孤儿 result,有就把切点往前挪到上一条 user_message
  • 历史太短(length <= 1)时返回一个 replacedTokens: 0 的 noop 摘要(:170-184),不做无意义折叠。

摘要正文由 buildCompactionSummary(:310)拼:先逐条列出 pinnedConstraints(:330-337)——这就是"钉住的约束能活过压缩"的兑现处——再扫历史里 Active Skill: / Skill Pin: 开头的行(extractSkillPins,:363),最后是每条 item 的一行摘要,超过 20 行就"头 4 + 尾 14 + 省略提示"(selectSummaryLines,:410)。

末尾还会追加一个 <kun:tool_digest sha256="..."> 标记(createToolDigestMarker,kun/src/loop/compaction-marker.ts:8),哈希源是被折叠 item 的稳定投影(compactedItemsDigestSource,:12)。

7.6 用模型写摘要(可选)

contextCompaction.summaryMode === 'model' 时,启发式摘要会被一次真实模型调用替换(调用点在 kun/src/loop/history-compaction-service.ts:258,实现 summarizeCompactionWithModelkun/src/loop/compaction-summary.ts:171)。

这条路的三个设计取舍:

  1. 喂真消息,不喂序列化文本。 history: [...conversation, continuationItem](:237),后面跟一条自由格式的续写提示(buildCompactionContinuationMessage,:51),提示里再把 pinnedConstraints 逐条列一遍。
  2. 故意丢掉主 agent 的前缀。 systemPrompt: COMPACTION_SYSTEM_PROMPT + prefix: [] + tools: [](:235-238),注释说明这是"干净的 summarizer turn"。代价是这次调用不复用主对话的缓存——而 Kun 的 system prompt 里其实建议"总结时尽量保持同样的契约和工具形状以复用已缓存字节"(kun-system-prompt.ts:45)。契约和实现在这一点上不一致,文档如实记录。
  3. 失败就退回启发式。 超时(默认 15 s,:8)、报错、返回空文本,一律经 recordFallback 回调记一条说明(:192-195:250-275)并返回 undefined

7.7 压缩之后:历史怎么被"截断"

effectiveHistoryAfterLatestCompaction(kun/src/loop/compaction-history.ts:3)从尾往前找第一条 replacedTokens > 0 的 compaction item,从它开始切:

if (item.kind === 'compaction' && item.replacedTokens > 0) return items.slice(index)

replacedTokens > 0 这个条件不是装饰:noop 摘要的 replacedTokens 是 0,不会被当成截断点。

磁盘上保存的是 [head, summary, tail] 的顺序,所以这个函数返回 [summary, tail]。而给 GUI 的 thread-store 用的是另一种排布——placeCompactionsAtTurnEnd(:57)把摘要挪到所属 turn 的末尾,否则渲染器的 groupTurns 会把"已压缩上下文"那一行显示到上一次对话下面(:45-56)。同一份数据,两种排布,各服务一端。


8. 省钱路由:用便宜模型决定用不用贵模型

8.1 auto 路由

thread 的 model 写成 auto 时,ModelRoutingService.resolve(kun/src/loop/model-routing-service.ts:27,旧名 resolveTurnModel)会先跑一次分类:

用户输入 + 最近 6 条上下文


flash 当分类器(deepseek-v4-flash)
maxTokens 96 · temperature 0 · json_object · thinking off · 4s 超时

├── 成功 → {"model":"...-pro|...-flash","thinking":"off|high|max"}
└── 超时/报错/解析失败 → 关键词 + 长度启发式

AUTO_MODEL_ROUTER_SYSTEM_PROMPT(kun/src/loop/auto-model-router.ts:19)的分派口径:琐碎/闲聊/单步走 flash;编码、调试、发版、多步、高风险、工具密集、需求含糊走 pro。thinking 档位:仅无工具的琐碎问答用 off,普通推理 high,涉及 agentic/多文件/架构/安全/调试的用 max

这次分类调用本身是"省钱"的实践样本(resolveAutoModelRoute,:27 与请求体 :46-71):没有工具、没有 few-shot、maxTokens: 96temperature: 0responseFormat: 'json_object'reasoningEffort: 'off',再加 4 秒硬超时(AUTO_MODEL_ROUTER_TIMEOUT_MS,:8)。

兜底路径 autoModelHeuristic(:83)是纯本地规则:命中 refactor/architecture/debug/security/… 等 12 个关键词就 pro;否则 <100 字符走 flash,>500 字符走 pro。解析用 parseAutoRouteRecommendation(:108),同时接受 thinking / reasoning_effort / effort 三种键名,并把 low/minimal/medium 都归一到 high(normalizeAutoRouteEffort,:215)。

路由结果按 (threadId, turnId) 缓存(kun/src/loop/model-routing-service.ts:36-42:55),同一 turn 内多步不重复分类

另有 normalizeRoleReasoningEffort(kun/src/loop/reasoning-effort.ts:8)守着一条更保守的默认:标题、摘要、review 这类一次性角色调用,配置值非法或缺失时一律落到 'off',免得"廉价默认"意外升级成深度思考。

8.2 预算闸门

TurnBudgetGate.check(kun/src/loop/turn-budget-gate.ts:99,旧名 checkBudgetGate)在装配准备的最前面跑(kun/src/loop/model-step-preparation-service.ts:110),读 thread 的 costBudgetUsd(turn-budget-gate.ts:116):

状态条件动作
放行无预算或 spent < budget × 0.8继续
警告spent ≥ budget × 0.8 且没警告过记一条 budget_warning,把 costBudgetWarningSent 置位,继续
拦截spent ≥ budgetbudget_limited,return 'stop'

拦截时还多做一件事:把 turnId 加进 goalResumeSuppressedByTurn(kun/src/loop/model-step-preparation-service.ts:116-119),防止"目标自动续跑"把这个 turn 又推回同一个耗尽的预算里。

8.3 压力回填:重启之后怎么办

recordPromptPressure(kun/src/loop/loop-telemetry.ts:41)在每次收到 usage chunk 时记下本 thread 的 promptTokens(调用点在 kun/src/loop/model-round-engine.ts:376),只记更大的值(if (current && current.promptTokens >= promptTokens) return)。压缩时 consumePromptPressure 取用并清空(:48,调用点在 kun/src/loop/history-compaction-service.ts:102)。

这张表是纯内存的,进程重启就没了。早先有个 hydratePromptPressureIfCold 会从持久化 usage 里冷启动补回最后一次压力值——该函数已移除;现在冷启动的兜底就是 §7.4 的 overhead 下限(estimateRequestOverheadTokens,kun/src/loop/history-compaction-service.ts:104-110):system prompt + few-shot + 工具 schema 照算,防止"重启后第一步把超大 thread 判成不用压"。


9. 计量:命中率的分母为什么是 hit+miss

9.1 两个分母,两种问题

hit
───────── ← cacheableTokenHitRate:"可缓存的那部分里,命中了多少"
hit + miss

hit
───────── ← totalInputTokenHitRate:"整个输入里,有多少是命中的"
promptTokens

Kun 两个都报,但主口径是上面那个。原因是分母的可信度差了一个量级:

  • hit + miss同一次响应里成对出现的两个 provider 计数器构成,它们描述的是同一个"可缓存 token 全集",内部自洽。
  • promptTokens 是账单口径,不同 provider 对它的定义并不一致。§7.3 那个 MiniMax 案例就是活证据:有 provider 把累计缓存读取量折进 prompt_tokens,分母被吹大,hit / promptTokens 直接失去意义。

主口径实现在 diagnoseCacheUsage(kun/src/cache/cache-diagnostics.ts:101-106),累计口径在 UsageCounter.record(kun/src/telemetry/usage-counter.ts:42-46)和 CacheTelemetry.snapshot(kun/src/telemetry/cache-telemetry.ts:57)——三处都是 hit / (hit + miss),总量为 0 时返回 null 而不是 0。

9.2 "半份遥测"必须当没有

// kun/src/cache/cache-diagnostics.ts:42
const hasProviderMetrics = hitTokens !== undefined && missTokens !== undefined

两个计数器必须同时存在才算有遥测。注释讲了不这么做的后果(:38-41:81-83):只报 hit 不报 miss 的 provider,会让分母 = hit,算出一个"完美 1.0"的假命中率,同时把 miss 原因分支整个掩盖掉。

宁可报 null("不知道")也不报一个好看的假数字——这一点和 system prompt 里那句"Never fabricate cache hit rates"是一致的(kun/src/prompt/kun-system-prompt.ts:56)。

9.3 没命中的时候,告诉用户为什么

每次请求会构造一个 CacheRequestSignature(kun/src/loop/model-step-service.ts:503-514),九个字段:

model · providerId · endpointFormat · prefixFingerprint · toolCatalogFingerprint
partitionHash · partitionPhase · unavailableAttachmentCount · activeSkillIds

UsageService.withCacheDiagnostics(kun/src/services/usage-service-core.ts:164,现在是私有方法,由落账路径在 :49 调用)把它和上一次同 thread 的签名逐字段比,不同的就产出一条 miss 原因:

原因触发条件
cold_request本 thread 没有上一次签名
model_changed / provider_changed / endpoint_changed对应字段变了
stable_prefix_changed前缀指纹变了
tool_catalog_changed工具目录指纹变了
skills_changed激活的 Skill 集合变了(比较前先排序)
provider_metrics_unavailable两个计数器不齐
provider_cache_miss + cache_ttl_unknownmiss > 0 且 hit == 0

每类原因配一句可执行的建议(kun/src/cache/cache-diagnostics.ts:50-78),比如前缀变了就提示"把时间戳、工作区片段等易变数据挪出稳定前缀"。这些字符串会一路走到 GUI 的用量面板(ThreadUsageBucket.last_cache_miss_reasons / last_cache_suggestions,kun/src/contracts/usage.ts:144-145)。

9.4 一个容易误读的指标:last_turn_cache_hit_rate

ThreadUsageBucket 里既有累计的 cache_hit_rate,又有 last_turn_cache_hit_rate。schema 的注释解释了为什么要拆(kun/src/contracts/usage.ts:135-139):累计值永远被那个不可避免的冷启动第一轮拖着,而用户想看的是稳态命中率。取值逻辑用 ISO 时间戳字典序比较,>= 保证并列时取数组靠后那条(kun/src/services/usage-service-responses.ts:57)。

跨线程聚合时,mergeUsage(kun/src/telemetry/usage-counter.ts:218-220)不做比率的平均——那是错的——而是先把 token 数加总再重算比率。


10. 巧妙之处(可以直接抄走的)

  1. "引用不变 = 没改动"当成协议用。 repairModelHistoryItemsnormalizeLoadedItem 在无改动时原样返回入参,上层就能用 !== 代替深比。这不是微优化,是修一个把健康探针饿死的真 bug(history-healing.ts:10-15,KunAgent/Kun#621)。

  2. 不信任 provider 的自报数据,但给它一个宽区间。 PROMPT_TOKEN_TRUST_FACTOR = 6 既容得下本地估算的合理低估,又抓得住 25 倍级别的胡说(kun/src/loop/context-compactor-types.ts:3-12)。

  3. 配置能调,但调不过安全帽。 阈值最终对 75% / 85% 取小,配置写 99% 也没用(model-context-profile.ts:134-147)。把"用户可能配错"当成必然。

  4. 只许增不许改的目录策略。 isAdditiveToolCatalogChange 用每工具单独哈希把"加工具"和"改工具"分开,前者放行、后者把目录变更冻结到下一 turn 再生效(kun/src/loop/turn-tool-catalog.ts:64kun/src/loop/loop-telemetry.ts:92)。

  5. 探测器故意不用正则。 UUID / hash / ISO / JWT 形状重叠,结构化解析在误报时可调试(prefix-volatility.ts:17-23)。JWT 还真去解码前两段验 JSON。

  6. 压缩前先修边界,而不是压完再补。 砍尾部裸 tool_call、把切点往前挪到 user_message 以避免孤儿 result(context-compactor.ts:231,241)。

  7. 同一份数据两种排布。 模型要 [summary, tail],渲染器要摘要落在自己那个 turn 的末尾,于是给 GUI 单独做一次重排(compaction-history.ts:46-82)。

  8. 压缩前保护技术片段。 compressProse 删冠词和填充词之前,先把路径 / 代码 / URL / 常量整段挡起来(token-economy.ts:155)。


11. 边界与局限(诚实清单)

  • 稳定前缀在绑定 persona 的 thread 上不再"纯净"。thread.systemPrompt 时,它经 buildThreadProfileInstruction 变成独立的 threadProfileInstruction 字段随请求发出(kun/src/loop/model-request-composer.ts:81:89);而 cacheSignature.prefixFingerprint 记的仍是不含 persona 的指纹。同 thread 内 persona 不变则诊断仍然自洽,跨 thread 比较会失真 (inferred)。

  • 生产环境默认不校验前缀指纹。 需要显式设 KUN_VERIFY_IMMUTABLE_PREFIX=1(immutable-prefix.ts:21,96)。线上偷改前缀不会当场报错。

  • 易变内容探测器只报不拦。 结果只进 pipeline stage 的 details(kun/src/loop/model-step-preparation-service.ts:190-194),没有任何阻断逻辑。

  • 模型摘要那条路会丢缓存。 它刻意用独立的 system prompt、空 prefix、空 tools(compaction-summary.ts:235-238),与 system prompt 自己写的"总结时尽量复用已缓存字节"(kun-system-prompt.ts:45)不一致。

  • 没注册 profile 的小窗口模型会爆窗。 回退阈值 96k/108.8k 假设窗口 ≥128k,注释自己承认 32k 端点可能在第一次压缩前就超窗(model-context-profile.ts:86-90)。

  • 历史卫生的两级限额只管当前 turn。 shouldCleanItemcurrentTurnId 过滤(request-history-hygiene.ts:140-141),跨 turn 的膨胀完全依赖压缩。

  • maxCumulativeToolResultTokens 的库内默认是 0(不限)。 120 000 这个值来自 DEFAULT_TOKEN_ECONOMY_CONFIG(token-economy.ts:29);直接调用 applyRequestHistoryHygiene 而不走 economy 配置的调用方,拿到的是 DEFAULT_MAX_CUMULATIVE_TOOL_RESULT_TOKENS = 0,即无累计上限(request-history-hygiene.ts:45)。

  • 两份规范化代码各写了一遍。 immutable-prefix.ts:27-52tool-catalog-fingerprint.ts:23-56 逐行同构,没有抽公共模块;任一侧改了排序规则,两条指纹线就会不一致。

  • LruCache / TtlLruCache 在运行时热路径没有使用点。 它们从 kun/src/index.ts:19 对外导出,而 §4.3 的快照表是手搓 LRU。


12. 代码地图

主题文件关键符号
稳定前缀结构与四个入口kun/src/cache/immutable-prefix.tsImmutablePrefixsetSystemPromptsetToolssetPinnedConstraintssetFewShots
指纹与规范化kun/src/cache/immutable-prefix.tsbuildFingerprintcanonicalizenormalizeToolsfewShotCacheShape
指纹自检与漂移定位kun/src/cache/immutable-prefix.tsverifyImmutablePrefixdescribeFingerprintDriftshouldVerifyImmutablePrefix
工具目录指纹kun/src/cache/tool-catalog-fingerprint.tsbuildToolCatalogFingerprintnormalizeToolSpecs
易变内容探测kun/src/cache/prefix-volatility.tsdetectVolatilePrefixContentisCanonicalUuidisJwt
缓存未命中诊断kun/src/cache/cache-diagnostics.tsdiagnoseCacheUsageCacheRequestSignatureCacheMissReason
通用缓存容器kun/src/cache/lru-cache.tsttl-lru-cache.tsLruCacheTtlLruCache
前缀内容规则kun/src/prompt/kun-system-prompt.tsKUN_SYSTEM_PROMPT(Cache behavior 一节)
tool_call/result 配对修复kun/src/domain/model-history-repair.tsrepairModelHistoryItemsisToolResultBridgeItem
加载期治愈kun/src/loop/history-healing.tshealLoadedHistoryItemsnormalizeLoadedItem
发送前历史卫生kun/src/loop/request-history-hygiene.tsapplyRequestHistoryHygieneapplyCumulativeToolResultBudgetdigestStaleToolResult
token economykun/src/loop/token-economy.tsDEFAULT_TOKEN_ECONOMY_CONFIGTOKEN_ECONOMY_INSTRUCTIONapplyTokenEconomyToRequestcompressProseprotectTechnicalSegments
图片封顶与计价kun/src/loop/tool-result-image.tscapToolResultImagesIMAGE_TOOL_RESULT_TOKEN_ESTIMATEisModelVisibleImageOutput
压缩计划与执行kun/src/loop/context-compactor.tsContextCompactorplanCompactioncompactPROMPT_TOKEN_TRUST_FACTORtrustworthyPromptTokens
token 估算kun/src/loop/context-estimator.tsmodel-request-estimator.tsContextEstimator.estimateTextestimateRequestOverheadTokens
模型阈值档案kun/src/loop/model-context-profile.tscontextThresholdsForModelMODEL_CONTEXT_PROFILESDEFAULT_CONTEXT_THRESHOLDS
压缩后历史视图kun/src/loop/compaction-history.tseffectiveHistoryAfterLatestCompactionplaceCompactionsAtTurnEnd
压缩摘要标记kun/src/loop/compaction-marker.tscreateToolDigestMarkercompactedItemsDigestSource
模型写摘要kun/src/loop/compaction-summary.tsCOMPACTION_SYSTEM_PROMPTsummarizeCompactionWithModelresolveCompactionModel
auto 路由kun/src/loop/auto-model-router.tsresolveAutoModelRouteAUTO_MODEL_ROUTER_SYSTEM_PROMPTautoModelHeuristic
角色级思考档位kun/src/loop/reasoning-effort.tsnormalizeRoleReasoningEffort
主装配点与各闸门kun/src/loop/model-step-service.tskun/src/loop/model-step-preparation-service.tskun/src/loop/model-request-composer.tskun/src/loop/model-routing-service.tskun/src/loop/turn-budget-gate.tskun/src/loop/loop-telemetry.tsModelStepService.runHistoryCompactionService.compactIfNeededModelRoutingService.resolveTurnBudgetGate.checkrecordPromptPressurerecordToolCatalogFingerprintisAdditiveToolCatalogChange
用量与命中率口径kun/src/telemetry/usage-counter.tscache-telemetry.tsUsageCounter.recordmergeUsageCacheTelemetry.snapshot
用量服务与诊断挂载kun/src/services/usage-service-core.tsUsageService.withCacheDiagnosticsbuildThreadUsageResponse
用量契约kun/src/contracts/usage.tsUsageSnapshotSchemaThreadUsageBucketSchema
前缀的生产装配点kun/src/server/runtime-composition-core.tscreateImmutablePrefix 调用(:122)

接着读: 工具目录本身怎么来、三道闸门怎么拦 → 04-tools-and-gates;这份请求体最终怎么变成 HTTP 报文 → 05-model-layer;谁在驱动这一切循环 → 02-agent-loop