Claude Code 的 SKILLS 技能渐进式披露实现原理解析

SKILLS 和 渐进式披露 是 A 家最早提出来的方案,也是 OpenClaw 火了后大家一直讨论的哪个技能好用很核心的强依赖的实现逻辑。

如果把 Claude Code 的 skills 理解成一堆 prompt 文件,后面的很多设计都解释不通。

从其源码实现来看,会发现它在解决的核心问题是:怎么让模型保留足够强的技能召回能力,同时又不把常驻上下文撑爆。

这件事说穿了就是五个字:渐进式披露

大概的逻辑是:

  • 先告诉模型「系统里存在 skills 机制」。
  • 再告诉它「当前有哪些 skill 名称和简短说明」。
  • 等它真的决定调用某个 skill 时,再把正文、权限、hooks、模型覆盖、附加工具权限这些重内容展开。
  • 如果某些 skill 还和路径、目录、文件类型绑定,那就继续往后拖,拖到模型真的碰到对应文件时再激活。

这是一个优雅且干净的工程化设计。它没有发明一套复杂到难以维护的 skill runtime,也没有把所谓智能寄托在黑盒检索器上,而是先把「披露成本」这件事控制住。

我们按工程实现往下拆:

  • skill 在系统里到底被建模成什么
  • 多来源 skill 是怎么统一装配的
  • 渐进式披露具体分了哪几层
  • 条件激活和动态发现是怎么接进文件操作链路的
  • inline 和 fork 两条执行路径分别解决什么问题
  • 这套设计真正适合什么场景,代价又是什么
  • 如果要在自己的 Agent 里复刻,最短落地路径应该怎么走

一、先看 skills 在系统里被建模成什么

Claude Code 里,skill 最终会被统一建模成 Command,而且类型是 prompt

最核心的构造函数是 createSkillCommand:

return {
type'prompt',
  name: skillName,
  description,
  hasUserSpecifiedDescription,
  allowedTools,
  argumentHint,
  argNames: argumentNames.length > 0 ? argumentNames : undefined,
  whenToUse,
  version,
  model,
  disableModelInvocation,
  userInvocable,
  context: executionContext,
  agent,
  effort,
  paths,
  contentLength: markdownContent.length,
  isHidden: !userInvocable,
  progressMessage: 'running',
  userFacingName(): string {
    return displayName || skillName
  },
  source,
  loadedFrom,
  hooks,
  skillRoot: baseDir,
async getPromptForCommand(args, toolUseContext) {
    ...
    return [{ type'text', text: finalContent }]
  },
}

这段代码说明有几个关键点:

  • skill 不是特殊 runtime object,而是 prompt command
  • skill 本体是 getPromptForCommand() 生成的一组文本 block
  • skill 可以带:
    • allowedTools
    • model
    • effort
    • paths
    • hooks
    • context: inline | fork
  • skill 的调用结果,不是「执行一段脚本」,而是把 skill 展开成后续对话消息,或者 fork 成子代理执行

如果我们自己做 Agent,建议参考。skill 不要单独发明一套 DSL runtime,直接把它抽象成「可延迟展开的 prompt 命令」就够了。

二、skills 的来源有哪几类

skills 并不只来自一个目录。getSkills() 会把多个来源统一聚合。[commands.ts] commands.ts#L353-L398

const [skillDirCommands, pluginSkills] = await Promise.all([
  getSkillDirCommands(cwd)...
  getPluginSkills()...
])
const bundledSkills = getBundledSkills()
const builtinPluginSkills = getBuiltinPluginSkillCommands()

然后 loadAllCommands() 再把这些东西和 workflow/plugin/内建命令一起合并。[commands.ts] commands.ts#L445-L469

也就是说,skills 的来源至少有:

  • bundled skills
  • 磁盘上的 /skills/
  • plugin skills
  • builtin plugin skills
  • 兼容旧 /commands/ 目录加载进来的 prompt commands

SkillTool 根本不需要知道 skill 来自哪里。只要最后是 prompt command,就能走统一调用路径。

三、skills 的「渐进式披露」分 5 层

1)第一层:系统提示只声明「技能机制存在」

系统提示里不会把所有 skill 正文直接塞进去。它只给一个能力声明,告诉模型:

  • 用户说 /<skill-name>,其实是在指 skill
  • 可以用 SkillTool 去执行
  • 不要乱猜,只能调用列出来的那些

这段在 [prompts.ts] prompts.ts#L353-L401:

hasSkills
  ? `/<skill-name> (e.g., /commit) is shorthand for users to invoke a user-invocable skill. When executed, the skill gets expanded to a full prompt. Use the ${SKILL_TOOL_NAME} tool to execute them. IMPORTANT: Only use ${SKILL_TOOL_NAME} for skills listed in its user-invocable skills section - do not guess or use built-in CLI commands.`
  : null

这一步只暴露了机制,没有暴露内容

2)第二层:只披露 skill 名称和短描述

真正给模型看的 skill 列表,是通过 getSkillToolCommands() 过滤出来的。[commands.ts] commands.ts#L561-L580

return allCommands.filter(
  cmd =>
    cmd.type === 'prompt' &&
    !cmd.disableModelInvocation &&
    cmd.source !== 'builtin' &&
    (
      cmd.loadedFrom === 'bundled' ||
      cmd.loadedFrom === 'skills' ||
      cmd.loadedFrom === 'commands_DEPRECATED' ||
      cmd.hasUserSpecifiedDescription ||
      cmd.whenToUse
    ),
)

这段有两个要点:

  • 只有 prompt 命令才能进 skill 列表
  • 并不是所有 prompt command 都自动暴露,至少得满足可描述性要求

也就是说,可执行集合对模型披露集合不是完全相同的。
Claude Code 在这里收了一刀,避免模型看到一堆没有描述、无法判断用途的技能。

3)第三层:列表本身还要走预算裁剪

skill 列表不是全量原文塞进 prompt,而是按预算压缩过的。核心逻辑在 [prompt.ts] tools/SkillTool/prompt.ts#L20-L171。

最关键的常量:

export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const DEFAULT_CHAR_BUDGET = 8_000
export const MAX_LISTING_DESC_CHARS = 250

以及格式化逻辑:

return `- ${cmd.name}${getCommandDescription(cmd)}`

和预算裁剪:

if (fullTotal <= budget) {
  return fullEntries.map(e => e.full).join('\n')
}

如果超预算,就会:

  • bundled skills 尽量保留完整描述
  • 其它 skills 截断 description
  • 极端情况下退化成只发 - skill-name

这就是很典型的渐进式披露:先给最小可用索引,不给正文

4)第四层:列表还是增量下发,不是每轮全量重发

技能列表通过 skill_listing attachment 发给模型。发送逻辑在 [attachments.ts] utils/attachments.ts#L2669-L2752。

核心逻辑:

const newSkills = allCommands.filter(cmd => !sent.has(cmd.name))
...
for (const cmd of newSkills) {
  sent.add(cmd.name)
}
...
return [
  {
    type'skill_listing',
    content,
    skillCount: newSkills.length,
    isInitial,
  },
]

这个 sentSkillNames 机制说明:

  • 第一次发的是初始批次
  • 后面只发新增的 skill
  • resume 之后还会 suppress,避免重复污染上下文

然后 messages.ts 会把它包成系统提醒。[messages.ts] utils/messages.ts#L3763-L3772

return wrapMessagesInSystemReminder([
  createUserMessage({
    content: `The following skills are available for use with the Skill tool:\n\n${attachment.content}`,
    isMeta: true,
  }),
])

很多 Agent 会每轮把所有 tools / skills 全量重发,Claude Code 显然在认真控 token。 当然,如果技能不多,也可以直接全量发,不要过早优化。

5)第五层:真正的 skill 内容延迟到调用时才展开

直到调用 SkillTool,skill 的真实正文才会通过 command.getPromptForCommand() 生成。[SkillTool.ts] utils/processUserInput/processSlashCommand.tsx#L869-L920

