Skip to content

FylloCode 知识主动沉淀

FylloCode 在 v0.14.1 上线了 knowledge 功能,可以主动发现并沉淀三类项目级知识:

类型定义典型条目谁能废弃
project关于本项目的事实架构不变量、坑(有锚点);业务/法务背景、口述历史(无锚点 + source有锚点:agent 验证后;无锚点:仅用户
reference关于第三方的事实框架文档总结、依赖实测行为(package 锚点)、外部 API 规律(url 锚点)agent 对照版本/保鲜期验证后
feedback用户发出的长期指令/纠正/强调(超出当前任务仍适用)"禁止直接跑 pnpm lint——缓存未命中时至少 3 分钟"、"不要采用 GPL 协议(法务)"只有用户能废弃

一次相当磨人的问题排查

这个工具的设定起源于一次 bug 排查。最终的解决很简单,在这里,但是实际耗费了我大半天的精力。

起初的原因很简单:在本地测试时,发现当单个会话中的对话轮次过多后,这个会话的页面加载会很慢。当时排查的对话大概有 15 turns,内容不算多,但是打开对话流需要至少 20 秒。

为了解决这个问题,我在 FylloCode 中拉起 Codex 协助我做排查。先是读取了 nuxt/ui 的 ChatMessages 组件相关的源码,因为 FylloCode 的对话流主要使用它来做构建,加载慢首先怀疑到是组件的问题。读取了完整的组件源码,并做了一些尝试性修复后,问题并没有解决。

然后开始用 Electron 的 Dev tools 对页面打开做 Record,观察火焰图。经过多轮测试后,最终定位到是 MarkStream 组件导致的耗时。这个组件是对 markstream-vue markdown 渲染库的一次轻量封装,本身没有什么业务逻辑。

在 Codex 深入排查 markstream-vue 源码的同时,我打开了官方文档,尝试从文档中找一些解决方案。markstream-vue 有很强的可定制性,于是针对参数调整又做了一轮尝试,依然无功而返,效果没有提升很多。在整个排查过程中,Agent 一直在输出类似于"这不对劲""这不应该""源码中已经做了优化,不会出现这类严重耗时"等等。

我决定让 Codex 和我一起搜索采用相同或类似技术栈的其他项目,我们发现了 deepchat。这是一个 6k star 的项目,技术栈和我们很相近:同样是 electron-vite + typescript 的桌面端,也支持 ACP,最重要的是,它的 markdown 渲染用的也是 markstream-vue。我让 Codex 按照 deepchat 对 markdown 的解析链路做分析,尝试做一次逻辑复刻,看能否解决 FylloCode 的问题。但其实 deepchat 没有做太多定制化,依然没有解决根本问题。

到这里已经耗费了很多时间、很多 token,问题还没有解决。于是我开始从头梳理,尝试用方法论对问题可能的发生点做大致定位:deepchat 是同技术栈项目,如果它有这类渲染问题,一定会有很多人反馈,但其实并没有,那为什么 FylloCode 会有如此严重的耗时?逐项对比下来,deepchat 在解析 assistant 输出时和 FylloCode 一样,都只用 markstream-vue 做纯文本渲染;发生工具调用时,deepchat 是自定义组件,而 FylloCode 用的是 nuxt/ui 的 ChatTool 组件。其他的也没什么不同。

等等,工具调用。这就是 deepchat 和 FylloCode 最大的不同。deepchat 的定位是一款通用的桌面端 Agent,支持集成云端 API 和本地 Agent,而 FylloCode 更面向产研团队做工程。由于这种差异,FylloCode 在单次 turn 中天然会比 deepchat 发生更多的工具调用,因为要对工程做调研、读取、搜索。这意味着 FylloCode 对话页面的 dom 节点会被不断分隔:每个工具调用就有一个 ChatTool,中间过程的文本内容(哪怕只有一句话)又会用 MarkStream 来渲染,所以 FylloCode 的对话流会产生多得多的节点。

15 次 turns 一共产生了近 400 个 markstream-vue 节点。再结合之前的火焰图分析,Codex 最终定位出根本原因:MarkStream 组件在封装时,为了做深浅模式适配,内部引入了 vueuseuseDark 函数,而由于 MarkStream 渲染的数量过多,在 flushJobs 期间频繁触发全局样式重算,最终导致了这个耗时问题。解决起来很简单,把 useDark 移到对话列表,MarkStream 组件通过 props 接收,问题就解决了,测试会话的加载时间从 20s 降到了 2s 左右。

关闭对话框后的考虑

事后我就在考虑,其实在这个过程中,有很多可以沉淀下来的知识和能力,如果不做点什么东西,这次排查 bug 的会话一旦关闭,所有东西都会消失掉。虽然 Plan 和 Proposal 的能力一直在规划中,但是这次耗时、耗精力、耗 token 的排查任务,也无法进入 Plan 和 Proposal。因为它不属于一个相对复杂的规划,问题排查需要频繁试错,也没有改动公共契约,例如用户可见行为、架构规范、页面或文件能力等。所以,会话关闭,Agent 在这个过程中的一些探索就全部白费了,下一次遇到类似的问题需要从头开始。

所以我开始考虑构建一个可以在对话过程中主动沉淀知识的工具。

在真正开始做之前,我深入研究了三款 Coding Agent 的记忆机制,了解了他们是怎么实现的记忆,在研究过程中也看到了很多社区的反馈,综合这些考虑,knowledge tool 的设计方向也逐渐浮现出来了。

研究下来的结论是:不能照搬 memory。拿 Claude Code 来说,它的 memory 每条都很短,写入即生效,也不需要审阅,过时了就地删改。而我想沉淀的东西不一样,比如这次排查的产出,本身就是长的、结构化的,写错了会影响后续的新会话,所以写入前得让用户看一眼。前提变了,机制就得重新想。下面把设计过程里几个纠结比较久的地方记录下来。

什么才算 knowledge

最先要考虑的是准入问题。如果什么都能进 knowledge,它很快会变成一份劣化的仓库摘要,而仓库本身永远是更权威的那份。所以我给 knowledge 定了一条硬性原则:未来的 Agent 能不能在合理的 token 预算内,从仓库、guidelines、lineage、git log 里推出同样的结论?能推出来的,都不能算作 knowledge。

这条原则筛下来,knowledge 只剩两类东西。

一类是昂贵推论的缓存。结论本身可以从仓库推出来,但是推导成本很高。比如"对话流里每个 text-part 都是一个独立的 MarkStream 实例,长对话会产生几百个实例,这条渲染路径对单个实例的开销非常敏感",想重新得出这个结论,得再跑一轮火焰图。

另一类是推不出来的事实。任何仓库扫描都得不到的内容,比如"不要采用 GPL 协议的依赖,法务原因"。这句话不写下来,Agent 永远不可能自己知道。

关于 knowledge 的分类

projectreference 很早就定了,麻烦的是第三类。

最开始它叫 preference,当时的分类思路是按"什么会让这条知识过期":project 随代码演进过期,reference 随依赖版本过期,preference 只有人改主意才过期。但 preference 装不下"法务不允许 GPL"这种东西,它不是偏好,是只有人能断言的项目事实。于是改名叫 context,扩成一个装偏好、业务约束、环境现实、口述历史的大桶。

然后又发现 context 太大了。什么都能叫 context,一个什么都能装的名字会让归类失去引导力。后来对照 Claude Code memory 的四个类型(user、project、feedback、reference)重新想了一遍,最后定为 feedback

  • 只装用户发出的长期指令,也就是超出当前任务仍然适用的那种。"禁止直接跑 pnpm lint,缓存未命中要 3 分钟"算,"把这个函数改成 X"不算,后者只是普通的任务命令。
  • 每条 feedback 必须连理由一起记。理由是它将来被正确复议的依据,哪天 lint 提速了,用户看到理由就知道这条可以废掉。
  • Claude Code 的 user 类型被排除掉了。FylloCode 是工程工具,不需要为用户本人建模。

这轮反复还带出一个更重要的变化:过期语义从类型下沉到了锚点。真正决定一条知识什么时候过期的不是类型,而是条目自己带的锚点。文件锚点存内容摘要(SHA-256),依赖锚点存解析后的版本,外部资料锚点存核验时间。有锚点的条目可以机械地检查新鲜度,写不出锚点的必须注明出处,只凭人的话废弃。类型退回纯粹的语义分组,过期检查从"重新推理一遍"变成了指纹比对。

knowledge 的存储位置

guidelines 是进仓库的,因为我考虑哪怕用户不使用 FylloCode,仓库内的文件也是可以被人和 Coding Agent 共享。那 knowledge 要不要也进?我在这里停了很久,最后决定不进,放在 app data 的项目级目录里。理由有三个:

  1. worktree 一致性。FylloCode 重度使用 worktree,仓库内的文件落在分支上,要等合并才对其他会话可见,分支丢弃知识就没了。app data 对所有窗口和 worktree 立即可见。
  2. 虚假权威。进了仓库的内容自带"已审定"的气场。guidelines 配得上,它是刻意维护团队的规范;knowledge 有一大半是待验证的推论,或者是更偏向用户个人的沉淀。
  3. PR 噪音。knowledge 是高频的会话副产品,混进功能 PR 会增加评审负担。

存储结构也删过一轮。中间版本设计了 entries/staged/ 两个子目录,一个正式区一个候选区,后来发现候选根本不需要目录。最终的形态是知识目录里的文件就是知识本身,扁平一层,只有一个写入方,"目录里的东西都经过用户审阅"这条不变量靠结构本身保证,不需要任何字段或过滤逻辑来维护。

何时触发 knowledge

这是整个设计里想得最久的地方,因为三个约束互相顶着。

knowledge 比 memory 长得多,写入前要告知、写入后要审阅。如果在过程中 Agent 发现可沉淀的知识就立刻开始,不止会打断用户的注意力,也会影响 Agent 的 Context。但是推迟到任务完全结束也不行,如果用户接着让 Agent 干了别的,注意力一转移,Agent 就把要沉淀的事“忘了”。至于 Codex 那种后台整理会话来形成记忆的方式,我明确不要,后台悄悄跑 LLM,用户的第一反应会是“FylloCode 在偷我的 token”。

想来想去,发现当我考虑的限制越多,问题的真正解法也就越靠近。既然我担心的都是 LLM 的注意力机制,那何不如反过来利用这种机制。会话开始时就引导 Agent 尝试发现可沉淀的知识,一旦出现这种信号,做一次很轻量的工具调用,FylloCode 可以在对话流中做一个很小的 UI 提醒,类似于读书时在这个位置标记了一个“书签”。书签在,后续当用户再提及的时候,Agent 可以把注意力拉会这个位置。

这样就会把这个单一问题分解为两步,“发现”和“撰写”,同时这个分解直接就可以解决透明化的问题,整个过程用户都可见。然后中间用应用持久化的状态来桥接,所有 LLM 调用都发生在用户看得见的前台对话里。

  • flag 阶段:信号出现的瞬间,Agent 只发一个 knowledge.flag,一句话候选加几个文件指针,百来个 token,然后接着干活。它还不是知识,只是个书签,不需要审阅,错了丢掉就是。什么算信号?常见的有四种:现实推翻了合理预设,也就是 Agent 发现自己在写"原来"、"turns out"的时刻,那次排查里 Codex 反复说"这不对劲",就是最典型的信号;一次排查或者长阅读,产出远小于读入量;用户发出长期指令;用户说出仓库里查不到的背景。当然形态列不全,兜底判据只有一句话:这条信息丢了,未来的会话要不要为它付出代价?
  • capture 阶段:真正的撰写推迟到用户主动触发。没处理的 flag 会一直留在会话的 EventRail 里,这本身就是提醒。用户想处理的时候点一下,FylloCode 组装一条普通的 user 消息发给 Agent,花 token 这件事的授权方式和平时聊天没有区别。Agent 拿着候选清单逐条过准入测试,淘汰的给出理由,留下的扩写成完整条目,最后打包成一张 knowledge.review 卡片,用户在一个面板里批量审阅,确认后由工程代码落盘。每个会话最多打扰用户一次。

这套两阶段有一个刻意的偏置:把 flag 的门槛做得很低,很轻量,只要有点像就记。假阳性有两道低成本的过滤兜着,capture 时 Agent 会复审,用户还要审卡片;假阴性就没救了,该记没记的信号是这个系统里唯一无法挽回的错误。

显式创建,显式审阅

capture 时 Agent 要对每条候选过五道准入测试:

  • 推不出来或者推起来很繁琐昂贵;
  • 有复用场景;
  • 所有权正确,用户的长期指令才是 feedback,Agent 自己推导出的约定要分流去 guidelines;
  • 结论已经过验证;
  • 还有一条专门为排查类候选设的,修复之后还成立吗。

典型会话的正确产出是零条,这几条规则都通过反而说明在走过场。

审阅通过后的落盘完全是工程代码的事。knowledge.review 的 payload 传结构化字段,不传任意 Markdown 全文,由 handler 生成文件、防路径逃逸、原子写入、按条目幂等重试。这和 Lineage 那篇说的是同一条原则:Agent 负责判断、分析和表达,链路完整性和数据安全交给工程系统。

最终的实现

v0.14.1 里,这套机制由几部分组成:

  • 两个 fyllo-action:knowledge.flag(被动渲染,未处理项留在 EventRail)和 knowledge.review(审阅卡片);
  • 一个 MCP tool:knowledge,四个 mode,capture 拿撰写指引和现有索引,update 修订过期条目,retire 废弃,audit 批量体检;
  • 一个派生索引:每次会话开始时扫描全部条目的 frontmatter,给 system-reminder 注入一个 <knowledge> 块,只带名字、一行钩子文案和新鲜度状态,每条 30 token 左右。状态是注入时实时算的,锚点指纹比对得出 activesuspectunknown,标了 suspect 的条目 Agent 用之前会先验证;
  • 知识文件本身:app data 下的扁平 Markdown 目录,frontmatter 带类型、锚点指纹和出处,正文写事实、原因,以及什么情况下它会被证伪。

索引选择派生,而不是像 Claude Code 似的维护一份 MEMORY.md 式的清单。手写索引和实体文件会漂移,Claude Code 就需要靠 consolidate 做事后治理;派生索引是文件系统的机械投影,不可能漂移,每次注入时还能带上实时计算的新鲜度。

它能带来什么

回到开头那次排查。如果当时就有 knowledge,那半天的产出应该是这样:

产出去向
修复本身(useDark 上移)git commit,本来就有
"不要在被大量实例化的叶子组件里直接订阅全局响应式 composable"guideline,成为团队规范
"对话流每个 text-part 一个 MarkStream 实例,长对话几百个,此路径对单实例开销非常敏感"project knowledge,锚点挂相关组件
修复的因果模型(为什么容器下传能解决)project knowledge,防止未来被"好心重构"回去
markstream-vue 的文档消化reference knowledge,锚点挂依赖版本

现实是这些全部随会话关闭消失了。后来另一个会话要改 markstream-vue,又把文档从头读了一遍,这正是 knowledge 要省的那类开销。一次命中省下的重复探索,够覆盖索引注入几十个会话的成本。

对团队来说收益是很具体的:排查过的坑,下次直接查到结论;读过的文档,下个会话不用重读;用户强调过的规矩,换个会话也不会被忘掉。还有那些看起来奇怪但事出有因的代码,不会被随手清理。

自进化的三大腿

knowledge 不是一个孤立的功能,它补上的是 FylloCode 知识体系的第三块。三个工具各管一件事:

  • guideline 管“应该怎么做”,是规范,进仓库走 PR;
  • knowledge 管“什么是真的、为什么”,是事实和因果,带锚点和出处,随验证更新;
  • lineage 管“这一切从哪来”,是任务、会话、Proposal 到 commit 的因果链。

三者之间也有流动的通道。knowledge 里被反复引用、值得成文的条目可以晋升为 guideline;lineage 覆盖不到的地方,比如不走 Proposal 的直接修复,由 knowledge 兜住因果模型;Agent 照着 guideline 干活的过程中产生新的发现,又流回 knowledge。

有了这三个工具,Coding Agent 在项目里就有了自进化的条件:每次会话在消费已有的规范、事实和脉络,同时也在生产新的。项目在长功能,Agent 对项目的理解也在跟着长,这份理解不再随会话关闭而蒸发。

设计过程中的每次纠结和反转都留在了仓库的 references/designs/knowledge-tool/ 里。按 knowledge 自己的标准,那份文档正好就是一条合格的 project knowledge。

基于 MIT 发布