这里才会发生:

  • $ARGUMENTS 替换
  • ${CLAUDE_SKILL_DIR} 替换
  • ${CLAUDE_SESSION_ID} 替换
  • markdown 内嵌 shell 执行
  • hooks 注册
  • 附加权限 attachment 注入
  • invoked skill 记录

换句话说,skill 的重内容、重权限、重上下文副作用,都是按需加载

四、除了延迟加载,它还做了「条件激活」

这也是渐进式披露的重要一层,而且很多人会漏掉。

1)带 paths frontmatter 的 skill,不会启动即暴露

getSkillDirCommands() 里会把 skill 分成两类:[loadSkillsDir.ts] loadSkillsDir.ts#L771-L803

if (
  skill.type === 'prompt' &&
  skill.paths &&
  skill.paths.length > 0 &&
  !activatedConditionalSkillNames.has(skill.name)
) {
  newConditionalSkills.push(skill)
} else {
  unconditionalSkills.push(skill)
}

然后 conditional skills 被先放进 conditionalSkills map,而不是直接进入模型可见集合。

这意味着:

  • 你定义了某个 skill 只适用于 *.tsx
  • 它不会在项目启动时就干扰所有任务
  • 只有模型真的碰到匹配文件时,这个 skill 才会被激活

2)激活时机挂在文件操作上

FileRead / FileWrite / FileEdit 三个工具里,都有两步副作用:

  • 发现上层目录里的 .claude/skills
  • 激活匹配当前文件路径的 conditional skills

比如 FileReadTool:[FileReadTool.ts] /tools/FileReadTool/FileReadTool.ts#L575-L591

const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd)
...
addSkillDirectories(newSkillDirs).catch(() => {})
...
activateConditionalSkillsForPaths([fullFilePath], cwd)

对应的激活实现是 [activateConditionalSkillsForPaths] skills/loadSkillsDir.ts#L997-L1058:

const skillIgnore = ignore().add(skill.paths)
...
if (skillIgnore.ignores(relativePath)) {
  dynamicSkills.set(name, skill)
  conditionalSkills.delete(name)
  activatedConditionalSkillNames.add(name)
}

这一步非常像条件规则系统,而不是纯静态注册。
效果就是:技能集合会随着你读写哪些文件而变化

五、动态发现本身也是渐进式披露的一部分

除了 path-conditional activation,Claude Code 还支持目录级动态发现

1)启动时只加载一部分 skill 目录

getSkillDirCommands() 启动时会加载:

  • managed
  • user
  • project dirs
  • additional dirs
  • legacy commands

但它不会把所有嵌套目录里的 .claude/skills 一次性全扫出来。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L638-L804

2)当模型碰到某个文件时,再向上走目录树找嵌套 skill

discoverSkillDirsForPaths() 会从当前文件的父目录开始,一路往上走到 cwd,查找 .claude/skills。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L861-L915

while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude''skills')
  ...
  await fs.stat(skillDir)
  ...
  newDirs.push(skillDir)
}

而且还做了两个非常实用的约束:

  • 已检查过的目录不会重复 stat
  • gitignored 目录里的 skills 不会静默加载

这个设计让:
技能跟着你进入子目录而出现,不跟整个仓库一起一次性曝光。

六、SkillTool 的调用链,实际上分 inline 和 fork 两条路

这是技能系统和普通 slash command 最大的不同之一。

1)调用前校验

SkillTool.validateInput() 会做:

  • 去掉前导 /
  • 检查 skill 是否存在
  • 检查是否 disableModelInvocation
  • 检查是否为 prompt 类型
    见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L355-L430

关键逻辑:

const commands = await getAllCommands(context)
const foundCommand = findCommand(normalizedCommandName, commands)
...
if (foundCommand.type !== 'prompt') {
  return {
    result: false,
    message: `Skill ${normalizedCommandName} is not a prompt-based skill`,
  }
}

2)权限检查

SkillTool.checkPermissions() 很细,除了 allow / deny 规则,还会对「只有安全属性的 skill」自动放行。[SkillTool.ts] /tools/SkillTool/SkillTool.ts#L433-L579

这个设计的意义是:

  • 简单 declarative skill 不必每次都弹权限
  • 带额外风险属性的 skill 要 ask user

3)inline skill:展开成后续对话消息

默认分支会走 processPromptSlashCommand()。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L635-L644

getMessagesForPromptSlashCommand() 干的事情很丰富:[processSlashCommand.tsx] utils/processUserInput/processSlashCommand.tsx#L827-L920

  • command.getPromptForCommand(args, context) 得到真正 skill 正文
  • 注册 hooks
  • addInvokedSkill() 记录 skill 内容,供 compact 时恢复
  • 从 skill 文本里再抽 attachment
  • 增加 command_permissions attachment
  • 生成一批 messages

返回结构里最关键的是:

return {
  messages,
  shouldQuery: true,
  allowedTools: additionalAllowedTools,
  model: command.model,
  effort: command.effort,
  command
}

也就是说,inline skill 的本质是:
把 skill 变成一段新的上下文和权限修饰,然后让主对话继续跑。

4)fork skill:交给子代理跑,再把结果归还

如果 skill frontmatter 里声明 context === 'fork',就走 executeForkedSkill()。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L622-L633

它会:

  • 构造子代理上下文
  • runAgent()
  • 收集 agent messages
  • 抽取结果文本
  • 最终返回 { status: 'forked', agentId, result }
    见 [executeForkedSkill] /tools/SkillTool/SkillTool.ts#L122-L290

这一步说明 Claude Code 已经把 skill 分成两类:

  • 知识/流程模板型 skill:inline 展开
  • 工作委派型 skill:fork 子代理执行

这个值得学一下。不是所有 skill 都应该展开在主上下文里。

七、结果返回逻辑

为什么它也算渐进式披露的一部分?

1)inline skill 的 tool_result

很轻

mapToolResultToToolResultBlockParam() 对 inline skill 的返回只是:

content: `Launching skill: ${result.commandName}`

见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L857-L862

也就是说,tool_result 本身不承载 skill 的全部结果。
真正有价值的内容在 newMessages 里,已经被送回主会话继续推理。

2)fork skill 的 tool_result

直接带最终结果

fork skill 返回的是:

content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`

见 [SkillTool.ts] tools/SkillTool/SkillTool.ts#L848-L855

这是因为 fork skill 已经在独立上下文里把工作做完了,主线程要拿的是总结结果。

所以在 Claude Code 里,skill 结果返回不是单一模式,而是:

  • inline:返回「已加载 skill」,真正内容进主对话
  • fork:返回「子代理执行结果」

这也是一种披露控制。
不同执行语义,对结果暴露方式也不同。

八、如何简要实现

一个新 Agent,如何简要实现 skills 的发现、召回、调用、结果返回?

一个够用、够短、能落地的最小设计,不追求和 Claude Code 一模一样,但核心思路一致。

1)第一步:统一 skill 数据结构

最小结构建议这样:

type Skill = {
  name: string
  description: string
  whenToUse?: string
  contentLoader: (args: string, ctx: AgentContext) => Promise<string>
  allowedTools?: string[]
  model?: string
  effort?: 'low' | 'medium' | 'high'
  context?: 'inline' | 'fork'
  paths?: string[]
}
  • contentLoader 允许延迟展开
  • context 决定 inline/fork
  • paths 支持条件激活
  • allowedTools/model/effort 支持 skill 级上下文修饰

这和 Claude Code 的 createSkillCommand() 思路是一致的。[loadSkillsDir.ts] skills/loadSkillsDir.ts#L270-L401

2)第二步:启动时只加载「索引」,不要加载正文

最简做法:

  • 扫描 skills 目录
  • 解析 frontmatter
  • 只把 name / description / whenToUse / paths / context 放进 registry
  • skill 正文不要此时进 prompt

示意:

async function loadSkillIndex(skillDirs: string[]): Promise<Skill[]> {
const skills: Skill[] = []
for (const dir of skillDirs) {
    for (const skillFile of await listSkillFiles(dir)) {
      const raw = await readFile(skillFile, 'utf8')
      const { frontmatter, content } = parseFrontmatter(raw)
      skills.push({
        name: basename(dirname(skillFile)),
        description: String(frontmatter.description ?? ''),
        whenToUse: frontmatter.when_to_use ? String(frontmatter.when_to_use) : undefined,
        paths: Array.isArray(frontmatter.paths) ? frontmatter.paths : undefined,
        context: frontmatter.context === 'fork' ? 'fork' : 'inline',
        contentLoader: async () => content,
      })
    }
  }
return skills
}

这个阶段要学 Claude Code 的不是目录细节,而是索引和正文分离

3)第三步:做一个「未发送 skill 集合」

这是渐进式披露的核心。

维护一个 session 级状态:

type SkillDisclosureState = {
  sentSkillNames: Set<string>
}

每轮只发送新的:

function getNewSkillListings(skills: Skill[], sent: Set<string>): Skill[] {
  const fresh = skills.filter(s => !sent.has(s.name))
  for (const s of fresh) sent.add(s.name)
  return fresh
}

然后把它格式化成短列表,而不是全文:

function formatSkillListing(skills: Skill[]): string {
  return skills.map(s => `- ${s.name}${s.description}`).join('\n')
}

这对应 Claude Code 的 sentSkillNames + skill_listing attachment 方案。

4)第四步:把文件操作接成动态发现触发器

如果你也想要「技能跟着目录出现」,最小版本就是:

  • 用户或模型读/写/改文件时
  • 从文件父目录往上走到 cwd
  • 看有没有 .agent/skills 或 .claude/skills
  • 找到新目录就加载 skill index

示意:

async function discoverSkillDirsForFile(filePath: string, cwd: string): Promise<string[]> {
const dirs: string[] = []
let current = dirname(filePath)
while (current.startsWith(cwd + sep)) {
    const candidate = join(current, '.agent''skills')
    if (await exists(candidate)) dirs.push(candidate)
    const parent = dirname(current)
    if (parent === current) break
    current = parent
  }
return dirs
}

Claude Code 的现成参考是 [discoverSkillDirsForPaths] skills/loadSkillsDir.ts#L861-L915。

5)第五步:做条件激活,而不是启动时全暴露

如果 skill 定义里有 paths,就不要一开始暴露。
等碰到匹配文件时再激活:

function activatePathScopedSkills(
  pending: Skill[],
  touchedFiles: string[],
): { active: Skill[]; remaining: Skill[] } {
const active: Skill[] = []
const remaining: Skill[] = []
for (const skill of pending) {
    if (!skill.paths || skill.paths.length === 0) {
      active.push(skill)
      continue
    }
    const matched = touchedFiles.some(file => matchAny(file, skill.paths!))
    if (matched) active.push(skill)
    else remaining.push(skill)
  }
return { active, remaining }
}

这就是 Claude Code conditionalSkills -> activateConditionalSkillsForPaths() 的最小复刻。


6)第六步:调用 skill 时才真正加载正文

不要提前把 skill 正文塞到 prompt。
调用时再做:

async function invokeSkill(
  skill: Skill,
  args: string,
  ctx: AgentContext,
): Promise<SkillInvocationResult> {
const prompt = await skill.contentLoader(args, ctx)

if (skill.context === 'fork') {
    const result = await runSubAgent({
      prompt,
      allowedTools: skill.allowedTools,
      model: skill.model,
      effort: skill.effort,
    })
    return { mode: 'fork', result }
  }

return {
    mode: 'inline',
    newMessages: [
      { role: 'user', content: `[SKILL:${skill.name}]` },
      { role: 'user', content: prompt, meta: true },
    ],
    allowedTools: skill.allowedTools,
    model: skill.model,
    effort: skill.effort,
  }
}

这就是 Claude Code SkillTool.call() 的最小骨架。[SkillTool.ts] tools/SkillTool/SkillTool.ts#L581-L863

7)第七步:结果返回必须分 inline 和 fork

直接照 Claude Code 的语义分两种:

inline

  • 返回一个轻 tool_result:Launching skill: xxx
  • 真正内容通过 newMessages 回到主对话继续推理

fork

  • 返回最终结果摘要
  • 子代理对话不污染主上下文

示意:

type SkillInvocationResult =
  | {
      mode: 'inline'
      newMessages: Message[]
      allowedTools?: string[]
      model?: string
      effort?: string
    }
  | {
      mode: 'fork'
      result: string
    }

这一步是很多新 Agent 最容易偷懒的地方。
要么所有 skill 都 inline,主上下文爆炸;要么所有 skill 都 fork,失去细粒度引导。

九、小结

「skills 的渐进式披露」其实就是 Claude Code 在控制 prompt 成本和能力密度时最典型的设计之一。它真正解决的问题不是「怎么找到一个 skill」,而是「怎么在不把上下文撑爆的前提下,让模型知道自己有技能可用」。

它背后的思路:

  • 先给索引
  • 再给局部集合
  • 再给真实正文
  • 最后才给执行结果

这是一个很像搜索引擎的设计:摘要、点击、展开、消费,而不是把整本书扔给你。

以上。

 

Claude Code 的下一个 AI 范式:KAIROS

在 Claude Code 的代码中,如果只算 KAIROS 出现的次数,其出现了 154 次;如果算上以其为前缀的变量啥的,其出现了 365 次。

KAIROS 是什么?

简单来说,KAIROS 是 Claude Code 未来的 AI 形态,一个在恰当时机出现的,一直在线的协同工作伙伴。

KAIROS (καιρός) 源自古希腊语,意为「正确的、关键的或合宜的时刻」,代表定性的、超越时序的「时机」或「关键瞬间」。

KAIROS 这件事,重点从来不在于它多了几个工具开关,也不在于文档里写了多少「常驻助手」「主动工作」这种产品话术。它真正改变的,是 Claude Code 的运行范式:从「终端里的同步问答器」,切到「长期在线、异步协作、跨渠道接入、能自己维持工作节奏的常驻代理」。

这个变化很大。大到我不太愿意把它叫成一次功能升级。它更像一次产品类别切换。

如果这个方向跑通,Claude Code 的竞争对象会变。它不再只是和一批 coding assistant CLI 去比「补全快不快、命令懂不懂、上下文长不长」。它会开始进入另一条赛道:谁更像一个持续在线的工程协作者,谁能承接跨时间、跨终端、跨系统的工作责任。

问题也在这里。KAIROS 现在的仓库状态,远没有到「产品封版」的程度。外围能力已经长出不少,主闭环还没彻底打穿。Bridge、Brief、频道消息、每日记忆日志、后台任务基础设施,这些都不是 PPT。assistant 主入口、gate、proactive 状态、session discovery 这些地方,又明显还是 stub。方向很清楚,骨架也搭起来了,真正决定产品能不能稳定落地的那几条主链路,还差最后一截硬骨头。

这篇文章我们不按功能清单来复述一遍。那样太浅,也没工程价值。回答三个关键的问题:

  1. KAIROS 在 Claude Code 里到底重新定义了什么
  2. 它已经落地了哪些关键能力,哪些地方还没闭环
  3. 为什么它必须改写记忆系统、交互渠道和执行模型

KAIROS 改写的不是功能,是运行模型

普通 CLI 的交互模型很简单。

用户打开终端。输入一条指令。模型分析上下文。调用工具。给出回答。进程结束,或者这一轮逻辑结束。下一次再来,虽然可能还能靠项目文件、历史记录、memory 文件接上一部分语境,但本质上还是新一轮同步请求-响应。

这个模式有一个天然上限:AI 只在用户看着终端的时候存在。用户不在,系统就不工作。外部事件来了,也接不住。长任务只能靠用户盯着。跨设备继续工作这件事,基本也无从谈起。

KAIROS 想改掉的,就是这个上限。

它想要的模型是另一种结构:

  • 会话可以长期存在
  • 进程重启后还能接回原来的会话
  • 外部系统可以把消息推到这个会话里
  • 用户没有新输入时,Agent 也能继续推进任务
  • 工作状态通过记忆、日志和摘要维持
  • 输出形态适配异步消费,而不是只适配终端前的一次性阅读

这不是「更主动一点」。这是一整套运行时假设变了。

我一直觉得,很多人看这类能力时容易掉进一个误区:看见 SleepTool、push notification、channels,就以为这只是「给 CLI 加点自动化」。这个理解有点太保守。真正的变化是,系统开始假设自己是一个持续值班的实体,而不是一个按回车键才苏醒的函数调用。

一旦假设变了,后面的东西都会跟着变。会话管理会变。记忆策略会变。输出格式会变。安全边界会变。成本模型会变。产品定位也会变。

从代码现状看,KAIROS 已经是一组能力家族

从文档和源码状态看,KAIROS 不是单点 feature flag,它更像一个能力总开关,把若干子系统串成一个共同叙事。

已有的子功能包括:

  • KAIROS_BRIEF
  • KAIROS_CHANNELS
  • KAIROS_PUSH_NOTIFICATION
  • KAIROS_GITHUB_WEBHOOKS
  • KAIROS_DREAM

工具注册层能看到对应工具:

  • SleepTool
  • SendUserFileTool
  • PushNotificationTool
  • SubscribePRTool

从这些就可能看到背后对应的产品动作:

  • 控制执行节奏和保活
  • 主动把结果回传给用户
  • 在终端外做异步通知
  • 订阅外部事件,反向驱动内部执行

这里最值得注意的是其产品动作的形态已经变了。

传统 CLI 工具的动作集合通常是:

  • 读文件
  • 写文件
  • 执行命令
  • 输出结果

KAIROS 的动作集合变成:

  • 等待
  • 监听
  • 回传
  • 唤醒
  • 跨渠道接入
  • 持续维持上下文

这说明它的定位已经不再是单纯的「本地操作器」。它正往「工作流中枢」走。

Bridge 是 KAIROS 最关键的基础设施之一

KAIROS 能不能成立,第一件事不是主动性,而是连续性

如果会话不能连续存在,所谓常驻助手就是假的。它最多是一个本地守护进程。用户侧体验还是断裂的。今天在一个终端开的事情,明天换个终端、换个设备、换个入口,就接不上了。那这个产品心智根本立不起来。

Bridge 正是在补这个问题。

从现有设计看,Bridge 的数据流:远端入口收到用户消息,通过 bridge 拉取工作,创建或恢复 REPL,会话继续执行,再把结果回传。这个思路解决的是「用户感知到的是不是同一个持续存在的助手」。

代码里关键点在 useReplBridge。assistant 模式下会启用 perpetual bridge session,目的是让远端看到的是同一条持续会话,而不是每次 CLI 启动都开一条新的 session。

没有 perpetual session,用户面对的是很多段相似但断裂的对话。每次恢复都像重新认识一次项目。上下文可能靠 memory 拼回来一点,但主观体验一定是断的。

有了 perpetual session,用户会开始把这个东西当成「同一个一直在线的协作者」。这就是范式变化真正落地的第一步。

但真实闭环卡住的地方,不在 Bridge 本身

Bridge 骨架基本有了,assistant 产品主链路还没闭环。

很多团队做 Agent 系统时,最容易陷入一种错觉:底层传输能通,远程会话能恢复,消息能送达,就以为产品主路径已经打通。其实不是。通道打通,只代表系统能传递状态。离「一个稳定可用的常驻助手产品」还差几个关键层:

  • 身份判定
  • gate 放行
  • 会话发现
  • assistant 专属上下文初始化
  • assistant 专属系统提示
  • 持续工作状态机
  • 长期记忆蒸馏

当前源码里,这些都还没有。

assistant 主入口还是 stub

assistant 主模块里,isAssistantMode() 返回 false,初始化函数是空的,assistant 专属 prompt addendum 也是空的。

这意味着什么?

意味着一大堆外围逻辑虽然预留了 assistant 分支,但真正运行时根本进不去。Bridge 想按 assistant 模式走 perpetual session,要靠 isAssistantMode()。主程序想按 assistant 模式切换运行逻辑,也要靠它。远端 worker 类型的区分,还是要靠它。

入口判定没实现,后面这条链就全断了。

KAIROS gate 还是 stub

isKairosEnabled() 直接返回 false,这表示:产品级放行逻辑还没接上。

这不是个小洞。因为常驻助手和普通 CLI 完全不是一个风险等级的东西。它能后台执行、接外部消息、长期持有上下文、主动做事。没有真实 gate,这种能力根本不适合大面积开。

所以从工程视角看,gate 现在是缺实现。从产品视角看,这代表「产品入口控制逻辑还停留在骨架层」。

session discovery 还是 stub

如果用户执行 assistant viewer 路径,系统理论上应该能发现已有常驻会话,然后接回去。现在 discovery 返回空数组,这条体验链路就断了。

这直接伤到产品最核心的承诺之一:会话连续性。

你都号称是常驻助手了,结果用户回来时找不到自己之前那条会话,这个心智会瞬间塌掉。

proactive 状态模块还是 stub

KAIROS 的 prompt 层已经开始定义 autonomous work 的行为协议了,比如:

  • 没有事做时必须 sleep
  • 用户不在终端前时偏向自主推进
  • 用户正在看终端时偏向协作和简洁输出

行为协议有了,状态机没落地。

KAIROS 最大的区别,不是工具多,而是 prompt 协议变了

对于 Agent 系统,prompt 在很多时候就是运行协议的一部分。

普通模式下,模型更像一个执行器。收到请求,完成任务,给出答复。

KAIROS 模式下,prompt 在定义另一种工作方式:

  • 什么时候该自己推进
  • 什么时候该停下来等待
  • 什么时候该用 Brief 压缩输出
  • 什么时候该把结果异步推给用户
  • 用户是否在终端前,会影响表达风格和协作策略
  • 没有明确工作时不能空转,必须 sleep

这本质上是在给模型灌输一套「值班协作者协议」。

因为常驻助手和一次性问答器的差别,很大一部分在于它是否具备稳定的工作节奏。节奏不稳,再强的工具集也会把系统拖进两个极端:

  • 一直唤醒,疯狂消耗 token 和 API 调用
  • 一直沉睡,错过事件和推进时机

所以 SleepTool 这种东西,表面看是个小工具,本质上是在为 Agent 增加时间维度。普通 CLI 处理的是空间里的资源:文件、命令、输出。KAIROS 开始处理时间里的资源:等待、唤醒、周期、空闲、值班。

这一步一旦做出来,产品形态就变了。

KAIROS 为什么必须改写记忆系统

这里必须要聊一下。

在长任务中,我们不能拿短会话的记忆模型去硬撑长时间在线系统。写放大、污染、冲突、检索噪音、摘要失真,很快全出来。

KAIROS 在这件事上切到了 daily log 模式。

普通模式下,长期记忆更接近「主题文件 + 索引」:

  • 新信息被整理成相对成型的 topic files
  • MEMORY.md 维护索引
  • 模型下次需要时按主题读回

这个模式适合短周期会话。信息密度高,整理成本还能接受。

KAIROS 场景不一样。它面对的是:

  • 长时间持续执行
  • 高频事件流输入
  • 用户不一定实时盯着
  • 外部渠道消息可能随时插入
  • 同一天内工作状态会不断变化
  • 大量信息是过程态,不适合立刻主题化

如果还按普通模式那种「一有信息就整理成 topic file」去写,工程上会出三个明显问题。

第一,写放大会很严重

频繁改 topic files,会导致目录不断抖动。MEMORY.md 也会被频繁重写。对于常驻系统,这种写入模式很不稳。量一上来就开始恶心人。

第二,过程信息会过早结构化

很多工作过程在当下并不适合写成结论。比如一条外部消息、一次等待中的验证、一个还没确认的假设、某项任务中间状态。这些东西如果过早塞进长期记忆,很容易污染后续上下文。

第三,恢复和追溯会变差

当你把所有信息即时揉进主题文件里,原始事件流会逐渐丢失。系统后面做蒸馏、回溯、纠错时,材料反而不够。

daily log 方案就是为了解这些问题。

它的核心思想很简单:

  • 白天先 append-only 记录到当日日志
  • 不急着重组和提炼
  • 后续再把成熟信息蒸馏成长期 memory

这是典型的事件流优先设计。先保留工作轨迹,再做结构化提炼。对常驻助手来说,这个方向很稳。

普通模式里的 memory 是模型的记忆补丁,KAIROS 里的 memory 是产品连续性的基础设施。

这两者不是一个级别的东西。

transcript 被纳入记忆蒸馏

KAIROS 还想做一件更重要的事:把 session transcript 也纳入记忆蒸馏输入。

这意味着长期记忆的来源,不再只靠模型「当前轮总结出的信息」,而是开始吸收完整工作轨迹。

你可以把这理解成两种 memory 策略的差异:

普通模式

长期记忆主要存「结论」

KAIROS 模式

长期记忆想从「事件流」中提取结论

因为一个持续在线的协作者,真正有价值的上下文往往不只在最后结果里,还在过程里:

  • 谁在什么时候发来过什么消息
  • 某项任务等待了多久
  • 哪个验证步骤失败过
  • 某个方向为什么被放弃
  • 同一项目在不同日期里怎么演化的

如果记忆系统拿不到这些过程信息,系统就只能越活越像一个失忆的执行器:只记结论,不记来路。

当前仓库里这条链还没补齐,session transcript 相关实现还是 stub。当把普通 auto-dream 关闭,改走 KAIROS 专属 dream 路径,那就必须有足够材料来做蒸馏。daily logs 和 transcript 就是这个材料池。

没有材料池,dream 只是概念。
没有蒸馏,daily log 只是堆积。
没有长期记忆收敛,常驻助手就只剩「一直在线」,没有「越用越像同一个协作者」。

Brief 不是 UI 花活

它是异步协作场景里的输出压缩层

我很喜欢 KAIROS 里对 Brief 的定位,因为这个点很多产品会忽略。

一旦系统进入长期运行、跨终端、跨渠道、还带移动端通知的场景,传统那种长篇回复会迅速失效。不是模型写不出来,而是用户根本没法消费。

你想象一下:

  • 一个后台任务跑了两小时
  • 外部 webhook 触发了一轮检查
  • Slack 里推来一条状态更新
  • 用户此时在手机上看通知

这时候如果系统还按终端里的详细答复风格,甩一大段解释文字出去,体验会很差。信息密度低,确认成本高,真正关键的状态反而埋住了。

Brief 的价值就在这里。它是异步工作场景的输出压缩层。

它解决的不是「怎么更优雅地显示」,而是「在不同消费界面里,用最低认知成本传递足够状态」。这是一个工程问题,不是文案问题。

所以我会把 Brief 看成 KAIROS 的必要配套,而不是锦上添花。没有它,常驻助手很容易被自己的输出拖死。

channels 让 Claude Code 开始脱离终端边界

KAIROS 另一条很重要的线,是频道消息系统,也是一个很让人期待的逻辑,虽然当前有一些方法已经可以实现。

当前设计已经允许外部消息通过 channel notification 之类的机制进入会话,被包装成结构化消息再投递给模型处理。这意味着:

  • Claude Code 不再只属于一个本地终端
  • 用户可以在终端外部和同一条工作流继续互动
  • AI 可以成为跨渠道的工作代理

这个变化一旦做成,产品就会从 Developer Tool 往 Agent Platform 滑过去。

再直白一点。

终端工具的边界,是「你必须来到我的界面里,我才能帮你做事」。
工作流代理的边界,是「我能在你的工作流里持续存在,你在哪个入口出现,我都能接上」。

这是两种完全不同的产品位置。

这样,channels 就很重要了,但不该排在最前面的优先级。因为它解决的是入口扩展问题,不是主闭环问题。一个内部还没站稳的系统,入口接得越多,事故面越大。没有 assistant 激活链、记忆蒸馏链和 proactive 状态机托底,channels 只会让问题更快暴露。

后台执行是一等能力,不是附属能力

KAIROS 把后台执行推成一等能力。

这是常驻助手能否成立的另一条底线。

如果 Agent 像同事一样持续工作,它就不能被单次命令执行周期绑死。用户离开终端,任务还得继续跑。长任务不能把主线程挂住。等待回调、监听事件、轮询状态这些行为,不能都靠用户盯着终端来维持。

很多 coding assistant 在 demo 阶段看起来很聪明,一到真实项目就暴露上限:用户一旦离开终端,整个系统的价值密度就迅速下降。长任务没人接。回调没人等。状态没人维持。所谓 Agent,最后还是个问答器。

KAIROS 显然想突破这个上限。

后台执行一旦变成一等能力,系统复杂度会明显上升。你要处理:

  • 阻塞任务后台化
  • 任务状态跟踪
  • 唤醒与恢复
  • 错误回传
  • 与记忆系统的状态对齐
  • 与通知节奏的协调

这些东西没有一项是白送的。做不好,后台任务会变成后台事故。

KAIROS 的产品价值,核心在五个地方

如果从产品结果看,我会把它的价值拆成五个方面。

一,留存会显著提升

一次性问答工具的留存天然一般。因为每轮交互都相对独立,用户用完就走。上下文积累浅,切换成本低。

常驻会话、跨重启续接、长期记忆、异步回传这些东西组合起来,用户会开始把 Claude Code 当成「当前项目的长期协作体」。一旦心智变成这样,迁移成本就会明显上升。

二,任务完成度会提升

很多高价值任务不是一轮 prompt 能做完的。它们需要等待、重试、监听、回调、验证、持续推进。普通模式下,这类任务经常会在用户离开终端时中断。

KAIROS 提供的后台执行、sleep 唤醒、外部事件驱动,正好在补这个缺口。产品会从「答题器」往「任务执行器」走。

三,渠道覆盖会扩大

有了 channels、push、bridge、webhook,Claude Code 的触点会明显增加。对产品运营和组织传播来说,这个价值非常直接。一个只能在终端里使用的工具,天然局限在一小撮高频命令行用户。一个能进入 Slack、移动通知、远程 viewer 的系统,扩散面会大得多。

四,粘性会增强

session continuity、daily logs、structured brief、跨渠道接续,这几样东西叠在一起,会形成很强的黏着效应。用户会越来越依赖它手里的上下文。上下文越深,替换成本越高。

五,产品想象空间会被抬高

做到这里,Claude Code 的竞争对象就不再只是其它 coding assistant。它会开始接近「开发团队的操作层代理」:监听、执行、回报、沉淀记忆、跨渠道协作。

KAIROS 的代价非常重

讲到这里如果只谈价值,那就是宣传稿了。工程里没有这么轻松的事。

KAIROS 的代价主要有四类。

第一,系统复杂度暴涨

从一次性 CLI 进入常驻模式后,系统要处理的东西会指数级增加:

  • 长生命周期会话
  • bridge 重连
  • 会话恢复
  • 远端消息幂等
  • 外部事件接入
  • 后台任务状态
  • 唤醒节奏
  • 记忆蒸馏
  • 多渠道权限边界
  • 通知频率控制

这些全都会增加测试成本、排障成本、回归成本。

传统 CLI 很多问题是可复现、可局部调试的。常驻 Agent 的问题常常是跨时间、跨系统、跨状态累积的。定位难度完全不是一个量级。

第二,成本模型会变差

tick + sleep 这套机制,本质上是在用更多调用换取持续在线行为。架构会更顺,产品体验会更强,API 成本也会上去。

如果没有很严格的唤醒控制、任务优先级控制和输出压缩策略,系统会非常烧钱。尤其当常驻会话一多,哪怕每个会话只是周期性地「看一眼有没有事」,成本都可能迅速放大。

很多团队做 Agent 产品,死得最早的往往不是能力不够,是成本失控。KAIROS 这条路如果真要产品化,成本治理必须和功能迭代同步推进,不能等做大了再补。

第三,安全与信任门槛会更高

一个普通 CLI 助手,最多是「用户下命令,它帮你执行」。
一个 KAIROS 式常驻助手,是「它持续持有上下文,接外部消息,可能在后台自主执行」。

这两个系统的风险等级完全不同。

assistant 模式下先检查 trusted directory,再检查 KAIROS gate,这个设计信号已经很明确:作者知道风险在上升。问题是现在 gate 还是 stub,主链路还没完全接上,说明这块还在建设中。

产品能不能卖进团队、卖进企业,很多时候就看这里。因为企业不会只问「它能做什么」,他们会问得更直接:

  • 它什么时候能自己执行
  • 谁能给它发消息
  • 它能看到哪些目录
  • 它会把什么记下来
  • 它错了怎么停
  • 它怎么审计

这些都是 KAIROS 必须正面回答的问题。

第四,产品承诺和实现闭环还没完全对齐

这是当前最现实的问题。

外围能力铺得已经不少,主入口和主状态层仍然有 stub。这个状态很典型:战略方向先行,支撑设施先铺,真正的产品主通路还在补。

这种状态有机会,也有风险。

机会在于,一旦主入口补齐,成长速度可能很快,因为外围都在等它。
风险在于,如果主闭环补得太慢,外围越多,系统越像「展台很大,地基不稳」。

为什么我认为 KAIROS 是整个项目里最重要的方向

因为它决定的不是某个 feature,而是产品类别。

没有 KAIROS,Claude Code 依然可以是一个很强的 coding assistant CLI。
有了 KAIROS,而且真做成了,它会变成一个持续在线、能跨渠道、能长期记忆、能异步执行的工程助手。

这两者在商业形态上不是一个东西。
在用户心智上不是一个东西。
在组织采购逻辑上也不是一个东西。

对个人开发者,这意味着「我下班以后,它还能继续跑」。
对团队协作,这意味着「我们多了一个持续在线的 AI teammate」。
对企业管理者,这意味着「AI 被接入的是工作流,不是单次问答」。

这三层价值如果连起来,产品天花板会明显抬高。

我甚至会说,KAIROS 成不成,决定了 Claude Code 最终是一个强工具,还是一个强平台。

当前仓库里,KAIROS 的真实成熟度如何?

大概是四点:

  • 产品意图非常清楚: 因为从文档、prompt、memory 策略、bridge 设计、channels、brief,到后台任务工具,整个方向是一致的。不是东一块西一块拼起来的。它们都在服务同一个产品叙事:把 Claude Code 推成常驻助手。
  • 框架布线已经做了很多: 工具层、提示词层、bridge 层、memory prompt 分叉、channel notification、部分 viewer 路径,这些都已经不是空想。它们证明团队不是在写概念文档,而是在提前铺路。
  • 关键外围能力有真实实现:Bridge perpetual session、频道消息接入、Brief 规则、daily-log memory prompt,这些都具备产品骨架。哪怕主入口还没封口,外围支撑已经能看出最终形态。
  • 主入口和核心状态闭环仍有明显缺口:assistant 主模块、gate、session discovery、proactive 状态、session transcript 等地方的 stub,说明核心闭环还没完全长实。这个阶段我会把它定义为:接近产品化的战略子系统,而不是一个完整封版的功能包。

KAIROS 的本质,是把 Claude Code 从「提效工具」推向「责任承接者」

文章写到这里,其实主已经很清楚了。

KAIROS 真正改变的,是 Claude Code 的「存在方式」。

普通 Claude Code 的存在方式是:用户打开终端,它出现;终端关闭,这轮交互的主价值结束。
KAIROS 的存在方式是:用户不在场时,它仍然能持有状态、接收事件、维持节奏、积累记忆、回传结果。

它在把 Claude Code 从前台交互工具,推向后台协作代理。

一旦这个方向跑通,产品的护城河也会变。将来真正难被替代的,不只是模型回答质量,而会落在这些更重的东西上:

  • 上下文沉淀深度
  • 渠道接入深度
  • 工作流嵌入程度
  • 组织内协同能力
  • 长期责任承接能力

这才是 KAIROS 值得持续投入的原因。

小结

KAIROS 已经完成了产品方向的自洽,完成了相当一部分外围基础设施铺设,正在卡在主入口、记忆蒸馏和自主循环这三条上。只要这三条主链补齐,它很快就会从「战略子系统」变成「真正定义 Claude Code 下一阶段的核心产品」。

这件事一旦做成,Claude Code 的故事就不再是「一个更强的 coding assistant CLI」。

它会变成另一个东西。

  • 一个持续在线的工程协作者。
  • 一个能接进团队工作流的后台代理。
  • 一个真正开始承接持续工作责任的 AI 系统。

这才是 KAIROS 的星辰大海。

以上。

 

深入 Claude Code 源码了解其记忆系统

最近做 Agent 的同学应该大部分都有研读 Claude Code 泄漏的源码,网上出了各种 AI 加持下的各种解读,教程,细节分析,甚至包括换了一种语言实现的版本,如 Python,Go,Rust 等等。感觉有点「一鲸落,万物生」的感觉。

之前学习了 Claude Code 的系统提示词,写了一篇关于记忆系统的提示词。今天我们再深入其源码,看看其实现的细节。

从其源码来看,

Claude Code 这套记忆系统把几类完全不同的问题拆开处理了:长期记忆、当前轮相关记忆、会话压缩摘要、子代理独立记忆。和 OpenClaw 不同,OpenClaw 使用了统一的 Memory Service,加上一个向量库做检索,Claude Code 走的是另一条路:文件系统优先,分层清晰,召回时机明确,代价可控

1. Claude Code 到底要记了什么

在 memoryTypes.ts#L14-L31 里,长期记忆的类型是的四类:

  • user
  • feedback
  • project
  • reference

这四类东西有一个共同点:它们都不容易从当前代码状态直接推导出来。用户习惯、项目背景、团队约束、外部系统入口,这些信息不写下来,下次对话就丢了。反过来,代码结构、文件路径、Git 历史、当前临时任务,这些内容源码里明确要求不要进长期记忆,因为它们本来就有权威来源。memoryTypes.ts#L183-L195

记忆系统只该保存「代码外的信息」和「会跨轮次继续影响决策的信息」。以编程为例,当我们把代码事实也塞进去,后面一定会出现双份真相。你会遇到一个很尴尬的局面:代码说 A,memory 说 B,模型开始摇摆。

2. 四层分工

第一层是 auto memory / team memory。这是长期记忆,负责跨会话保存信息。目录逻辑在 paths.ts#L79-L259 和 teamMemPaths.ts#L66-L94。

第二层是 relevant memories。这一层不关心长期存储,它只负责一件事:用户当前这一问,应该把哪几条历史记忆临时塞进上下文。入口在 findRelevantMemories.ts#L39-L141 和 attachments.ts#L2197-L2425。

第三层是 session memory。这层服务的是长会话压缩,不负责跨会话记忆。位于当前 session 下的 summary.md。sessionMemory.ts#L183-L350

第四层是 agent memory。子代理如果要持久化自己的经验,可以放 user/project/local 三种 scope 的独立目录。agentMemory.ts#L12-L177

这四层拆开之后,很多设计选择就顺了:

  • 长期记忆用文件,便于审计和手工修复
  • 当前轮召回走轻量检索,减少 prompt 污染
  • 长会话压缩用单独 summary,避免每次 compact 都从头总结
  • 子代理隔离状态,减少串味

3. 长期记忆为什么选文件

Claude Code 的长期记忆是使用的 Markdown 文件。每条记忆一个文件,外加一个 MEMORY.md 入口索引。这部分规则在 memdir.ts#L199-L316 里写得很明白。

源码里的写入约束是这样的:

'## How to save memories',
'',
'Saving a memory is a two-step process:',
'',
'**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:',
'',
...MEMORY_FRONTMATTER_EXAMPLE,
'',
`**Step 2** — add a pointer to that file in \`${ENTRYPOINT_NAME}\`. \`${ENTRYPOINT_NAME}\` is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`${ENTRYPOINT_NAME}\`.`,

实现位置见 memdir.ts#L219-L230。

还有有三个工程判断。

第一,MEMORY.md 只是索引,不承载正文。如果把所有记忆都堆到一个大文件里,前期简单,后期灾难。Claude Code 从一开始就做拆分,每条记忆单文件,这样更新一条信息时不会引起全量重写。

第二,frontmatter 强制有 description 字段,这个字段后面要参与召回。很多团队做知识条目,只写正文,不写检索摘要,最后靠 embedding 硬扛。Claude Code 反过来,它要求记忆写入阶段就产出一条高质量摘要。召回质量在写入那一刻就埋下去了。

第三,完全基于文件系统,调试成本低。你可以直接去目录里看文件,团队同步时还能走 Git 或远端同步链路。数据库方案最大的问题不在性能,在可观察性。出了问题你要查 schema、查索引、查 embedding 版本、查写入日志,排障很慢。

文件方案当然也有代价。文件一多,目录扫描成本会上升;MEMORY.md 入口过长也会逼近 prompt token 上限。Claude Code 后面靠动态召回机制兜住了这个问题,这个设计是连起来看的。

4. 写入链路

它怎么把记忆真正落盘

4.1 主模型直接写

长期记忆的第一条写入链路,是主模型自己写。系统 prompt 里已经告诉它记忆目录在哪、允许写什么、怎么写文件、什么时候写。

loadMemoryPrompt() 会把 memory rules 注入系统提示词,入口在 [loadMemoryPrompt] memdir.ts#L419-L507。这一段 prompt 并没有替模型做决策,它只是把写入协议放进脑子里:目录、类型、索引格式、读取时机、失效校验。

这意味着 Claude Code 对模型的假设很明确:模型可以自己判断「这条信息值不值得保存」,然后调用写文件工具去落盘。写入不是一个外置 API,写入就是普通文件操作。

这条路有个好处:反馈延迟很低。用户刚说完「记住这个偏好」,主模型当轮就能写,不用等后台任务。

4.2 后台抽取器补写

如果主模型这一轮没动手写,系统会在 turn end 触发后台抽取器。stop hook 在 stopHooks.ts#L141-L156:

if (
  feature('EXTRACT_MEMORIES') &&
  !toolUseContext.agentId &&
  isExtractModeActive()
) {
  void extractMemoriesModule!.executeExtractMemories(
    stopHookContext,
    toolUseContext.appendSystemMessage,
  )
}

真正逻辑在 extractMemories.ts#L329-L567。

这条链路里最重要的一段判断是:

if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
  logForDebugging(
    '[extractMemories] skipping — conversation already wrote to memory files',
  )
  ...
  return
}

位置见 extractMemories.ts#L345-L360。

它防的是双写。主模型已经写过,后台抽取器就别再重做一遍。很多系统做异步归档时忘了这件事,最后要么生成重复记忆,要么覆盖用户刚刚确认的内容。

后台抽取器的权限也非常收敛。createAutoMemCanUseTool() 明确规定,只准:

  • Read / Grep / Glob
  • 只读 Bash
  • memory 目录内的 Edit / Write

实现见 [createAutoMemCanUseTool] extractMemories.ts#L166-L222。

extractor 的职责:它只做归档,不许顺手验证代码,不许顺手修改业务文件,不许借机跑工具链。权限如果不锁死,后台代理迟早会从归档器膨胀成第二个主代理。

4.3 KAIROS 下的写法

KAIROS 模式更有意思。它不要求模型实时维护 MEMORY.md,新记忆先按天追加到日志文件里。规则在 [buildAssistantDailyLogPrompt] memdir.ts#L318-L370。

"This session is long-lived. As you work, record anything worth remembering by **appending** to today's daily log file:",
` \`${logPathPattern}\` `,
'Write each entry as a short timestamped bullet. Create the file (and parent directories) on first write if it does not exist. Do not rewrite or reorganize the log — it is append-only. A separate nightly process distills these logs into `MEMORY.md` and topic files.',

这条策略很适合长驻 Agent。会话存活时间长时,频繁重写 topic files 和索引很贵,冲突也多。先写 append-only 日志,夜间再蒸馏,吞吐更稳,模型也更不容易在白天工作时把记忆目录写乱。

5. 召回链路

它怎么决定哪段记忆该进来

Claude Code 的召回要分成两种看。

一种是静态注入,也就是固定随上下文加载的那些东西。另一种是动态召回,根据当前 query 临时挑选最相关的记忆文件。

很多系统只做前者,结果上下文越来越肥。很多系统只做后者,结果基本行为约束丢了。Claude Code 两条都做。

5.1 静态注入

getUserContext() 会构造一个 claudeMd 字段,位置在 context.ts#L155-L188。

核心调用是:

const claudeMd = shouldDisableClaudeMd
  ? null
  : getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))

getMemoryFiles() 的实现很长,在 claudemd.ts#L790-L1075。它会统一加载:

  • Managed 指令
  • User 指令
  • Project 指令
  • Local 指令
  • AutoMem 的 MEMORY.md
  • TeamMem 的 MEMORY.md

然后 getClaudeMds() 把这些文件串成提示词内容,[getClaudeMds] claudemd.ts#L1153-L1195。

它给模型一个稳定的全局工作框架。它会知道项目规则、用户偏好、团队共享记忆索引。它适合放那些「大方向会持续生效」的内容。

5.2 动态召回

静态注入解决不了所有问题。长期记忆正文一多,全部塞进 prompt 代价太高。Claude Code 的处理方式,是每轮用户发言后启动一个相关记忆预取。

入口在 query.ts#L297-L304:

using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
  state.messages,
  state.toolUseContext,
)

这个预取不会阻塞主流程。到后面条件满足时再消费,query.ts#L1595-L1617。

真正检索逻辑在 attachments.ts#L2197-L2425。它会先决定搜索哪个目录:如果用户显式提到某个 agent,就搜 agent memory;否则搜 auto memory。

然后调用 [findRelevantMemories] findRelevantMemories.ts#L39-L141。

这里最有意思的点在于,它没有用向量库。它的步骤是:

  1. scanMemoryFiles() 扫 memory 目录里的 .md 文件,读 frontmatter,产出一个 manifest
    见 memoryScan.ts#L35-L94

  2. 用户 query + manifest 发给一个 sideQuery 模型
    见 findRelevantMemories.ts#L77-L141

  3. 让这个模型返回最多 5 个文件名

  4. 再去读取这些文件正文,截断到限定行数和字节数
    见 [readMemoriesForSurfacing] attachments.ts#L2280-L2333

这套方案的好处:

  • 没有 embedding 构建成本
  • 没有索引维护复杂度
  • manifest 很小,side query 很快
  • 召回逻辑对开发者可见,容易调

缺点也明确。召回质量强依赖 frontmatter 的 description。写入时 description 写差了,后面召回一定差。

6. 记忆怎么进入上下文

不是一处注入,是四处入口

很多人看 Agent 源码时老在问「上下文是在什么地方拼进去的」。这个问题本身就有误导性。Claude Code 里记忆的注入入口不止一个。

6.1 system prompt 入口

第一处是 system prompt。这里进来的内容主要是「记忆系统的使用规则」,比如什么时候读、什么时候存、什么时候验证失效。对应实现是 prompts.ts#L492-L527 调 loadMemoryPrompt()

这是行为层指令。

6.2 user context 入口

第二处是 getUserContext() 构造的 claudeMd。这里进来的是 CLAUDE.md、rules、MEMORY.md 这种比较稳定的文本。context.ts#L155-L188

这是稳定背景层。

6.3 attachment 入口

第三处是 relevant memory attachment。被召回的正文不会直接拼到 claudeMd,而是先变成 attachment,再由 messages.ts#L3743-L3756 包装成 <system-reminder>

return wrapMessagesInSystemReminder(
  attachment.memories.map(m => {
    const header = m.header ?? memoryHeader(m.path, m.mtimeMs)
    return createUserMessage({
      content: `${header}\n\n${m.content}`,
      isMeta: true,
    })
  }),
)

这意味着这些记忆是临时的、按轮次加载的、带 freshness header 的系统提醒。它的优先级和普通用户消息不同。

6.4 compact summary 入口

第四处是 session memory compact。上下文过长后,系统会把会话前半段替换为一条 summary message,summary 内容来自 summary.md 的裁剪版。sessionMemoryCompact.ts#L437-L503

这是上下文续命层。

四处入口分工以后,就能看明白为什么 Claude Code 的行为相对稳定:规则、稳定背景、临时相关信息、压缩摘要,各走各的通道,互相不抢角色。

7. 真正的压缩发生在哪里

很多人一听「记忆系统」,第一反应是长期 memory 压缩。Claude Code 里最成熟的压缩逻辑,实际上落在 session memory 上。

前面说过,session memory 是 summary.md,它本身就是会话结构化摘要。维护逻辑在 sessionMemory.ts#L272-L350。

当上下文真的不够时,系统优先尝试 trySessionMemoryCompaction(),sessionMemoryCompact.ts#L514-L619。

它的动作顺序:

  1. 先确认 session memory 功能和 compact 功能都开着
  2. 等待正在进行中的 session memory 抽取结束
  3. 读取 summary.md
  4. 如果还是空模板,放弃,退回传统 compact
  5. 计算需要保留的 recent messages 窗口
  6. summary.md 的裁剪版构造 compact summary
  7. 组装 boundary + summary + recent messages + attachments + hooks

这里的「保留 recent messages」特别关键。作者没有图省事把所有旧消息都抹掉,而是保留一段最近窗口。窗口大小由 [DEFAULT_SM_COMPACT_CONFIG] sessionMemoryCompact.ts#L56-L66 定义,默认:

  • minTokens = 10000
  • minTextBlockMessages = 5
  • maxTokens = 40000

保留窗口的计算在 [calculateMessagesToKeepIndex] sessionMemoryCompact.ts#L324-L397。

这个策略解决的问题是:摘要永远会损失细节,最近一段工作现场最好保留原始消息,模型续做时不至于失真。要是所有内容都只剩 summary,模型会失去工具调用上下文、局部错误信息、最近的计划变更。

更细的一层防御在 adjustIndexToPreserveAPIInvariants()。 sessionMemoryCompact.ts#L232-L314

它干的事情很硬核,也很必要:如果最近保留窗口里出现了 tool_result,系统必须把匹配的 tool_use 也补进来;如果 assistant 消息因为流式输出被拆成多个共享 message.id 的块,thinking 和 tool_use 也要一起补齐。否则 compact 后发给 API 的消息链会断,直接报错。

这一段代码说明作者踩过坑,或者至少认真想过 API 侧不变量。很多开源 Agent 框架在消息压缩这里写得很草,最后线上 bug 都长一个样:tool result 找不到 parent,thinking 丢了,message 合并失败。

8. session memory 自己也会被裁剪

就算 summary.md 已经是摘要,compact 时还会再做一次 section 级裁剪。逻辑在 [truncateSessionMemoryForCompact] prompts.ts#L249-L295。

过程如下:

  • # section 拆段
  • 每个 section 允许的大小用 MAX_SECTION_LENGTH * 4 粗略换算成字符数
  • 超过就按行保留前半部分
  • 最后插入 [... section truncated for length ...]

实际截断函数见 [flushSessionSection] prompts.ts#L298-L324。

这套逻辑谈不上优雅,语义理解也谈不上深入,但它有一个优点:非常稳。系统真的到了上下文极限时,保底截断总比把整个 compact 失败掉强。工程里很多时候要的是「退化可接受」,不是「完美压缩」。

然后 createCompactionResultFromSessionMemory() 把裁剪后的 session memory 包成 summary message。 sessionMemoryCompact.ts#L437-L503

这里还有一个细节:如果发生过裁剪,它会额外附一句话,告诉模型和人类完整 session memory 文件路径在哪。排障时你可以直接打开原始 summary.md,不用猜裁掉了什么。

9. KAIROS 的压缩逻辑和普通模式不一样

KAIROS 里还有另一种「压缩」,它压的不是当前上下文,而是长期事件流。

在 memdir.ts#L321-L349 里能看到,KAIROS 模式下白天写的是 append-only daily log。到夜间,/dream 流程会把这些日志蒸馏成 topic files 和 MEMORY.md

这是另一类压缩:

  • 输入是时间顺序日志
  • 输出是主题化长期记忆

session memory compact 处理的是「上下文窗口」问题。KAIROS dream 处理的是「长期事件沉淀」问题。这两类压缩混在一起看会非常乱,源码里其实已经把它们分得很开。

10 小结

这套设计的工程代价与收益

10.1 一些值得学习的点

第一,分层彻底。长期记忆、当前轮召回、会话摘要、子代理记忆,各自有自己的存储形态和注入入口。系统复杂度是被隔离开的。

第二,文件优先。排查方便,审计方便,人工纠错方便。很多团队高估了数据库和向量库的必要性,低估了可观察性的重要性。

第三,动态召回走轻量 manifest + side query。对 CLI Agent 这种高频交互场景,这个方案的性价比很高。它把复杂度留给模型的小规模选择,而不是重型检索基础设施。

第四,压缩时保 recent window,并修补 tool_use/tool_result 不变量。这一点极少有团队一开始就写对。

10.2 一些代价

第一,frontmatter 的 description 质量变成关键依赖。这个字段一旦写烂,召回效果会大幅波动。它省掉了 embedding 的复杂度,也把一部分压力前置给写入质量。

第二,双通道写入意味着状态机会更复杂。主模型可以写,后台 extractor 也能写。虽然代码里有跳过逻辑,但这类架构天然比单通道更需要小心。

第三,session memory 的 section 截断是粗粒度的。它靠字符数近似 token,再按行截断,这属于保底工程,不属于精细压缩。能用,谈不上漂亮。

第四,MEMORY.md 仍然有索引容量压力。即便动态召回已经分担了很大一部分负担,入口索引的组织质量依然重要。

10.3 我们能用什么

如果把这套思路迁移到我们自己的 Agent 系统,可以借鉴(抄):

第一,先拆问题,再选技术。你要先决定自己在解哪件事:跨会话长期记忆、当前轮检索、超长对话压缩、团队共享经验。不要一上来就建一个统一 Memory API。

第二,先用文件,再考虑数据库。只要你的系统规模还没逼到那个份上,文件系统几乎总是更划算。它便宜、透明、好调试。很多团队用数据库,是因为觉得那样「更像正经系统」,这个判断没什么含金量。

第三,把召回质量的责任前移到写入阶段。Claude Code 用 description 做 manifest 检索这件事,给我的启发很大。与其指望后面靠复杂召回算法弥补,不如要求写入时就产出高质量摘要和类型信息。

如果你们团队正在做本地化的企业内 Agent,我甚至建议先抄一版这种架构原型:
长期记忆用 Markdown + frontmatter,当前轮召回走 manifest + 小模型筛选,会话压缩单独维护结构化 summary。三周内就能跑起来。比起一开始堆向量库、事件总线、关系数据库,这条路短得多。

11. 其它

Claude Code 的记忆系统没有神秘技术。它的难点不在某个单点算法,在边界控制和时机设计。什么时候写,写到哪里,什么时候读,读多少,压缩后保留什么,这些问题都比「用什么模型做召回」更重要。

如果你把这篇文章里的结论压成一句工程建议,那就是:
先把长期记忆、短期相关记忆、会话压缩拆开,再谈检索和存储。

源码入口可以优先读这几组文件:

  • 长期记忆规则与路径:
    [memdir.ts] [paths.ts] [memoryTypes.ts]

  • 记忆静态注入与动态召回:
    [claudemd.ts] [attachments.ts] [findRelevantMemories.ts]

  • 会话记忆与压缩:
    [sessionMemory.ts] [prompts.ts] [sessionMemoryCompact.ts]

  • 团队共享记忆:
    [teamMemPaths.ts] [teamMemorySync/index.ts]

以上。